From 3c26e1bdab582c13be58628b3572774ba091a349 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 18:35:04 +0200 Subject: [PATCH] build: bind checkpoint manifest to acceptance --- docs/CODEX_HANDOFF.md | 2 +- docs/research/m1-no-device-acceptance.md | 9 +++- docs/roadmap.md | 3 +- scripts/prepare_m1_acceptance_kit.py | 43 +++++++++++++-- scripts/validate_m1_manual_acceptance.py | 46 +++++++++++++++- scripts/verify_rayforge_windows_checkpoint.py | 14 ++++- tests/test_m1_acceptance_kit.py | 54 ++++++++++++++----- tests/test_m1_manual_acceptance.py | 30 +++++++++++ tests/test_windows_checkpoint_verifier.py | 12 +++++ 9 files changed, 191 insertions(+), 22 deletions(-) diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 5213ef2..2aeb715 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -21,7 +21,7 @@ The repository now provides: - a checksummed acceptance-kit generator containing only allowlisted public synthetic inputs, with provenance binding to the repository commit and non-executing artifact-verification report, including the Rayforge pin and - bundle, installer and addon-verification fields; + bundle, installer, addon-verification and source checkpoint-manifest fields; - a fail-closed manual desktop-acceptance report and validator; - pinned upstream contract/license manifests and a drift checker; - a fail-closed API-23 adapter isolating Rayforge's internal driver registry; diff --git a/docs/research/m1-no-device-acceptance.md b/docs/research/m1-no-device-acceptance.md index d63b4f1..dc94504 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -57,6 +57,9 @@ Rayforge commit, requires the manifest and bundle to contain exactly the versioned repository addon file set with matching digests, and requires the pinned Rayforge license both inside the ZIP and as a sidecar. Missing, changed or additional addon files fail verification. +The schema-1 verification output records the exact checkpoint-manifest name +and SHA-256 plus the fixed checkpoint and addon identities, so later manual +acceptance can retain the complete artifact provenance chain. It never executes the application or installer, and a passing result retains the manual GUI interaction gate. The installer, manifest and license sidecar form one distribution set. @@ -90,7 +93,8 @@ report into the kit at creation time: ```powershell python scripts/prepare_m1_acceptance_kit.py ` - --artifact-verification .build/artifacts/rayforge-windows-verification.json + --artifact-verification .build/artifacts/rayforge-windows-verification.json ` + --checkpoint-manifest .build/artifacts/rayforge-windows-checkpoint.json ``` The default output is `.build/acceptance-kit/`. The generator uses a fixed @@ -102,7 +106,8 @@ artifact-verification report by SHA-256. Editing, replacing or referring to an external provenance report therefore fails validation. The copied report must also name the pinned Rayforge commit, contain valid bundle and installer SHA-256 values, and confirm that the exact current addon file count was -verified. +verified. Its own schema, checkpoint identity and addon identity are fixed, and +its recorded checkpoint-manifest hash must match the copied schema-2 manifest. 1. Pass the non-executing artifact verifier above. 2. Launch the portable build with a fresh application-data directory. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c53d97..b711838 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -335,7 +335,8 @@ ignored build area, records their hashes and refuses to overwrite an existing test run. Its schema-2 provenance chain binds the editable manual report to the exact kit manifest, repository commit and non-executing artifact-verification report; substituted or changed inputs, a different Rayforge pin, malformed -artifact hashes or an unverified addon fail validation. +artifact hashes, a changed checkpoint manifest or an unverified addon fail +validation. The pinned upstream contracts and license notices are now machine-verifiable. Rayforge's documented addon lifecycle is viable, but production driver registration still needs a supported symmetric registry API. The current diff --git a/scripts/prepare_m1_acceptance_kit.py b/scripts/prepare_m1_acceptance_kit.py index 96cd7db..c701b8a 100644 --- a/scripts/prepare_m1_acceptance_kit.py +++ b/scripts/prepare_m1_acceptance_kit.py @@ -49,15 +49,28 @@ def create_kit( output: Path, repository_commit: str | None = None, artifact_verification: Path | None = None, + checkpoint_manifest: Path | None = None, ) -> dict: verification_source = None - if artifact_verification is not None: + checkpoint_source = None + if (artifact_verification is None) != (checkpoint_manifest is None): + raise ValueError( + "artifact verification and checkpoint manifest must be supplied together" + ) + if artifact_verification is not None and checkpoint_manifest is not None: verification_source = artifact_verification.resolve() + checkpoint_source = checkpoint_manifest.resolve() verification = json.loads( verification_source.read_text(encoding="utf-8-sig") ) + checkpoint_bytes = checkpoint_source.read_bytes() + checkpoint = json.loads(checkpoint_bytes.decode("utf-8-sig")) + checkpoint_record = verification.get("checkpoint_manifest", {}) if ( - verification.get("status") != "passed" + verification.get("schema_version") != 1 + or verification.get("verification") + != "Rayforge Windows no-device artifact verification" + or verification.get("status") != "passed" or verification.get("hardware_access") is not False or verification.get("executed_binaries") is not False or verification.get("manual_interaction_required") is not True @@ -65,6 +78,19 @@ def create_kit( or SHA256.fullmatch(verification.get("bundle_sha256", "")) is None or SHA256.fullmatch(verification.get("installer_sha256", "")) is None or verification.get("verified_addon_files") != EXPECTED_ADDON_FILES + or verification.get("addon") != "the_pirate_ray_jcz" + or checkpoint_record.get("path") != checkpoint_source.name + or checkpoint_record.get("sha256") + != hashlib.sha256(checkpoint_bytes).hexdigest() + or checkpoint.get("schema_version") != 2 + or checkpoint.get("checkpoint") != "Rayforge Windows no-device bundle" + or checkpoint.get("addon") != verification.get("addon") + or checkpoint.get("rayforge_commit") + != verification.get("rayforge_commit") + or checkpoint.get("bundle", {}).get("sha256") + != verification.get("bundle_sha256") + or checkpoint.get("installer", {}).get("sha256") + != verification.get("installer_sha256") ): raise ValueError("artifact-verification report did not pass the no-device gate") @@ -116,6 +142,8 @@ def create_kit( if verification_source is not None: verification_target = output / "artifact-verification.json" shutil.copyfile(verification_source, verification_target) + checkpoint_target = output / checkpoint_source.name + shutil.copyfile(checkpoint_source, checkpoint_target) report["artifact_verification"] = { "status": "passed", "report_path": verification_target.name, @@ -136,13 +164,22 @@ def main() -> int: type=Path, default=ROOT / ".build" / "acceptance-kit", ) + parser.add_argument( + "--checkpoint-manifest", + type=Path, + help="schema-2 manifest consumed by the artifact verifier", + ) parser.add_argument( "--artifact-verification", type=Path, help="passing JSON output from verify_rayforge_windows_checkpoint.py", ) args = parser.parse_args() - result = create_kit(args.output, artifact_verification=args.artifact_verification) + result = create_kit( + args.output, + artifact_verification=args.artifact_verification, + checkpoint_manifest=args.checkpoint_manifest, + ) print(f"kit={args.output.resolve()}") print(f"files={len(result['files'])}") print(f"repository_commit={result['repository_commit']}") diff --git a/scripts/validate_m1_manual_acceptance.py b/scripts/validate_m1_manual_acceptance.py index 1aaafce..6f257b2 100644 --- a/scripts/validate_m1_manual_acceptance.py +++ b/scripts/validate_m1_manual_acceptance.py @@ -67,14 +67,24 @@ def _validate_evidence(report_root: Path, records: list[dict]) -> list[str]: def _bound_json(report_root: Path, binding: dict, label: str) -> tuple[dict | None, list[str]]: - value = binding.get("report_path") or binding.get("manifest_path") or "" + value = ( + binding.get("report_path") + or binding.get("manifest_path") + or binding.get("path") + or "" + ) relative = PurePosixPath(value) if not value or relative.is_absolute() or ".." in relative.parts: return None, [f"unsafe {label} path: {value!r}"] path = report_root.joinpath(*relative.parts) if not path.is_file(): return None, [f"{label} is missing: {value}"] - digest = binding.get("report_sha256") or binding.get("manifest_sha256") or "" + digest = ( + binding.get("report_sha256") + or binding.get("manifest_sha256") + or binding.get("sha256") + or "" + ) actual = hashlib.sha256(path.read_bytes()).hexdigest() if not SHA256.fullmatch(digest) or digest != actual: return None, [f"{label} SHA-256 mismatch: {value}"] @@ -112,6 +122,13 @@ def _validate_artifact_report(report_root: Path, binding: dict) -> list[str]: return errors if verification.get("status") != "passed": errors.append("artifact verification did not pass") + if verification.get("schema_version") != 1: + errors.append("unsupported artifact-verification schema") + if ( + verification.get("verification") + != "Rayforge Windows no-device artifact verification" + ): + errors.append("unexpected artifact-verification identity") if verification.get("hardware_access") is not False: errors.append("artifact verification does not preserve the hardware gate") if verification.get("executed_binaries") is not False: @@ -120,12 +137,37 @@ def _validate_artifact_report(report_root: Path, binding: dict) -> list[str]: errors.append("artifact verification dropped the manual interaction gate") if verification.get("rayforge_commit") != RAYFORGE_COMMIT: errors.append("artifact verification uses the wrong Rayforge commit") + if verification.get("addon") != "the_pirate_ray_jcz": + errors.append("artifact verification uses the wrong addon") if SHA256.fullmatch(verification.get("bundle_sha256", "")) is None: errors.append("artifact verification has an invalid bundle SHA-256") if SHA256.fullmatch(verification.get("installer_sha256", "")) is None: errors.append("artifact verification has an invalid installer SHA-256") if verification.get("verified_addon_files") != EXPECTED_ADDON_FILES: errors.append("artifact verification did not verify the exact addon file set") + checkpoint, checkpoint_errors = _bound_json( + report_root, + verification.get("checkpoint_manifest", {}), + "checkpoint manifest", + ) + errors.extend(checkpoint_errors) + if checkpoint is not None: + if checkpoint.get("schema_version") != 2: + errors.append("unsupported checkpoint-manifest schema") + if checkpoint.get("checkpoint") != "Rayforge Windows no-device bundle": + errors.append("unexpected checkpoint-manifest identity") + if checkpoint.get("addon") != verification.get("addon"): + errors.append("checkpoint-manifest addon mismatch") + if checkpoint.get("rayforge_commit") != verification.get("rayforge_commit"): + errors.append("checkpoint-manifest Rayforge commit mismatch") + if checkpoint.get("bundle", {}).get("sha256") != verification.get( + "bundle_sha256" + ): + errors.append("checkpoint-manifest bundle SHA-256 mismatch") + if checkpoint.get("installer", {}).get("sha256") != verification.get( + "installer_sha256" + ): + errors.append("checkpoint-manifest installer SHA-256 mismatch") return errors diff --git a/scripts/verify_rayforge_windows_checkpoint.py b/scripts/verify_rayforge_windows_checkpoint.py index 05c9765..7d250e0 100644 --- a/scripts/verify_rayforge_windows_checkpoint.py +++ b/scripts/verify_rayforge_windows_checkpoint.py @@ -65,9 +65,14 @@ def _relative_below(path: PurePosixPath, root: PurePosixPath) -> PurePosixPath | def verify(manifest_path: Path) -> dict: manifest_path = manifest_path.resolve() - manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) + manifest_bytes = manifest_path.read_bytes() + manifest = json.loads(manifest_bytes.decode("utf-8-sig")) if manifest.get("schema_version") != 2: raise ValueError("unsupported checkpoint schema") + if manifest.get("checkpoint") != "Rayforge Windows no-device bundle": + raise ValueError("unexpected checkpoint identity") + if manifest.get("addon") != "the_pirate_ray_jcz": + raise ValueError("unexpected checkpoint addon") if manifest.get("rayforge_commit") != UPSTREAM["commit"]: raise ValueError("Rayforge commit does not match the repository pin") if manifest.get("hardware_access") is not False or manifest.get("transport") is not None: @@ -143,8 +148,15 @@ def verify(manifest_path: Path) -> dict: verified_addon_files = len(packaged_addon) return { + "schema_version": 1, + "verification": "Rayforge Windows no-device artifact verification", "status": "passed", "rayforge_commit": manifest["rayforge_commit"], + "addon": manifest["addon"], + "checkpoint_manifest": { + "path": manifest_path.name, + "sha256": _digest(manifest_bytes), + }, "hardware_access": False, "executed_binaries": False, "bundle_sha256": manifest["bundle"]["sha256"], diff --git a/tests/test_m1_acceptance_kit.py b/tests/test_m1_acceptance_kit.py index c0168a5..be30964 100644 --- a/tests/test_m1_acceptance_kit.py +++ b/tests/test_m1_acceptance_kit.py @@ -16,8 +16,20 @@ from scripts.prepare_m1_acceptance_kit import ( class M1AcceptanceKitTests(unittest.TestCase): @staticmethod - def _verification(status="passed"): - return { + def _artifact_pair(root: Path, status="passed"): + checkpoint = { + "schema_version": 2, + "checkpoint": "Rayforge Windows no-device bundle", + "addon": "the_pirate_ray_jcz", + "rayforge_commit": RAYFORGE_COMMIT, + "bundle": {"sha256": "1" * 64}, + "installer": {"sha256": "2" * 64}, + } + checkpoint_path = root / "rayforge-windows-checkpoint.json" + checkpoint_path.write_text(json.dumps(checkpoint), encoding="utf-8") + verification = { + "schema_version": 1, + "verification": "Rayforge Windows no-device artifact verification", "status": status, "hardware_access": False, "executed_binaries": False, @@ -26,7 +38,15 @@ class M1AcceptanceKitTests(unittest.TestCase): "bundle_sha256": "1" * 64, "installer_sha256": "2" * 64, "verified_addon_files": EXPECTED_ADDON_FILES, + "addon": "the_pirate_ray_jcz", + "checkpoint_manifest": { + "path": checkpoint_path.name, + "sha256": hashlib.sha256(checkpoint_path.read_bytes()).hexdigest(), + }, } + verification_path = root / "verification.json" + verification_path.write_text(json.dumps(verification), encoding="utf-8") + return verification_path, checkpoint_path def test_kit_contains_only_allowlisted_public_inputs(self): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: @@ -84,15 +104,13 @@ class M1AcceptanceKitTests(unittest.TestCase): def test_passing_artifact_verification_is_bound_into_report(self): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: root = Path(directory) - verification = root / "verification.json" - verification.write_text( - json.dumps(self._verification()), encoding="utf-8" - ) + verification, checkpoint = self._artifact_pair(root) output = root / "kit" create_kit( output, repository_commit="e" * 40, artifact_verification=verification, + checkpoint_manifest=checkpoint, ) copied = output / "artifact-verification.json" report = json.loads((output / "manual-report.json").read_text(encoding="utf-8")) @@ -106,22 +124,20 @@ class M1AcceptanceKitTests(unittest.TestCase): def test_failed_artifact_verification_is_rejected(self): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: root = Path(directory) - verification = root / "verification.json" - verification.write_text( - json.dumps(self._verification(status="failed")), encoding="utf-8" - ) + verification, checkpoint = self._artifact_pair(root, status="failed") with self.assertRaisesRegex(ValueError, "did not pass"): create_kit( root / "kit", repository_commit="f" * 40, artifact_verification=verification, + checkpoint_manifest=checkpoint, ) def test_wrong_rayforge_pin_or_missing_hashes_are_rejected(self): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: root = Path(directory) - verification = root / "verification.json" - result = self._verification() + verification, checkpoint = self._artifact_pair(root) + result = json.loads(verification.read_text(encoding="utf-8")) result["rayforge_commit"] = "0" * 40 result["bundle_sha256"] = "not-a-hash" verification.write_text(json.dumps(result), encoding="utf-8") @@ -130,6 +146,20 @@ class M1AcceptanceKitTests(unittest.TestCase): root / "kit", repository_commit="f" * 40, artifact_verification=verification, + checkpoint_manifest=checkpoint, + ) + + def test_changed_checkpoint_manifest_is_rejected(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + root = Path(directory) + verification, checkpoint = self._artifact_pair(root) + checkpoint.write_text("{}", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "did not pass"): + create_kit( + root / "kit", + repository_commit="f" * 40, + artifact_verification=verification, + checkpoint_manifest=checkpoint, ) diff --git a/tests/test_m1_manual_acceptance.py b/tests/test_m1_manual_acceptance.py index e520e2c..7a1ed3d 100644 --- a/tests/test_m1_manual_acceptance.py +++ b/tests/test_m1_manual_acceptance.py @@ -42,7 +42,19 @@ class M1ManualAcceptanceTests(unittest.TestCase): "manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), "repository_commit": "b" * 40, } + checkpoint = { + "schema_version": 2, + "checkpoint": "Rayforge Windows no-device bundle", + "addon": "the_pirate_ray_jcz", + "rayforge_commit": RAYFORGE_COMMIT, + "bundle": {"sha256": "1" * 64}, + "installer": {"sha256": "2" * 64}, + } + checkpoint_path = root / "rayforge-windows-checkpoint.json" + checkpoint_path.write_text(json.dumps(checkpoint), encoding="utf-8") verification = { + "schema_version": 1, + "verification": "Rayforge Windows no-device artifact verification", "status": "passed", "hardware_access": False, "executed_binaries": False, @@ -51,6 +63,11 @@ class M1ManualAcceptanceTests(unittest.TestCase): "bundle_sha256": "1" * 64, "installer_sha256": "2" * 64, "verified_addon_files": EXPECTED_ADDON_FILES, + "addon": "the_pirate_ray_jcz", + "checkpoint_manifest": { + "path": checkpoint_path.name, + "sha256": hashlib.sha256(checkpoint_path.read_bytes()).hexdigest(), + }, } verification_path = root / "artifact-verification.json" verification_path.write_text(json.dumps(verification), encoding="utf-8") @@ -163,6 +180,19 @@ class M1ManualAcceptanceTests(unittest.TestCase): self.assertIn("artifact verification uses the wrong Rayforge commit", result["errors"]) self.assertIn("artifact verification has an invalid installer SHA-256", result["errors"]) + def test_changed_checkpoint_manifest_is_rejected(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + root = Path(directory) + report = self._passing(root) + (root / "rayforge-windows-checkpoint.json").write_text( + "{}", encoding="utf-8" + ) + result = validate(self._write(root, report)) + self.assertEqual("failed", result["status"]) + self.assertTrue( + any("checkpoint manifest SHA-256 mismatch" in error for error in result["errors"]) + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_windows_checkpoint_verifier.py b/tests/test_windows_checkpoint_verifier.py index 2cd741c..d95137a 100644 --- a/tests/test_windows_checkpoint_verifier.py +++ b/tests/test_windows_checkpoint_verifier.py @@ -94,6 +94,9 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: result = verify(self._checkpoint(Path(directory))) self.assertEqual(result["status"], "passed") + self.assertEqual(1, result["schema_version"]) + self.assertEqual("the_pirate_ray_jcz", result["addon"]) + self.assertRegex(result["checkpoint_manifest"]["sha256"], r"^[0-9a-f]{64}$") self.assertFalse(result["executed_binaries"]) expected_count = sum( path.is_file() and "__pycache__" not in path.parts @@ -132,6 +135,15 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): with self.assertRaises(FileNotFoundError): verify(manifest) + def test_checkpoint_and_addon_identity_are_required(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + manifest = self._checkpoint(Path(directory)) + data = json.loads(manifest.read_text(encoding="utf-8")) + data["addon"] = "foreign_addon" + manifest.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unexpected checkpoint addon"): + verify(manifest) + def test_manifest_cannot_omit_or_change_repository_addon_source(self): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: manifest = self._checkpoint(Path(directory))