diff --git a/docs/research/research-package-guide.md b/docs/research/research-package-guide.md index 1a70e63c75..088978e061 100644 --- a/docs/research/research-package-guide.md +++ b/docs/research/research-package-guide.md @@ -339,3 +339,57 @@ Tekalign, with substantial OpenAI Codex assistance. Apache-2.0. The exact HEAD/index fallback premise follows Arham-Tahir64's PR3102 sparse-reference work; this applies it to replay declarations. The original planner, canonical Git spelling, symlink and inventory-error controls retain their predecessor credit. + +## Historical source/credit receipt adapters + +Two source/credit receipts now register an explicit historical-only wrapper: +`research/pr1-retention-receipt` and +`research/pr636-family-witness-manifest-receipt`. Their original package bytes +are frozen at `20ecf2891d80b5acf18d980b2164fb44ea7faa90`, with original input +bases `d8e0f83f00191df62174b641e9fff055c357f641` and +`1f7a934975e5eacaf83511c9ac870263bf095e05`, respectively. Later additions to +CONTRIBUTORS.md and the ledger must not be rolled back to satisfy those old +live-input pins. + +Their current SOURCES.json retains each original commit/blob/SHA256 pin as +historical provenance. Current VERIFY.json and README.md explicitly describe +the historical scope. The adapter permits only the exact reviewed transformation +of those three files; current verifier, evidence and other package bytes must +still equal the frozen original. This is a two-package policy, not a fallback +for arbitrary stale live pins. + +```sh +python3 -B scripts/replay_historical_receipt.py --package research/pr1-retention-receipt +python3 -B scripts/replay_historical_receipt.py --package research/pr636-family-witness-manifest-receipt +python3 -W error -B -m unittest discover -s tests -p 'test_research_historical_receipts.py' -v +``` + +CI must fetch full Git history (`fetch-depth: 0`). Required historical commit, +tree and blob objects must be locally available; missing objects fail closed, +and this adapter never fetches them. The adapter authenticates exact +commit/path/blob identities and SHA256 pins, then copies only the original +package, source bytes and required Git objects into a private temporary tree. +It shares no Git object store, config, refs, hooks, worktree or alternate with +the live repository. Replacement objects and inherited Git configuration cannot +substitute history; legacy grafts, symlinks and path aliases are rejected. +The unchanged generic validator and unchanged original commands run there, +including the PR1 positive/twelve failure controls and PR636 positive/four +failure controls. Original per-command timeouts and Python cache isolation +remain in force. The temporary tree is removed after success or failure. + +Success requires final unchanged-file checks for original materialized files +and live package/input bytes, and emits `PASS_HISTORICAL_RECEIPT_ONLY` with both +commit IDs and `live_inputs_validated=false`. `--check-only` authenticates only +metadata and pins, emitting `PASS_HISTORICAL_METADATA_ONLY`; it runs no verifier. +Missing history, changed pins, unapproved wrapper/code/evidence changes or +failed controls exit nonzero. This is not a sandbox against arbitrary untrusted +code, an atomic snapshot of a changing checkout, a current-state validation, +a new scientific acceptance or a PR closure decision. + +The current registered wrappers also run the focused regression suite and +declare its file and the adapter as inputs. Their edits therefore invalidate +these two consumers without changing the shared planner or workflows. All +other packages retain normal live-input validation. The original manifests +remain immutable in Git; running their original live contract against later +input bytes still fails closed as before. Existing contributor credit, +scientific evidence and selected-frontier obligations are unchanged. diff --git a/research/pr1-retention-receipt/README.md b/research/pr1-retention-receipt/README.md index 16bd447014..bbaff8b068 100644 --- a/research/pr1-retention-receipt/README.md +++ b/research/pr1-retention-receipt/README.md @@ -154,3 +154,33 @@ this package follows. Prepared by **Aaron Wang**, with help from opencode (substantial AI assistance; no model named; no independent human review claimed). Apache-2.0. Predecessor licenses and notices remain in force. + +## Explicit historical-only registered replay + +The registered command now authenticates and replays the original package at +`20ecf2891d80b5acf18d980b2164fb44ea7faa90` against its original pinned base. +It does not validate today's ledger, contributor list, or other live input +bytes. The original SOURCES.json, VERIFY.json, verifier and evidence are read +unchanged from that commit into an isolated temporary tree. Current source +rows keep their exact original commit/blob/SHA256 pins as historical provenance. + +Only these three wrapper files (README.md, SOURCES.json and VERIFY.json) may +differ from the frozen package, and the adapter checks their exact approved +transformation. All current verifier and evidence bytes must still equal the +original tree. The original positive and negative controls run without edits; +materialized and live package/input files must remain unchanged afterward. +`PASS_HISTORICAL_RECEIPT_ONLY` is a historical source/credit result, not a +current-state validation, new scientific acceptance, or closure decision. + +Run the historical adapter with Python 3.12: + +```sh +python3 -B scripts/replay_historical_receipt.py --package research/pr1-retention-receipt +``` + +The generic runner's default live-input contract is unchanged. To inspect the +original live contract, replay the original frozen package metadata against live +inputs: it correctly rejects later byte drift. The registered wrapper declares +the adapter and its focused tests as inputs, so changes select these two +consumers without a global shared-workflow invalidation. See +[the replay guide](../../docs/research/research-package-guide.md#historical-sourcecredit-receipt-adapters). diff --git a/research/pr1-retention-receipt/SOURCES.json b/research/pr1-retention-receipt/SOURCES.json index ac94245585..1e41734053 100644 --- a/research/pr1-retention-receipt/SOURCES.json +++ b/research/pr1-retention-receipt/SOURCES.json @@ -4,31 +4,31 @@ "files": [ { "path": "CITATION.cff", - "usage": "input", + "usage": "provenance", "git_blob": "574b2108bc40b5799f5a8c54fa3519d8aea51892", "sha256": "4b17b8cff777e2f619afc47d7421e49e9309bdff9f8fe2f185fc774ff18a25e1" }, { "path": "CONTRIBUTORS.md", - "usage": "input", + "usage": "provenance", "git_blob": "6431453337f4ff2fec9b560decce3267f443ea5a", "sha256": "e16a737af455f68cf329710bf9bc48ec1728101cbeaf1519dd7aef0fbafad172" }, { "path": "Makefile", - "usage": "input", + "usage": "provenance", "git_blob": "02055437d2b90c705e8b82d2ef2b58633101ca7d", "sha256": "cbcc8cb5e977970fce52ad0b2e4440eb6b6a9c9e87fa1bcfd6af11a16f9344b7" }, { "path": "NOTICE", - "usage": "input", + "usage": "provenance", "git_blob": "59d8e0cf3102e15bd11db430838365531e2d7f18", "sha256": "b49022cb4104d90b182e4c335f670faf45529025b15db77bf0291835083c28bd" }, { "path": "README.md", - "usage": "input", + "usage": "provenance", "git_blob": "74f49a5eca5910b915cb8446462c081c0372b141", "sha256": "00162212b6ae88b02330d824c8d1469ef41097c88b91e32b0876f83d29a94caa" }, @@ -40,7 +40,7 @@ }, { "path": "docs/reproducibility.md", - "usage": "input", + "usage": "provenance", "git_blob": "ce620954a86c0b6688859a7421441fc97a3d9259", "sha256": "333f87bf13c4aa911af89d278923ea84bfd091966e23f5fb1332e83aa4565db9" }, @@ -52,19 +52,19 @@ }, { "path": "docs/research/pr-ledger.json", - "usage": "input", + "usage": "provenance", "git_blob": "f5ce7e4c467d3e9bfe1d16a1524228b11c655769", "sha256": "f36486250793e396bebe63f51adafcee82fe28a4a0c7e9a40cf6b5d1713cb066" }, { "path": "research/overnight-pr-review-20261009/dispositions.json", - "usage": "input", + "usage": "provenance", "git_blob": "ed53da272d6dc016420f2a6a75321e9c18fb5bc1", "sha256": "9dd0264e667551852a7d6747df490e3e542465caf347d9f43998c6f0665faae9" }, { "path": "research/overnight-pr-review-20261009/source-inventory.json", - "usage": "input", + "usage": "provenance", "git_blob": "ab2e653500b152ca672d8651d2c4476d5672d9fb", "sha256": "0dc9bc5fc0da37f4442014e17106823f24c4ecaf36b144643fc5684474ac2261" } diff --git a/research/pr1-retention-receipt/VERIFY.json b/research/pr1-retention-receipt/VERIFY.json index a7d74ba05a..f1d0c4f5cf 100644 --- a/research/pr1-retention-receipt/VERIFY.json +++ b/research/pr1-retention-receipt/VERIFY.json @@ -12,30 +12,37 @@ "docs/reproducibility.md", "docs/research/pr-ledger.json", "research/overnight-pr-review-20261009/dispositions.json", - "research/overnight-pr-review-20261009/source-inventory.json" + "research/overnight-pr-review-20261009/source-inventory.json", + "scripts/replay_historical_receipt.py", + "tests/test_research_historical_receipts.py" ], "commands": [ { "argv": [ "{python}", - "-I", - "-O", "-B", - "{package}/verify.py" + "scripts/replay_historical_receipt.py", + "--package", + "{package}" ], - "timeout_seconds": 60, - "checks": "Authenticate the 11 vendored PR1 head files against their recorded live REST blob identities, reparse every changed file's REST patch hunk-exactly against the API additions field, classify each path at the base commit, recompute the multiset survival of PR1's added lines in the five shared files, exclude byte-identical copies of the six new files in the exact base-tree enumeration, and verify credit against CONTRIBUTORS.md, NOTICE, README.md, CITATION.cff, the ledger and the 2026-10-09 review records." + "timeout_seconds": 480, + "checks": "Historical-only replay: authenticate the exact frozen package and original source pins, run unchanged original commands and failure controls in an isolated Git snapshot, and require historical/live bytes unchanged; does not validate today's input bytes." }, { "argv": [ "{python}", - "-I", - "-O", "-B", - "{package}/test_verify.py" + "-m", + "unittest", + "discover", + "-s", + "tests", + "-p", + "test_research_historical_receipts.py", + "-v" ], "timeout_seconds": 120, - "checks": "One real-evidence positive case and twelve failure controls over vendored bytes, REST blob identities, patch parse counts, base-file content, the CITATION.cff convention line, the CONTRIBUTORS credit row, a forged tree copy, the credit enumeration, both 2026-10-09 review records and the expected result itself." + "checks": "Focused historical-adapter regression controls for wrong history/pins, path aliases, missing inputs, wrapper/code/evidence drift, isolation and unchanged-file gates; the generic live-input validator remains strict." } ] } diff --git a/research/pr636-family-witness-manifest-receipt/README.md b/research/pr636-family-witness-manifest-receipt/README.md index 466012cb30..e12564ddab 100644 --- a/research/pr636-family-witness-manifest-receipt/README.md +++ b/research/pr636-family-witness-manifest-receipt/README.md @@ -170,3 +170,33 @@ which audited a different family. Prepared by Aaron Wang / navinor with help from opencode. Substantial AI assistance is disclosed; no independent human review is claimed. Apache-2.0. + +## Explicit historical-only registered replay + +The registered command now authenticates and replays the original package at +`20ecf2891d80b5acf18d980b2164fb44ea7faa90` against its original pinned base. +It does not validate today's ledger, contributor list, or other live input +bytes. The original SOURCES.json, VERIFY.json, verifier and evidence are read +unchanged from that commit into an isolated temporary tree. Current source +rows keep their exact original commit/blob/SHA256 pins as historical provenance. + +Only these three wrapper files (README.md, SOURCES.json and VERIFY.json) may +differ from the frozen package, and the adapter checks their exact approved +transformation. All current verifier and evidence bytes must still equal the +original tree. The original positive and negative controls run without edits; +materialized and live package/input files must remain unchanged afterward. +`PASS_HISTORICAL_RECEIPT_ONLY` is a historical source/credit result, not a +current-state validation, new scientific acceptance, or closure decision. + +Run the historical adapter with Python 3.12: + +```sh +python3 -B scripts/replay_historical_receipt.py --package research/pr636-family-witness-manifest-receipt +``` + +The generic runner's default live-input contract is unchanged. To inspect the +original live contract, replay the original frozen package metadata against live +inputs: it correctly rejects later byte drift. The registered wrapper declares +the adapter and its focused tests as inputs, so changes select these two +consumers without a global shared-workflow invalidation. See +[the replay guide](../../docs/research/research-package-guide.md#historical-sourcecredit-receipt-adapters). diff --git a/research/pr636-family-witness-manifest-receipt/SOURCES.json b/research/pr636-family-witness-manifest-receipt/SOURCES.json index 5483551a76..a4d17b3e21 100644 --- a/research/pr636-family-witness-manifest-receipt/SOURCES.json +++ b/research/pr636-family-witness-manifest-receipt/SOURCES.json @@ -16,13 +16,13 @@ }, { "path": "docs/research/pr-ledger.json", - "usage": "input", + "usage": "provenance", "git_blob": "20df7e36f5378796816db70aa755f281f550b45a", "sha256": "905c5efdf20883dbc212b936bde516c192017869754bcbd4bca9052f03c0f07e" }, { "path": "research/larger-gain/MANIFEST.json", - "usage": "input", + "usage": "provenance", "git_blob": "c8c22f51ca33156f696b48ea8214ef2ffcc13f00", "sha256": "73c186c2807c703e43293c7cf785557a046958c245fa812e433f7fe208cdef6d" } diff --git a/research/pr636-family-witness-manifest-receipt/VERIFY.json b/research/pr636-family-witness-manifest-receipt/VERIFY.json index abe86c1999..277e485ecd 100644 --- a/research/pr636-family-witness-manifest-receipt/VERIFY.json +++ b/research/pr636-family-witness-manifest-receipt/VERIFY.json @@ -5,17 +5,37 @@ "requirements": [], "inputs": [ "docs/research/pr-ledger.json", - "research/larger-gain/MANIFEST.json" + "research/larger-gain/MANIFEST.json", + "scripts/replay_historical_receipt.py", + "tests/test_research_historical_receipts.py" ], "commands": [ { "argv": [ "{python}", "-B", - "{package}/verify.py" + "scripts/replay_historical_receipt.py", + "--package", + "{package}" + ], + "timeout_seconds": 480, + "checks": "Historical-only replay: authenticate the exact frozen package and original source pins, run unchanged original commands and failure controls in an isolated Git snapshot, and require historical/live bytes unchanged; does not validate today's input bytes." + }, + { + "argv": [ + "{python}", + "-B", + "-m", + "unittest", + "discover", + "-s", + "tests", + "-p", + "test_research_historical_receipts.py", + "-v" ], "timeout_seconds": 120, - "checks": "Drift identity of the two committed larger-gain MANIFEST copies against their recorded digests and against the working tree's research/larger-gain/MANIFEST.json; per-PR pin verdicts and retention counts recomputed from the per-PR audit rows; every changed-path retention class re-derived against git ls-tree -r ; cross-PR shared-blob counts recomputed; one positive fixture of the classification rule; four failure controls (tampered digest, substituted manifest diff, omitted row, substituted class) that must each be rejected." + "checks": "Focused historical-adapter regression controls for wrong history/pins, path aliases, missing inputs, wrapper/code/evidence drift, isolation and unchanged-file gates; the generic live-input validator remains strict." } ] } diff --git a/scripts/replay_historical_receipt.py b/scripts/replay_historical_receipt.py new file mode 100644 index 0000000000..b98c09457b --- /dev/null +++ b/scripts/replay_historical_receipt.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""Explicit historical-only replay of two frozen source/credit receipts. + +The generic live-input validator is unchanged. This adapter authenticates and +isolates original bytes; success never claims that today's inputs match them. +Prepared with substantial OpenAI Codex assistance. Apache-2.0. +""" +import argparse +import copy +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile + +import research_package as replay + +ROOT = Path(__file__).resolve().parents[1] +FROZEN_COMMIT = "20ecf2891d80b5acf18d980b2164fb44ea7faa90" +# Changes to this explicit policy require review. No automatic historical +# fallback exists for other packages or for the default live validator. +POLICY = { + "research/pr636-family-witness-manifest-receipt": { + "package_commit": FROZEN_COMMIT, + "package_tree": "23693de73801a0987c80fb0eed1c8e45a5206890", + "base_commit": "1f7a934975e5eacaf83511c9ac870263bf095e05", + "manifest_sha256": { + "SOURCES.json": "9f18c9391184a197b338a1b3c7b43ddc571b2c49e5aac0f283e9e8e76d7d14f5", + "VERIFY.json": "18a182e83005ee6b9745037f524098dbd8b28b389a6213ef98b1010f3e8adb38", + }, + "inputs": ("docs/research/pr-ledger.json", "research/larger-gain/MANIFEST.json"), + "git_inputs": ("research/larger-gain/NOTICE", "research/larger-gain/README.md"), + }, + "research/pr1-retention-receipt": { + "package_commit": FROZEN_COMMIT, + "package_tree": "334d46aae47e139ca26229f8bf6e7d6556e585c8", + "base_commit": "d8e0f83f00191df62174b641e9fff055c357f641", + "manifest_sha256": { + "SOURCES.json": "8460f7af7e1b89f2389fcb1870eec63fc220d65ff6b0db41d0e5e735ca3c524d", + "VERIFY.json": "e070bc8ffc93911bdfc669671063f23dae1f0c290d5c72c82e1992a07beb4252", + }, + "inputs": ("CITATION.cff", "CONTRIBUTORS.md", "Makefile", "NOTICE", "README.md", + "docs/reproducibility.md", "docs/research/pr-ledger.json", + "research/overnight-pr-review-20261009/dispositions.json", + "research/overnight-pr-review-20261009/source-inventory.json"), + "git_inputs": (), + }, +} + + +ADAPTER = "scripts/replay_historical_receipt.py" +REGRESSION = "tests/test_research_historical_receipts.py" +WRAPPER_NOTICE = """ +## Explicit historical-only registered replay + +The registered command now authenticates and replays the original package at +`20ecf2891d80b5acf18d980b2164fb44ea7faa90` against its original pinned base. +It does not validate today's ledger, contributor list, or other live input +bytes. The original SOURCES.json, VERIFY.json, verifier and evidence are read +unchanged from that commit into an isolated temporary tree. Current source +rows keep their exact original commit/blob/SHA256 pins as historical provenance. + +Only these three wrapper files (README.md, SOURCES.json and VERIFY.json) may +differ from the frozen package, and the adapter checks their exact approved +transformation. All current verifier and evidence bytes must still equal the +original tree. The original positive and negative controls run without edits; +materialized and live package/input files must remain unchanged afterward. +`PASS_HISTORICAL_RECEIPT_ONLY` is a historical source/credit result, not a +current-state validation, new scientific acceptance, or closure decision. + +Run the historical adapter with Python 3.12: + +```sh +python3 -B scripts/replay_historical_receipt.py --package {package} +``` + +The generic runner's default live-input contract is unchanged. To inspect the +original live contract, replay the original frozen package metadata against live +inputs: it correctly rejects later byte drift. The registered wrapper declares +the adapter and its focused tests as inputs, so changes select these two +consumers without a global shared-workflow invalidation. See +[the replay guide](../../docs/research/research-package-guide.md#historical-sourcecredit-receipt-adapters). +""" + + +def wrapper_files(files, package): + """Exact, reviewable transformation; never accept arbitrary metadata drift.""" + sources = json.loads(files[package + "/SOURCES.json"][1]) + for row in sources["files"]: + row["usage"] = "provenance" + original = json.loads(files[package + "/VERIFY.json"][1]) + manifest = copy.deepcopy(original) + manifest["inputs"] = [*original["inputs"], ADAPTER, REGRESSION] + manifest["commands"] = [ + {"argv": ["{python}", "-B", ADAPTER, "--package", "{package}"], + "timeout_seconds": 480, + "checks": "Historical-only replay: authenticate the exact frozen package and original source pins, " + "run unchanged original commands and failure controls in an isolated Git snapshot, " + "and require historical/live bytes unchanged; does not validate today's input bytes."}, + {"argv": ["{python}", "-B", "-m", "unittest", "discover", "-s", "tests", + "-p", "test_research_historical_receipts.py", "-v"], + "timeout_seconds": 120, + "checks": "Focused historical-adapter regression controls for wrong history/pins, path aliases, " + "missing inputs, wrapper/code/evidence drift, isolation and unchanged-file gates; " + "the generic live-input validator remains strict."}, + ] + result = {package + "/" + name: ("100644", (json.dumps(value, indent=2) + "\n").encode()) + for name, value in (("SOURCES.json", sources), ("VERIFY.json", manifest))} + readme = files[package + "/README.md"][1] + result[package + "/README.md"] = ("100644", readme + WRAPPER_NOTICE.format(package=package).encode()) + return result + + +def need(condition, message): + if not condition: + raise ValueError(message) + + +def safe_path(name): + path = replay.canonical_relative_path(name) + need(".git" not in [part.lower().rstrip(" .") for part in path.parts] + and not name.startswith(":") and not any(ord(char) < 32 for char in name), "unsafe historical path: " + name) + return path + + +def git_environment(): + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update(GIT_NO_REPLACE_OBJECTS="1", GIT_CONFIG_NOSYSTEM="1", + GIT_CONFIG_GLOBAL=os.devnull, GIT_OPTIONAL_LOCKS="0", GIT_NO_LAZY_FETCH="1") + return env + + +def git(root, *args, **kwargs): + return subprocess.run(["git", "--no-replace-objects", "--literal-pathspecs", *args], + cwd=root, env=git_environment(), check=True, timeout=60, + stderr=subprocess.PIPE, **kwargs) + + +def output(root, *args): + return git(root, *args, stdout=subprocess.PIPE).stdout + + +def object_bytes(root, oid, kind): + need(isinstance(oid, str) and re.fullmatch(r"[0-9a-f]{40}", oid), "invalid historical object ID") + need(output(root, "cat-file", "-t", oid).strip() == kind.encode(), "wrong Git object type: " + oid) + raw = output(root, "cat-file", kind, oid) + actual = hashlib.sha1(kind.encode() + b" " + str(len(raw)).encode() + b"\0" + raw).hexdigest() + need(actual == oid, "Git object bytes do not match their ID: " + oid) + return raw + + +def tree_rows(root, commit, path=None, recursive=False): + argv = ["ls-tree", "-z"] + (["-r", "-t"] if recursive else []) + [commit] + if path is not None: + safe_path(path) + argv += ["--", path] + rows = {} + for record in output(root, *argv).split(b"\0"): + if not record: + continue + header, raw_path = record.split(b"\t", 1) + mode, kind, oid = header.decode("ascii").split() + name = raw_path.decode("utf-8") + safe_path(name) + need(name not in rows, "duplicate Git tree path: " + name) + rows[name] = (mode, kind, oid) + return rows + + +def pinned_file(root, commit, name): + rows = tree_rows(root, commit, name) + need(set(rows) == {name}, "missing exact historical input: " + name) + mode, kind, oid = rows[name] + need(mode in ("100644", "100755") and kind == "blob", "non-regular historical input: " + name) + return mode, oid, object_bytes(root, oid, "blob") + + +def package_snapshot(root, package): + safe_path(package) + directory = replay.symlink_free_relative_path(root, package) + need(directory.is_dir(), "missing live package: " + package) + names = [] + for parent, dirs, files in os.walk(directory, followlinks=False): + for name in dirs + files: + path = Path(parent) / name + need(not path.is_symlink(), "symlink in live package: " + str(path)) + names += [(Path(parent) / name).relative_to(root).as_posix() for name in files] + return file_snapshot(root, names) + + +def file_snapshot(root, names): + result = {} + for name in names: + safe_path(name) + path = replay.relative_file(root, name) + mode = path.stat().st_mode + need(stat.S_ISREG(mode), "non-regular live file: " + name) + result[name] = (stat.S_IMODE(mode), hashlib.sha256(path.read_bytes()).hexdigest()) + return result + + +def authenticate(root, package, policy): + package_commit, base = policy["package_commit"], policy["base_commit"] + # Grafts are rejected, not trusted as evidence; replacement objects and + # inherited Git config/environment are disabled in every Git operation. + grafts = Path(output(root, "rev-parse", "--git-path", "info/grafts").decode().strip()) + if not grafts.is_absolute(): + grafts = root / grafts + need(not grafts.exists() or not grafts.read_bytes().strip(), "legacy Git grafts are not supported") + objects = set() + for commit in {package_commit, base}: + raw = object_bytes(root, commit, "commit") + root_tree = raw.split(b"\n", 1)[0].decode().removeprefix("tree ") + object_bytes(root, root_tree, "tree") + objects.update((commit, root_tree)) + objects.update(row[2] for row in tree_rows(root, commit, recursive=True).values() if row[1] == "tree") + package_entry = tree_rows(root, package_commit, package).get(package) + need(package_entry == ("040000", "tree", policy["package_tree"]), "frozen package tree mismatch") + files = {} + for name, (mode, kind, oid) in tree_rows(root, package_commit, package, recursive=True).items(): + if kind == "tree": + continue + need(name.startswith(package + "/") and mode in ("100644", "100755") and kind == "blob", + "non-regular frozen package entry: " + name) + files[name] = (mode, object_bytes(root, oid, "blob")) + objects.add(oid) + for name, expected in policy["manifest_sha256"].items(): + need(hashlib.sha256(files[package + "/" + name][1]).hexdigest() == expected, + "frozen original manifest digest mismatch: " + name) + approved = {**files, **wrapper_files(files, package)} + live = package_snapshot(root, package) + need(set(live) == set(files), "live package inventory differs from the frozen original") + for name, (mode, raw) in approved.items(): + need(live[name][1] == hashlib.sha256(raw).hexdigest() + and bool(live[name][0] & 0o111) == (mode == "100755"), "live package drift: " + name) + sources = json.loads(files[package + "/SOURCES.json"][1]) + manifest = json.loads(files[package + "/VERIFY.json"][1]) + need(sources["base_commit"] == base, "historical base differs from approved policy") + need(manifest["inputs"] == list(policy["inputs"]), "historical input list differs from approved policy") + source_names = set() + for row in sources["files"]: + name = row["path"] + safe_path(name) + need(name not in source_names and name not in files, "duplicate or overlapping historical source: " + name) + source_names.add(name) + need(replay.source_pin(row, base) == base + ":" + name, "only approved base-commit source pins are supported") + mode, oid, raw = pinned_file(root, base, name) + need(oid == row["git_blob"], "historical input Git blob mismatch: " + name) + need(hashlib.sha256(raw).hexdigest() == row["sha256"], "historical input SHA256 mismatch: " + name) + files[name] = (mode, raw) + objects.add(oid) + need({row["path"] for row in sources["files"] if row["usage"] == "input"} == set(policy["inputs"]), + "historical live-input pins differ from approved policy") + for name in policy["git_inputs"]: + _, oid, _ = pinned_file(root, base, name) + objects.add(oid) + # These live bytes may differ from history. Keep them intact, never replace + # them with old contributors, ledger rows, or other historical input data. + live_inputs = file_snapshot(root, [*policy["inputs"], ADAPTER, REGRESSION]) + return files, objects, live, live_inputs + + +def materialize(root, temporary, files, objects, package_commit): + git(temporary, "init", "--quiet", "--template=") + # Self-contained object copy: no worktree, alternates, shared object store, + # links, hooks, refs, or config from the live repository are exposed. + with tempfile.TemporaryFile() as pack: + git(root, "pack-objects", "--stdout", input=("\n".join(sorted(objects)) + "\n").encode(), stdout=pack) + pack.seek(0) + git(temporary, "index-pack", "--stdin", stdin=pack, stdout=subprocess.PIPE) + git(temporary, "update-ref", "--no-deref", "HEAD", package_commit) + for name, (mode, raw) in files.items(): + safe_path(name) + path = temporary / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(raw) + path.chmod(0o755 if mode == "100755" else 0o644) + + +@contextmanager +def replay_environment(): + previous = os.environ.copy() + clean = {key: value for key, value in git_environment().items() if not key.startswith("PYTHON")} + clean["PYTHONDONTWRITEBYTECODE"] = "1" + os.environ.clear() + os.environ.update(clean) + try: + yield + finally: + os.environ.clear() + os.environ.update(previous) + + +def run(root, package, check_only=False): + need(package in POLICY, "package is not approved for historical-only replay: " + package) + policy = POLICY[package] + files, objects, live_package, live_inputs = authenticate(root, package, policy) + print("HISTORICAL_INPUTS_ONLY package=" + package + " base=" + policy["base_commit"] + + " package_commit=" + policy["package_commit"] + + " (no validation of today's input bytes)", flush=True) + try: + with tempfile.TemporaryDirectory(prefix="historical-receipt-") as directory: + isolated = Path(directory) + materialize(root, isolated, files, objects, policy["package_commit"]) + before = file_snapshot(isolated, files) + before_package = package_snapshot(isolated, package) + try: + with replay_environment(): + if check_only: + replay.validate(isolated, package) + else: + replay.run_package(isolated, package) + finally: + need(file_snapshot(isolated, files) == before + and package_snapshot(isolated, package) == before_package, + "historical replay changed its materialized source/evidence files") + finally: + need(package_snapshot(root, package) == live_package + and file_snapshot(root, live_inputs) == live_inputs, + "live package or inputs changed during historical replay") + label = "PASS_HISTORICAL_METADATA_ONLY" if check_only else "PASS_HISTORICAL_RECEIPT_ONLY" + print(label + " " + package + " base=" + policy["base_commit"] + + " package_commit=" + policy["package_commit"] + + " live_inputs_validated=false") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", required=True) + parser.add_argument("--check-only", action="store_true") + args = parser.parse_args() + try: + run(ROOT, args.package, args.check_only) + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc: + parser.exit(1, str(exc) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_research_historical_receipts.py b/tests/test_research_historical_receipts.py new file mode 100644 index 0000000000..8c70fbfaf1 --- /dev/null +++ b/tests/test_research_historical_receipts.py @@ -0,0 +1,355 @@ +"""Synthetic Git controls for explicit historical-only receipt adaptation. + +No network, original evidence rewriting, or mathematical suite is required. +""" +import contextlib +import copy +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import replay_historical_receipt as historical +import research_package as replay + +PACKAGE = "research/pr1-retention-receipt" +INPUT = "docs/input.txt" +CATALOG = "docs/research/agent-task-catalog.json" + + +class HistoricalReceiptTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="test-historical-") + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.git("init", "--quiet", "--template=") + self.git("config", "user.name", "Historical fixture") + self.git("config", "user.email", "fixture@example.invalid") + self.put(INPUT, b"original input\n") + card = {"id": "receipt", "question": "historical source check"} + self.put(CATALOG, json.dumps({"tracks": [{"tasks": [card]}]})) + self.put(historical.ADAPTER, b"# fixture adapter input\n") + self.put(historical.REGRESSION, b"# fixture regression input\n") + self.base = self.commit() + self.sources = {"schema_version": 1, "base_commit": self.base, "files": [ + self.source_row(INPUT, "input"), self.source_row(CATALOG, "provenance")]} + self.manifest = {"schema_version": 1, "status": "ready", "python": "3.12", + "requirements": [], "inputs": [INPUT], "commands": [{ + "argv": ["{python}", "-B", "{package}/verify.py"], + "timeout_seconds": 10, "checks": "Original input and assertion control"}]} + task = {"schema_version": 1, "base_commit": self.base, "task_id": "receipt", + "original_card": card, "scope_status": "refined", "scope_change_reason": "fixture", + "actual_scope": "historical receipt fixture", "outcome": "completed", + "bottleneck_link": "fixture", "stop_rule": "one check", "evidence": "verifier", + "author": "fixture", "ai_assistance": "fixture", "open_questions": [], + "no_open_questions_reason": "bounded fixture"} + self.put(PACKAGE + "/TASK.json", json.dumps(task)) + self.original_readme = "## Open next questions\nBounded fixture\n" + self.put(PACKAGE + "/README.md", self.original_readme) + self.put(PACKAGE + "/verify.py", "from pathlib import Path\n" + "assert __debug__, 'assertions were disabled'\n" + "assert Path('docs/input.txt').read_bytes() == b'original input\\n'\n" + "print('PASS_ORIGINAL_CONTROL')\n") + self.freeze() + self.put(INPUT, b"current input with later credit\n") + self.commit() + self.patch = mock.patch.dict(historical.POLICY, {PACKAGE: self.policy}, clear=True) + self.patch.start() + self.addCleanup(self.patch.stop) + + def git(self, *args): + return subprocess.run(["git", *args], cwd=self.root, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout + + def put(self, name, value): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(value.encode() if isinstance(value, str) else value) + + def commit(self): + self.git("add", "--all") + self.git("commit", "--quiet", "-m", "fixture") + return self.git("rev-parse", "HEAD").decode().strip() + + def source_row(self, name, usage): + return {"path": name, "usage": usage, + "git_blob": self.git("rev-parse", self.base + ":" + name).decode().strip(), + "sha256": hashlib.sha256((self.root / name).read_bytes()).hexdigest()} + + def freeze(self): + self.put(PACKAGE + "/README.md", self.original_readme) + self.put(PACKAGE + "/SOURCES.json", json.dumps(self.sources)) + self.put(PACKAGE + "/VERIFY.json", json.dumps(self.manifest)) + commit = self.commit() + self.policy = {"package_commit": commit, + "package_tree": self.git("rev-parse", commit + ":" + PACKAGE).decode().strip(), + "base_commit": self.base, "inputs": (INPUT,), "git_inputs": (), + "manifest_sha256": {name: hashlib.sha256((self.root / PACKAGE / name).read_bytes()).hexdigest() + for name in ("SOURCES.json", "VERIFY.json")}} + original = {PACKAGE + "/" + name: ("100644", (self.root / PACKAGE / name).read_bytes()) + for name in ("SOURCES.json", "VERIFY.json", "README.md")} + for name, (_, raw) in historical.wrapper_files(original, PACKAGE).items(): + self.put(name, raw) + if hasattr(self, "patch"): + historical.POLICY[PACKAGE] = self.policy + + def run_receipt(self, check_only=False): + with contextlib.redirect_stdout(io.StringIO()) as text: + historical.run(self.root, PACKAGE, check_only=check_only) + return text.getvalue() + + def test_positive_historical_replay_keeps_live_bytes_and_git_unchanged(self): + before = historical.package_snapshot(self.root, PACKAGE) + live = (self.root / INPUT).read_bytes() + git_before = {p.relative_to(self.root).as_posix(): p.read_bytes() + for p in (self.root / ".git").rglob("*") if p.is_file()} + result = self.run_receipt() + self.assertIn("PASS_HISTORICAL_RECEIPT_ONLY", result) + self.assertIn("live_inputs_validated=false", result) + self.assertEqual((self.root / INPUT).read_bytes(), live) + self.assertEqual(historical.package_snapshot(self.root, PACKAGE), before) + git_after = {p.relative_to(self.root).as_posix(): p.read_bytes() + for p in (self.root / ".git").rglob("*") if p.is_file()} + self.assertEqual(git_before, git_after) + + def test_default_live_validation_still_fails(self): + for _ in range(2): + wrapped = {name: (self.root / PACKAGE / name).read_bytes() + for name in ("SOURCES.json", "VERIFY.json")} + try: + for name in wrapped: + self.put(PACKAGE + "/" + name, self.git("show", self.policy["package_commit"] + ":" + PACKAGE + "/" + name)) + with self.assertRaisesRegex(ValueError, "working source drift"): + replay.validate(self.root, PACKAGE) + finally: + for name, raw in wrapped.items(): + self.put(PACKAGE + "/" + name, raw) + self.run_receipt(check_only=True) + + def test_arbitrary_wrapper_metadata_drift_rejected(self): + for name in ("SOURCES.json", "VERIFY.json", "README.md"): + with self.subTest(name=name): + path = self.root / PACKAGE / name + original = path.read_bytes() + try: + path.write_bytes(original + b" ") + with self.assertRaisesRegex(ValueError, "live package drift"): + self.run_receipt() + finally: + path.write_bytes(original) + + def test_original_manifest_digest_mismatch_rejected(self): + historical.POLICY[PACKAGE]["manifest_sha256"]["VERIFY.json"] = "0" * 64 + with self.assertRaisesRegex(ValueError, "original manifest digest mismatch"): + self.run_receipt() + + def test_metadata_only_is_not_labelled_receipt_success(self): + result = self.run_receipt(check_only=True) + self.assertIn("PASS_HISTORICAL_METADATA_ONLY", result) + self.assertNotIn("PASS_HISTORICAL_RECEIPT_ONLY", result) + + def test_unknown_package_fails_closed(self): + with self.assertRaisesRegex(ValueError, "not approved"): + historical.run(self.root, "research/other") + + def test_wrong_frozen_commit_rejected(self): + historical.POLICY[PACKAGE]["package_commit"] = "0" * 40 + with self.assertRaises(subprocess.CalledProcessError): + self.run_receipt() + + def test_wrong_frozen_package_tree_rejected(self): + historical.POLICY[PACKAGE]["package_tree"] = "0" * 40 + with self.assertRaisesRegex(ValueError, "package tree mismatch"): + self.run_receipt() + + def test_wrong_history_base_rejected(self): + historical.POLICY[PACKAGE]["base_commit"] = historical.POLICY[PACKAGE]["package_commit"] + with self.assertRaisesRegex(ValueError, "base differs"): + self.run_receipt() + + def test_wrong_blob_pin_rejected(self): + self.sources["files"][0]["git_blob"] = "0" * 40 + self.freeze() + with self.assertRaisesRegex(ValueError, "Git blob mismatch"): + self.run_receipt() + + def test_wrong_sha256_pin_rejected(self): + self.sources["files"][0]["sha256"] = "0" * 64 + self.freeze() + with self.assertRaisesRegex(ValueError, "SHA256 mismatch"): + self.run_receipt() + + def test_foreign_head_pin_rejected(self): + self.sources["files"][0]["pin"] = "head:" + self.base + ":" + INPUT + self.freeze() + # The supported general resolver yields the same base expression. A + # genuinely different commit is what must not replace the approved base. + self.sources["files"][0]["pin"] = "head:" + "f" * 40 + ":" + INPUT + self.freeze() + with self.assertRaisesRegex(ValueError, "base-commit source pins"): + self.run_receipt() + + def test_undeclared_input_rejected(self): + self.manifest["inputs"] = [] + self.freeze() + with self.assertRaisesRegex(ValueError, "input list differs"): + self.run_receipt() + + def test_missing_historical_input_rejected(self): + self.sources["files"][0]["path"] = "docs/missing.txt" + self.freeze() + with self.assertRaisesRegex(ValueError, "missing exact historical input"): + self.run_receipt() + + def test_duplicate_pin_rejected(self): + self.sources["files"].append(copy.deepcopy(self.sources["files"][0])) + self.freeze() + with self.assertRaisesRegex(ValueError, "duplicate or overlapping"): + self.run_receipt() + + def test_malicious_paths_rejected(self): + for name in ("../escape", "/escape", "docs/../input.txt", "docs//input.txt", + "./docs/input.txt", "docs\\input.txt", ".git/config", "docs/.GiT/config", + "docs/input\n.txt", "docs/input\x00.txt", ":(glob)*"): + with self.subTest(name=name): + with self.assertRaises(ValueError): + historical.safe_path(name) + + def test_traversal_in_authenticated_sources_rejected(self): + self.sources["files"][0]["path"] = "../escape" + self.freeze() + with self.assertRaisesRegex(ValueError, "canonical"): + self.run_receipt() + + def test_live_package_drift_rejected(self): + self.put(PACKAGE + "/verify.py", "print('fake success')\n") + with self.assertRaisesRegex(ValueError, "live package drift"): + self.run_receipt() + + def test_extra_live_package_file_rejected(self): + self.put(PACKAGE + "/extra.py", "print('untracked')\n") + with self.assertRaisesRegex(ValueError, "inventory differs"): + self.run_receipt() + + def test_live_package_symlink_rejected(self): + (self.root / PACKAGE / "link").symlink_to(self.root / INPUT) + with self.assertRaisesRegex(ValueError, "symlink"): + self.run_receipt() + + def test_frozen_package_symlink_rejected(self): + (self.root / PACKAGE / "link").symlink_to(self.root / INPUT) + self.freeze() + with self.assertRaisesRegex(ValueError, "non-regular frozen package"): + self.run_receipt() + + def test_live_input_symlink_rejected(self): + (self.root / INPUT).unlink() + (self.root / INPUT).symlink_to(self.root / CATALOG) + with self.assertRaisesRegex(ValueError, "symlink"): + self.run_receipt() + + def test_missing_live_input_rejected(self): + (self.root / INPUT).unlink() + with self.assertRaisesRegex(ValueError, "missing or external"): + self.run_receipt() + + def test_materialized_source_mutation_fails_final_gate(self): + def corrupt(root, package): + (root / INPUT).write_text("bad mutation") + with mock.patch.object(replay, "run_package", side_effect=corrupt): + with self.assertRaisesRegex(ValueError, "changed its materialized"): + self.run_receipt() + + def test_materialized_extra_package_file_fails_final_gate(self): + def corrupt(root, package): + (root / package / "extra").write_text("bad mutation") + with mock.patch.object(replay, "run_package", side_effect=corrupt): + with self.assertRaisesRegex(ValueError, "changed its materialized"): + self.run_receipt() + + def test_live_mutation_during_replay_fails_final_gate(self): + def corrupt(root, package): + (self.root / INPUT).write_text("bad live mutation") + with mock.patch.object(replay, "run_package", side_effect=corrupt): + with self.assertRaisesRegex(ValueError, "live package or inputs changed"): + self.run_receipt() + + def test_failure_propagates_and_temporary_directory_is_removed(self): + paths = [] + def fail(root, package): + paths.append(root) + raise ValueError("original verifier rejected") + with mock.patch.object(replay, "run_package", side_effect=fail): + with self.assertRaisesRegex(ValueError, "original verifier rejected"): + self.run_receipt() + self.assertTrue(paths) + self.assertTrue(all(not path.exists() for path in paths)) + + def test_original_assertions_run_despite_inherited_optimization(self): + with mock.patch.dict(os.environ, {"PYTHONOPTIMIZE": "2"}): + self.run_receipt() + self.assertEqual(os.environ["PYTHONOPTIMIZE"], "2") + + def test_replacement_cannot_substitute_wrong_history(self): + self.put(INPUT, b"replacement bytes\n") + replacement = self.commit() + self.git("replace", self.base, replacement) + self.run_receipt() + + def test_inherited_git_directory_cannot_redirect_authentication(self): + with mock.patch.dict(os.environ, {"GIT_DIR": str(self.root / "absent"), + "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.bare", + "GIT_CONFIG_VALUE_0": "true"}): + self.run_receipt() + + def test_historical_symlink_input_rejected(self): + (self.root / "docs/link.txt").symlink_to("input.txt") + commit = self.commit() + with self.assertRaisesRegex(ValueError, "non-regular historical input"): + historical.pinned_file(self.root, commit, "docs/link.txt") + + def test_original_timeout_is_enforced(self): + self.manifest["commands"][0]["timeout_seconds"] = 1 + self.put(PACKAGE + "/verify.py", "import time; time.sleep(10)\n") + self.freeze() + with self.assertRaisesRegex(ValueError, "verification timed out"): + self.run_receipt() + + def test_original_nonzero_exit_is_not_success(self): + self.put(PACKAGE + "/verify.py", "raise SystemExit(7)\n") + self.freeze() + with self.assertRaisesRegex(ValueError, "verification command exited 7"): + self.run_receipt() + + def test_adapter_input_mutation_fails_final_gate(self): + def corrupt(root, package): + (self.root / historical.ADAPTER).write_text("bad mutation") + with mock.patch.object(replay, "run_package", side_effect=corrupt): + with self.assertRaisesRegex(ValueError, "live package or inputs changed"): + self.run_receipt() + + def test_legacy_grafts_rejected(self): + self.put(".git/info/grafts", self.base + "\n") + with self.assertRaisesRegex(ValueError, "legacy Git grafts"): + self.run_receipt() + + def test_isolated_git_has_no_alternates_or_live_config(self): + def inspect(root, package): + self.assertFalse((root / ".git/objects/info/alternates").exists()) + self.assertNotIn(str(self.root), (root / ".git/config").read_text()) + self.assertEqual(historical.output(root, "show", self.base + ":" + INPUT), b"original input\n") + self.assertIn(INPUT.encode(), historical.output(root, "ls-tree", "-r", self.base)) + with mock.patch.object(replay, "run_package", side_effect=inspect): + self.run_receipt() + + +if __name__ == "__main__": + unittest.main()