From 1a885a3546cd68995be448e2ca91f28cc23ec155 Mon Sep 17 00:00:00 2001 From: roger-sato Date: Sun, 18 Oct 2026 18:09:19 +0900 Subject: [PATCH 1/2] fix(research): bind source reads to original Git objects --- docs/research/research-package-guide.md | 25 ++++ scripts/research_package.py | 16 ++- tests/test_research_git_replacements.py | 167 ++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 tests/test_research_git_replacements.py diff --git a/docs/research/research-package-guide.md b/docs/research/research-package-guide.md index 1a70e63c75..c2cd7671ba 100644 --- a/docs/research/research-package-guide.md +++ b/docs/research/research-package-guide.md @@ -56,6 +56,31 @@ explicit head sha); there is no `external` pin for bytes that are not a Git object here. For such an input, record its source and a byte-equality check in the verifier and explain the gap in the README. +Shared Git source readers ignore local replacement refs, including a custom +`GIT_REPLACE_REF_BASE` namespace. Otherwise Git can report the original blob ID +while returning a replacement's bytes, allowing locally generated pins to fail +in a clean checkout. The generator, metadata validator, HEAD input inventory, +committed-byte preflight and package exporter use the stored objects. Replacement +refs are left intact; the policy applies only to these Git subprocesses and does +not change the caller's environment or verifier subprocesses. This authenticates +source bytes, not publication ancestry or mathematical claims; the optional +source-history preflight below remains separate. + +Regression controls use temporary Git repositories with blob and commit +replacements, the legacy batch fallback, newline paths, a custom replacement +namespace, forged live pins and an exported payload: + +```sh +python3 -W error -B -m unittest discover -s tests -p 'test_research_git_replacements.py' -v +``` + +This extends the immutable-object policy of Nick Petalas's +[source-history preflight (PR3304)](https://github.com/CrocSwap/integer-mult-bounds/pull/3304) +to the shared source readers. The runner and scaffold retain Douglas Colkitt's +attribution, and the exporter retains Tekalign's. Added regression coverage and +reader policy prepared for roger-sato with substantial OpenAI Codex assistance; +Apache-2.0. + `original_card` in `TASK.json` must equal the full pinned catalogue card object. Hand authors can print the exact object instead of copying it by hand: diff --git a/scripts/research_package.py b/scripts/research_package.py index 404823640a..b67555d9c7 100644 --- a/scripts/research_package.py +++ b/scripts/research_package.py @@ -63,8 +63,16 @@ def relative_file(root, name): return result +def git_source_env(): + # Replacement refs can pair an original object ID with different bytes. + # Pins and HEAD inventories must describe the stored objects even when the + # caller has configured a replacement namespace or an inherited override. + return {**os.environ, "GIT_NO_REPLACE_OBJECTS": "1"} + + def git(root, *args): - return subprocess.run(["git", *args], cwd=root, capture_output=True, check=True).stdout + return subprocess.run(["git", *args], cwd=root, env=git_source_env(), + capture_output=True, check=True).stdout def tracked_input_paths(root): @@ -74,7 +82,7 @@ def tracked_input_paths(root): preserves Git filenames containing whitespace, including CR and LF. """ raw = subprocess.run( - ["git", "ls-tree", "-r", "--name-only", "-z", "HEAD"], cwd=root, + ["git", "ls-tree", "-r", "--name-only", "-z", "HEAD"], cwd=root, env=git_source_env(), capture_output=True, check=True, timeout=30).stdout return frozenset(name.decode("utf-8") for name in raw.split(b"\0") if name) @@ -92,7 +100,7 @@ def git_source_rows(root, base, names): Newline paths and non-blob objects retain the legacy Git-show semantics. The caller must close this generator if source validation fails mid-stream. """ - with subprocess.Popen(["git", "cat-file", "--batch"], cwd=root, + with subprocess.Popen(["git", "cat-file", "--batch"], cwd=root, env=git_source_env(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process: try: @@ -135,7 +143,7 @@ def _git_blob_batch(root, specs, nul): argv = ["git", "cat-file", "--batch"] + (["-z"] if nul else []) # Usage diagnostics from the unsupported-option probe are expected. Avoid # a second pipe that could block while we consume the framed stdout reply. - with subprocess.Popen(argv, cwd=root, stdin=subprocess.PIPE, + with subprocess.Popen(argv, cwd=root, env=git_source_env(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as process: try: for index, spec in enumerate(specs): diff --git a/tests/test_research_git_replacements.py b/tests/test_research_git_replacements.py new file mode 100644 index 0000000000..2b8e1edac9 --- /dev/null +++ b/tests/test_research_git_replacements.py @@ -0,0 +1,167 @@ +"""Local replacement refs must not redefine immutable research source pins.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) +import check_research_commit as publication +import export_research_package as exporter +import new_research_package as scaffold +import research_package as package +sys.path.pop(0) + + +class GitReplacementTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.raw_git('init', '-q') + self.raw_git('config', 'user.name', 'Fixture') + self.raw_git('config', 'user.email', 'fixture@example.invalid') + self.raw_git('config', 'core.autocrlf', 'false') + self.catalog_name = 'docs/research/agent-task-catalog.json' + self.card = {'id': 'fixture', 'question': 'Authenticate the committed bytes'} + self.original = b'authentic input\n' + self.replacement = b'replacement input\n' + files = { + self.catalog_name: json.dumps({'priority_revision': 6, + 'tracks': [{'tasks': [self.card]}]}).encode(), + 'certificates/selected-result.json': b'{}', + 'input.txt': self.original, + } + if os.name != 'nt': + files['line\nbreak'] = self.original + for name, data in files.items(): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + self.commit() + self.base = self.raw_git('rev-parse', 'HEAD').decode().strip() + self.oid = self.raw_git('rev-parse', 'HEAD:input.txt').decode().strip() + self.other_oid = self.raw_git('hash-object', '-w', '--stdin', data=self.replacement).decode().strip() + self.names = sorted(files) + + def raw_git(self, *args, data=None): + # Fixture controls deliberately exercise Git's ordinary replacement behavior. + env = os.environ.copy() + env.pop('GIT_NO_REPLACE_OBJECTS', None) + return subprocess.run(['git', *args], cwd=self.root, input=data, env=env, + capture_output=True, check=True).stdout + + def commit(self): + self.raw_git('add', '.') + self.raw_git('-c', 'maintenance.auto=false', '-c', 'gc.auto=0', 'commit', '-qm', 'fixture') + + def assert_original_readers(self, names): + expected = [(self.oid, self.original)] * len(names) + specs = [self.base + ':' + name for name in names] + self.assertEqual(list(package.git_source_rows(self.root, self.base, names)), expected) + self.assertEqual(list(package.git_blobs(self.root, specs)), expected) + # Cover the legacy line protocol and its single-object CR/LF resolver too. + self.assertEqual(list(package._git_blob_batch(self.root, specs, nul=False)), expected) + self.assertEqual(list(package.git_blobs(self.root, [self.oid])), expected[:1]) + for spec in specs: + self.assertEqual(package.git(self.root, 'show', spec), self.original) + + def test_blob_replacements_cannot_change_ids_or_bytes_in_any_reader(self): + self.raw_git('replace', self.oid, self.other_oid) + self.assertEqual(self.raw_git('show', self.base + ':input.txt'), self.replacement) + names = ['input.txt'] + (['line\nbreak'] if os.name != 'nt' else []) + self.assert_original_readers(names) + + def test_custom_replacement_namespace_does_not_change_parent_environment(self): + namespace = 'refs/research-test-replacements/' + self.raw_git('update-ref', namespace + self.oid, self.other_oid) + for inherited in (None, ''): + env = {**os.environ, 'GIT_REPLACE_REF_BASE': namespace} + env.pop('GIT_NO_REPLACE_OBJECTS', None) + if inherited is not None: + env['GIT_NO_REPLACE_OBJECTS'] = inherited + with self.subTest(inherited=inherited), patch.dict(os.environ, env, clear=True): + self.assertEqual(self.raw_git('show', self.base + ':input.txt'), self.replacement) + self.assert_original_readers(['input.txt']) + self.assertEqual(os.environ.get('GIT_NO_REPLACE_OBJECTS'), inherited) + + def test_commit_replacement_cannot_change_tree_inventory_or_catalogue(self): + (self.root / 'input.txt').write_bytes(self.replacement) + (self.root / 'replacement-only.txt').write_bytes(b'not in original tree') + altered = {'priority_revision': 6, 'tracks': [{'tasks': [ + {**self.card, 'question': 'Replacement catalogue'}]}]} + (self.root / self.catalog_name).write_text(json.dumps(altered)) + self.commit() + other = self.raw_git('rev-parse', 'HEAD').decode().strip() + self.raw_git('reset', '--hard', self.base) + self.raw_git('replace', self.base, other) + self.assertEqual(self.raw_git('show', self.base + ':input.txt'), self.replacement) + self.assert_original_readers(['input.txt']) + self.assertEqual(package.tracked_input_paths(self.root), frozenset(self.names)) + target = scaffold.create(self.root, 'fixture', 'fixture', 'Fixture', ['input.txt']) + self.assertEqual(json.loads((target / 'TASK.json').read_bytes())['original_card'], self.card) + self.assertEqual(package.catalogue_card(self.root, 'research/fixture', 'fixture'), self.card) + + def ready_package(self): + target = scaffold.create(self.root, 'fixture', 'fixture', 'Fixture', ['input.txt']) + task = json.loads((target / 'TASK.json').read_bytes()) + task.update(bottleneck_link='Portable source checks', stop_rule='Bounded fixture', + outcome='completed', evidence='Synthetic Git replacement controls', + ai_assistance='OpenAI Codex', open_questions=[], + no_open_questions_reason='Only the scoped byte binding is checked') + (target / 'TASK.json').write_text(json.dumps(task)) + manifest = json.loads((target / 'VERIFY.json').read_bytes()) + manifest['status'] = 'ready' + manifest['commands'][0]['checks'] = 'Synthetic source binding' + (target / 'VERIFY.json').write_text(json.dumps(manifest)) + return target + + def test_scaffold_and_validator_reject_replacement_only_live_bytes(self): + self.raw_git('replace', self.oid, self.other_oid) + (self.root / 'input.txt').write_bytes(self.replacement) + with self.assertRaisesRegex(ValueError, 'source has uncommitted changes'): + scaffold.create(self.root, 'bad', 'fixture', 'Fixture', ['input.txt']) + self.assertFalse((self.root / 'research/bad').exists()) + (self.root / 'input.txt').write_bytes(self.original) + target = self.ready_package() + package.validate(self.root, 'research/fixture') + (self.root / 'input.txt').write_bytes(self.replacement) + with self.assertRaisesRegex(ValueError, 'working source drift'): + package.validate(self.root, 'research/fixture') + sources = json.loads((target / 'SOURCES.json').read_bytes()) + row = next(row for row in sources['files'] if row['path'] == 'input.txt') + self.assertEqual(row['git_blob'], self.oid) + row['sha256'] = hashlib.sha256(self.replacement).hexdigest() + (target / 'SOURCES.json').write_text(json.dumps(sources)) + with self.assertRaisesRegex(ValueError, 'source SHA256 provenance mismatch'): + package.validate(self.root, 'research/fixture') + + def test_publication_preflight_compares_actual_commit_bytes(self): + self.ready_package() + self.commit() + self.raw_git('replace', self.oid, self.other_oid) + publication.check(self.root, 'research/fixture') + (self.root / 'input.txt').write_bytes(self.replacement) + with self.assertRaisesRegex(ValueError, 'working/commit bytes differ: input.txt'): + publication.check(self.root, 'research/fixture') + + def test_export_retains_original_payload_and_receipt_under_replacement(self): + self.ready_package() + self.commit() + self.raw_git('replace', self.oid, self.other_oid) + output = self.root / 'bundle.tar' + receipt = exporter.export(self.root, 'research/fixture', 'HEAD', output) + with tarfile.open(output) as archive: + self.assertEqual(archive.extractfile('input.txt').read(), self.original) + row = next(row for row in receipt['files'] if row['path'] == 'input.txt') + self.assertEqual(row['sha256'], hashlib.sha256(self.original).hexdigest()) + self.assertEqual(exporter.check_bundle(output), receipt) + + +if __name__ == '__main__': + unittest.main() From 05a7fa8434062016d733c3d92c0a4c4a53e52cc5 Mon Sep 17 00:00:00 2001 From: roger-sato Date: Sun, 18 Oct 2026 20:57:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=B1=A5=E6=AD=B4=E3=83=98=E3=83=AB?= =?UTF-8?q?=E3=83=91=E3=83=BC=E3=81=AE=E5=87=BA=E5=85=B8=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E9=9A=9B=E3=81=AE=E5=86=8D=E7=8F=BE=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../isolated-descendant-bytecode/README.md | 14 ++ .../isolated-descendant-bytecode/SOURCES.json | 2 +- .../test_research_descendant_source_roles.py | 149 ++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 tests/test_research_descendant_source_roles.py diff --git a/research/isolated-descendant-bytecode/README.md b/research/isolated-descendant-bytecode/README.md index d3d83682de..f6a84ab99d 100644 --- a/research/isolated-descendant-bytecode/README.md +++ b/research/isolated-descendant-bytecode/README.md @@ -79,6 +79,20 @@ pins are in [SOURCES.json](SOURCES.json). Canonical scientific base is runner is provenance; the manifest also declares the evolving live runner and regressions for replay selection. +The `research_package.py` source row is historical provenance: `verify.py` +loads that helper from the exact base-commit Git blob and checks its original +SHA256 before copying it into the differential fixture. Its base, blob and +SHA256 are unchanged. The original manifest is retained at commit +`20ecf2891d80b5acf18d980b2164fb44ea7faa90` (blob +`5e2d5560769ca754175472d123829a8586bb4ddc`). Classifying this row as a live +byte pin incorrectly prevented compatible changes to the current helper. +`VERIFY.json` still declares the current helper as an input for replay +selection, and its unchanged third command runs the eight current-helper +bytecode regressions. The sixteen-run historical differential, all three +registered commands, verifier and `RESULT.json` are retained unchanged. +This metadata correction was prepared for roger-sato with substantial OpenAI +Codex assistance; Arham Tahir's original work and source credit are retained. + ```sh python3.12 -B scripts/research_package.py --package research/isolated-descendant-bytecode --check-only python3.12 -B scripts/research_package.py --package research/isolated-descendant-bytecode diff --git a/research/isolated-descendant-bytecode/SOURCES.json b/research/isolated-descendant-bytecode/SOURCES.json index 5e2d556076..089b03a95a 100644 --- a/research/isolated-descendant-bytecode/SOURCES.json +++ b/research/isolated-descendant-bytecode/SOURCES.json @@ -22,7 +22,7 @@ }, { "path": "scripts/research_package.py", - "usage": "input", + "usage": "provenance", "git_blob": "404823640a4506cc363d5e999b724553a41a39a2", "sha256": "8e1fe8bebe7f045c82a672a103ed87a35cd9dfdbab5848b97969350449d0c8b4" }, diff --git a/tests/test_research_descendant_source_roles.py b/tests/test_research_descendant_source_roles.py new file mode 100644 index 0000000000..61378d20ee --- /dev/null +++ b/tests/test_research_descendant_source_roles.py @@ -0,0 +1,149 @@ +"""Historical helper authentication and current-helper replay remain separate. + +Arham Tahir's source-bound differential and registered commands are unchanged. +Prepared for roger-sato with substantial OpenAI Codex assistance. Apache-2.0. +""" +import copy +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +ORIGINAL = '20ecf2891d80b5acf18d980b2164fb44ea7faa90' +PACKAGE = 'research/isolated-descendant-bytecode' +HELPER = 'scripts/research_package.py' + + +def git(root, *args): + return subprocess.check_output(['git', '--no-replace-objects', *args], cwd=root, + stderr=subprocess.PIPE, timeout=30) + + +class DescendantSourceRoleTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.original = json.loads(git(ROOT, 'show', ORIGINAL + ':' + PACKAGE + '/SOURCES.json')) + self.manifest = json.loads((ROOT / PACKAGE / 'VERIFY.json').read_bytes()) + names = set(self.manifest['inputs']) | { + PACKAGE + '/' + name for name in + ('SOURCES.json', 'VERIFY.json', 'TASK.json', 'README.md', 'verify.py', 'RESULT.json')} + for name in names: + target = self.root / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / name, target) + git(self.root, 'init', '-q') + objects = Path(git(ROOT, 'rev-parse', '--git-path', 'objects').decode().strip()) + if not objects.is_absolute(): + objects = ROOT / objects + (self.root / '.git/objects/info/alternates').write_text(str(objects.resolve()) + '\n') + git(self.root, 'config', 'user.name', 'Fixture') + git(self.root, 'config', 'user.email', 'fixture@example.invalid') + git(self.root, 'config', 'core.autocrlf', 'false') + self.commit() + + def commit(self): + git(self.root, 'add', '.') + git(self.root, 'commit', '-qm', 'fixture') + + def write_sources(self, value): + (self.root / PACKAGE / 'SOURCES.json').write_text(json.dumps(value), encoding='utf-8') + + def cli(self, script='research_package.py', arguments=None): + if arguments is None: + arguments = ['--package', PACKAGE, '--check-only'] + flags = ['-O'] if sys.flags.optimize else [] + return subprocess.run([sys.executable, *flags, '-B', str(self.root / 'scripts' / script), + *arguments], cwd=self.root, capture_output=True, text=True, timeout=60) + + def test_only_source_role_changes_and_frozen_evidence_is_retained(self): + expected = copy.deepcopy(self.original) + row = next(row for row in expected['files'] if row['path'] == HELPER) + self.assertEqual(row['usage'], 'input') + row['usage'] = 'provenance' + self.assertEqual(json.loads((ROOT / PACKAGE / 'SOURCES.json').read_bytes()), expected) + for name in ('VERIFY.json', 'TASK.json', 'verify.py', 'RESULT.json'): + with self.subTest(name=name): + self.assertEqual((ROOT / PACKAGE / name).read_bytes(), + git(ROOT, 'show', ORIGINAL + ':' + PACKAGE + '/' + name)) + self.assertIn(HELPER, self.manifest['inputs']) + self.assertEqual(len(self.manifest['commands']), 3) + + def test_original_gate_rejects_but_correct_role_accepts_current_helper(self): + # The same harmless change demonstrates the old gate on any successor. + helper = self.root / HELPER + helper.write_bytes(helper.read_bytes() + b'\n# compatible fixture comment\n') + migrated = json.loads((self.root / PACKAGE / 'SOURCES.json').read_bytes()) + self.write_sources(self.original) + before = self.cli() + self.assertNotEqual(before.returncode, 0) + self.assertIn('working source drift: ' + HELPER, before.stderr) + self.write_sources(migrated) + after = self.cli() + self.assertEqual(after.returncode, 0, after.stderr) + self.assertIn('PASS_PACKAGE_METADATA_AND_PINS', after.stdout) + + def test_historical_blob_and_hash_corruption_still_rejects(self): + sources = json.loads((self.root / PACKAGE / 'SOURCES.json').read_bytes()) + for field, value, message in ( + ('git_blob', '0' * 40, 'source blob provenance mismatch'), + ('sha256', '0' * 64, 'source SHA256 provenance mismatch')): + with self.subTest(field=field): + corrupted = copy.deepcopy(sources) + next(row for row in corrupted['files'] if row['path'] == HELPER)[field] = value + self.write_sources(corrupted) + result = self.cli() + self.assertNotEqual(result.returncode, 0) + self.assertIn(message + ': ' + HELPER, result.stderr) + self.assertNotIn('PASS_PACKAGE', result.stdout) + + def test_unrelated_live_input_drift_still_rejects(self): + name = 'scripts/check_research_commit.py' + path = self.root / name + path.write_bytes(path.read_bytes() + b'\n# unpinned fixture change\n') + result = self.cli() + self.assertNotEqual(result.returncode, 0) + self.assertIn('working source drift: ' + name, result.stderr) + + def test_current_helper_remains_a_planner_and_publication_input(self): + base = git(self.root, 'rev-parse', 'HEAD').decode().strip() + helper = self.root / HELPER + helper.write_bytes(helper.read_bytes() + b'\n# current input change\n') + result = self.cli('check_research_commit.py', ['--package', PACKAGE]) + self.assertNotEqual(result.returncode, 0) + self.assertIn('working/commit bytes differ: ' + HELPER, result.stderr) + self.commit() + explanation = self.root / 'plan.json' + result = self.cli(arguments=['--changed-since', base, '--explain-plan', str(explanation)]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), {'include': [{'package': PACKAGE}]}) + selected = json.loads(explanation.read_bytes())['selected'] + self.assertEqual(selected[0]['causes']['declared_inputs'], [HELPER]) + + def test_broken_live_cache_isolation_fails_the_unchanged_registered_command(self): + helper = self.root / HELPER + source = helper.read_bytes() + protected = b'run_command(isolated_python_argv(argv, cache), cwd, timeout)' + self.assertEqual(source.count(protected), 1) + helper.write_bytes(source.replace(protected, b'run_command(argv, cwd, timeout)')) + command = self.manifest['commands'][2] + argv = [sys.executable if value == '{python}' else value.replace('{package}', PACKAGE) + for value in command['argv']] + env = os.environ.copy() + env.pop('PYTHONOPTIMIZE', None) + result = subprocess.run(argv, cwd=self.root, env=env, capture_output=True, text=True, + timeout=command['timeout_seconds']) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Ran 8 tests', result.stderr) + self.assertIn('UNVERIFIED_BYTECODE_SENTINEL', result.stderr) + self.assertIn('FAILED', result.stderr) + + +if __name__ == '__main__': + unittest.main()