From 161ce1b96288349fd67ecda1734d2f96850a2ee9 Mon Sep 17 00:00:00 2001 From: Chris R Date: Tue, 20 Oct 2026 07:38:41 +1100 Subject: [PATCH] Extract explicit historical receipt adapters from PR3577 Preserve exact reviewed bytes from 3088a09c77fe5353218df861e34b0fe67dbf1b6b on current canonical main. Keep P112 repair in PR3885 and P104 research separate. No frozen scientific inputs or selected frontier changed. Prepared by Clear1nsight with substantial OpenAI assistance. --- docs/research/research-package-guide.md | 54 +++ research/pr1-retention-receipt/README.md | 30 ++ research/pr1-retention-receipt/SOURCES.json | 18 +- research/pr1-retention-receipt/VERIFY.json | 27 +- .../README.md | 30 ++ .../SOURCES.json | 4 +- .../VERIFY.json | 26 +- scripts/replay_historical_receipt.py | 344 +++++++++++++++++ tests/test_research_historical_receipts.py | 355 ++++++++++++++++++ 9 files changed, 864 insertions(+), 24 deletions(-) create mode 100644 scripts/replay_historical_receipt.py create mode 100644 tests/test_research_historical_receipts.py 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()