From 21c669ff695f80d1215d551a76e63ddbd4f3ba2b Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:59:34 -0700 Subject: [PATCH 01/24] Add certificate mode status policy for gated PF-Core modes. Introduce an explicit A0 status table so experimental certificate modes cannot silently claim release-ready evidence. Schemas and unit tests pin the policy surface for downstream binding. --- .../pf_core_certificate_mode_status.py | 169 ++++++++++++++++++ .../test_pf_core_certificate_mode_status.py | 164 +++++++++++++++++ schemas/PFCoreCertificate.v0.schema.json | 64 ++++++- schemas/pf_core.certificate_mode_status.json | 80 +++++++++ schemas/pf_core.defs.json | 11 ++ 5 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 python/pcs_core/pf_core_certificate_mode_status.py create mode 100644 python/tests/test_pf_core_certificate_mode_status.py create mode 100644 schemas/pf_core.certificate_mode_status.json diff --git a/python/pcs_core/pf_core_certificate_mode_status.py b/python/pcs_core/pf_core_certificate_mode_status.py new file mode 100644 index 0000000..fa6e441 --- /dev/null +++ b/python/pcs_core/pf_core_certificate_mode_status.py @@ -0,0 +1,169 @@ +"""Machine-readable PF-Core certificate mode claim-surface status (A0). + +Authoritative table: ``schemas/pf_core.certificate_mode_status.json``. +Disabled modes fail closed under ``--release-grade`` and the default public CLI. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any, Mapping + +from pcs_core.paths import schemas_dir + +MODE_STATUS_FILENAME = "pf_core.certificate_mode_status.json" +MODE_STATUSES = frozenset({"release_candidate", "legacy", "disabled", "experimental", "preview"}) +# Public CLI may issue these when allowed_issuance is true. +PUBLIC_CLI_STATUSES = frozenset({"release_candidate", "legacy", "experimental"}) +# Release-grade issuance is limited to RC + legacy (non-tool-use). +RELEASE_GRADE_STATUSES = frozenset({"release_candidate", "legacy"}) + + +class CertificateModeStatusError(ValueError): + """Raised when the mode-status table is missing or malformed.""" + + +@lru_cache(maxsize=1) +def certificate_mode_status_path() -> Path: + path = schemas_dir() / MODE_STATUS_FILENAME + if not path.is_file(): + raise CertificateModeStatusError(f"missing certificate mode status table: {path}") + return path + + +@lru_cache(maxsize=1) +def load_certificate_mode_status() -> dict[str, Any]: + path = certificate_mode_status_path() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise CertificateModeStatusError(f"unreadable mode status table {path}: {exc}") from exc + if not isinstance(data, dict): + raise CertificateModeStatusError(f"mode status table root must be an object: {path}") + modes = data.get("modes") + if not isinstance(modes, list) or not modes: + raise CertificateModeStatusError(f"mode status table missing modes[]: {path}") + seen: set[str] = set() + for entry in modes: + if not isinstance(entry, dict): + raise CertificateModeStatusError("modes[] entries must be objects") + mode = str(entry.get("mode") or "") + status = str(entry.get("status") or "") + if not mode: + raise CertificateModeStatusError("modes[] entry missing mode") + if mode in seen: + raise CertificateModeStatusError(f"duplicate mode in status table: {mode!r}") + if status not in MODE_STATUSES: + raise CertificateModeStatusError( + f"mode {mode!r} has unknown status {status!r}; " + f"expected one of {sorted(MODE_STATUSES)}" + ) + if "allowed_issuance" not in entry or not isinstance(entry["allowed_issuance"], bool): + raise CertificateModeStatusError(f"mode {mode!r} requires boolean allowed_issuance") + if "description" not in entry or not str(entry.get("description") or "").strip(): + raise CertificateModeStatusError(f"mode {mode!r} requires description") + seen.add(mode) + return data + + +def iter_mode_status_entries() -> list[dict[str, Any]]: + data = load_certificate_mode_status() + modes = data["modes"] + assert isinstance(modes, list) + return [dict(entry) for entry in modes if isinstance(entry, dict)] + + +def mode_status_by_name() -> dict[str, dict[str, Any]]: + return {str(entry["mode"]): entry for entry in iter_mode_status_entries()} + + +def get_certificate_mode_status(mode: str) -> dict[str, Any] | None: + return mode_status_by_name().get(mode) + + +def get_external_claim_class_status(claim_class: str) -> dict[str, Any] | None: + data = load_certificate_mode_status() + entries = data.get("external_claim_classes") or [] + if not isinstance(entries, list): + return None + for entry in entries: + if isinstance(entry, dict) and str(entry.get("claim_class") or "") == claim_class: + return dict(entry) + return None + + +def enforce_certificate_mode_issuance( + mode: str, + *, + release_grade: bool = False, + allow_non_public: bool = False, +) -> str | None: + """Return an error message when public / release-grade issuance must fail closed. + + Codegen and fixture generators may pass ``allow_non_public=True``. The default + public CLI and ``--release-grade`` paths must leave it false. + """ + if allow_non_public: + return None + entry = get_certificate_mode_status(mode) + if entry is None: + return f"unknown certificate_mode {mode!r} (not present in mode status table)" + status = str(entry.get("status") or "") + allowed = bool(entry.get("allowed_issuance")) + if not allowed: + return ( + f"certificate mode {mode!r} is {status}; public issuance refused " + "(allowed_issuance=false in schemas/pf_core.certificate_mode_status.json)" + ) + if status not in PUBLIC_CLI_STATUSES: + return ( + f"certificate mode {mode!r} status {status!r} is not issuable via the " + f"default public CLI (allowed: {sorted(PUBLIC_CLI_STATUSES)})" + ) + if release_grade and status not in RELEASE_GRADE_STATUSES: + return ( + f"certificate mode {mode!r} status {status!r} is not allowed under " + f"--release-grade (allowed: {sorted(RELEASE_GRADE_STATUSES)})" + ) + return None + + +def public_issuance_modes(*, release_grade: bool = False) -> frozenset[str]: + """Modes that may be issued under the stated policy.""" + allowed: set[str] = set() + for entry in iter_mode_status_entries(): + mode = str(entry["mode"]) + if enforce_certificate_mode_issuance(mode, release_grade=release_grade) is None: + allowed.add(mode) + return frozenset(allowed) + + +def mode_status_summary_lines() -> list[str]: + lines: list[str] = [] + for entry in sorted(iter_mode_status_entries(), key=lambda e: str(e["mode"])): + lines.append( + f"{entry['mode']}: status={entry['status']} " + f"allowed_issuance={entry['allowed_issuance']}" + ) + external = get_external_claim_class_status("CertificateChecked") + if external: + lines.append( + f"external CertificateChecked: status={external['status']} " + f"allowed_issuance={external['allowed_issuance']}" + ) + return lines + + +def assert_status_table_covers_modes(known_modes: Mapping[str, Any] | frozenset[str]) -> None: + """Fail if CERTIFICATE_MODES and the status table drift apart.""" + table_modes = set(mode_status_by_name()) + known = set(known_modes) + missing = known - table_modes + extra = table_modes - known + if missing or extra: + raise CertificateModeStatusError( + "certificate mode status table drift: " + f"missing_from_table={sorted(missing)} extra_in_table={sorted(extra)}" + ) diff --git a/python/tests/test_pf_core_certificate_mode_status.py b/python/tests/test_pf_core_certificate_mode_status.py new file mode 100644 index 0000000..1e4200b --- /dev/null +++ b/python/tests/test_pf_core_certificate_mode_status.py @@ -0,0 +1,164 @@ +"""A0 certificate mode status table + public issuance fail-closed policy.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from pcs_core.lean_check import resolve_lean_check_artifact_paths, run_pfcore_lean_check +from pcs_core.paths import repo_root +from pcs_core.pf_core_certificate_mode_status import ( + assert_status_table_covers_modes, + enforce_certificate_mode_issuance, + get_certificate_mode_status, + get_external_claim_class_status, + load_certificate_mode_status, + public_issuance_modes, +) +from pcs_core.pf_core_lean_codegen import CERTIFICATE_MODES + +REPO = repo_root() +FILE_READ = REPO / "examples" / "pf-core-valid" / "file_read_allowed" / "trace.json" +DISABLED_MODES = frozenset( + { + "HandoffSafeCertificate", + "ContractCheckedCertificate", + "EffectFrameCertificate", + "FramePreservedCertificate", + } +) + + +def test_mode_status_table_loads_and_covers_all_modes() -> None: + data = load_certificate_mode_status() + assert data["artifact_type"] == "PFCoreCertificateModeStatus.v0" + assert_status_table_covers_modes(CERTIFICATE_MODES) + assert get_certificate_mode_status("TraceSafeRCertificate")["status"] == "release_candidate" + assert get_certificate_mode_status("TraceSafeCertificate")["status"] == "legacy" + assert get_certificate_mode_status("CompositionalExtensionCertificate")["status"] == ( + "experimental" + ) + for mode in DISABLED_MODES: + entry = get_certificate_mode_status(mode) + assert entry is not None + assert entry["status"] == "disabled" + assert entry["allowed_issuance"] is False + external = get_external_claim_class_status("CertificateChecked") + assert external is not None + assert external["status"] == "preview" + + +@pytest.mark.parametrize("mode", sorted(DISABLED_MODES)) +def test_disabled_modes_fail_closed_on_public_issuance(mode: str) -> None: + err = enforce_certificate_mode_issuance(mode, release_grade=False) + assert err is not None + assert "allowed_issuance=false" in err or "disabled" in err + err_rg = enforce_certificate_mode_issuance(mode, release_grade=True) + assert err_rg is not None + + +def test_experimental_allowed_on_public_cli_but_not_release_grade() -> None: + mode = "CompositionalExtensionCertificate" + assert enforce_certificate_mode_issuance(mode, release_grade=False) is None + err = enforce_certificate_mode_issuance(mode, release_grade=True) + assert err is not None + assert "release-grade" in err + + +def test_public_issuance_modes_sets() -> None: + public = public_issuance_modes(release_grade=False) + assert "TraceSafeRCertificate" in public + assert "TraceSafeCertificate" in public + assert "CompositionalExtensionCertificate" in public + assert public.isdisjoint(DISABLED_MODES) + release = public_issuance_modes(release_grade=True) + assert "TraceSafeRCertificate" in release + assert "TraceSafeCertificate" in release + assert "CompositionalExtensionCertificate" not in release + + +@pytest.mark.parametrize("mode", sorted(DISABLED_MODES)) +def test_lean_check_rejects_disabled_modes_by_default(mode: str, tmp_path: Path) -> None: + trace = json.loads(FILE_READ.read_text(encoding="utf-8")) + work = tmp_path / "trace.json" + work.write_text(json.dumps(trace), encoding="utf-8") + code, result = run_pfcore_lean_check( + work, + certificate_mode=mode, + skip_build=True, + skip_lean_proof=True, + release_grade=False, + ) + assert code != 0 + codes = [issue.get("code") for issue in result.get("issues", [])] + assert "CertificateModeIssuanceDenied" in codes + + +def test_lean_check_reports_deterministic_artifact_paths(tmp_path: Path) -> None: + out = tmp_path / "PFCoreCertificate.v0.json" + result_out = tmp_path / "LeanCheckResult.v0.json" + paths = resolve_lean_check_artifact_paths( + trace_path=FILE_READ, + out_path=out, + result_out_path=result_out, + generated_proof_path=tmp_path / "proof.lean", + ) + assert paths["certificate"] == str(out.resolve()) + assert paths["lean_check_result"] == str(result_out.resolve()) + assert paths["generated_proof"] == str((tmp_path / "proof.lean").resolve()) + assert paths["semantic_projection"].endswith("PFCoreSemanticProjection.v0.json") + assert paths["theorem_manifest"].endswith("PFCoreTheoremManifest.v0.json") + + code, result = run_pfcore_lean_check( + FILE_READ, + out_path=out, + result_out_path=result_out, + skip_build=True, + skip_lean_proof=True, + ) + assert "artifact_paths" in result + assert result["artifact_paths"]["lean_check_result"] == str(result_out.resolve()) + assert result_out.is_file() + assert code in (0, 1) + + +def _workflow_text(name: str) -> str: + return (REPO / ".github" / "workflows" / name).read_text(encoding="utf-8-sig") + + +def test_release_workflow_wires_lean_check_result() -> None: + text = _workflow_text("release.yml") + assert "workflow_dispatch:" in text + assert "--result-out /tmp/pfcore-release-lean-check.json" in text + assert "--lean-check-result /tmp/pfcore-release-lean-check.json" in text + assert "Upload local release artifacts" in text + # Preview path still runs lean-check then bundle then validate then attest/absence. + lean_idx = text.index("--result-out /tmp/pfcore-release-lean-check.json") + bundle_idx = text.index("--lean-check-result /tmp/pfcore-release-lean-check.json") + validate_idx = text.index("pcs pf-core validate-bundle ../dist/release-bundle") + attest_idx = text.index("--allow-absence") + upload_idx = text.index("Upload local release artifacts") + assert lean_idx < bundle_idx < validate_idx < attest_idx < upload_idx + + +def test_pf_core_release_gate_preview_path_includes_lean_check_result() -> None: + text = _workflow_text("pf-core-release-gate.yml") + assert "workflow_dispatch:" in text + assert "--result-out /tmp/pfcore-preview-lean-check.json" in text + assert "--lean-check-result /tmp/pfcore-preview-lean-check.json" in text + assert "--result-out /tmp/pfcore-release-lean-check.json" in text + assert "LEAN_CHECK_RESULT=/tmp/pfcore-release-lean-check.json" in text + assert '--lean-check-result "${LEAN_CHECK_RESULT}"' in text + assert "Preview lean-check" in text + assert "Upload preview release bundle" in text + preview_lean = text.index("--result-out /tmp/pfcore-preview-lean-check.json") + preview_bundle = text.index("--lean-check-result /tmp/pfcore-preview-lean-check.json") + preview_validate = text.index( + "pcs pf-core validate-bundle /tmp/pfcore-preview-bundle", + preview_bundle, + ) + preview_attest = text.index("--allow-absence", preview_validate) + preview_upload = text.index("Upload preview release bundle") + assert preview_lean < preview_bundle < preview_validate < preview_attest < preview_upload diff --git a/schemas/PFCoreCertificate.v0.schema.json b/schemas/PFCoreCertificate.v0.schema.json index deda124..1884885 100644 --- a/schemas/PFCoreCertificate.v0.schema.json +++ b/schemas/PFCoreCertificate.v0.schema.json @@ -46,7 +46,8 @@ "HandoffSafeCertificate", "CompositionalExtensionCertificate", "ContractCheckedCertificate" - ] + ], + "description": "See schemas/pf_core.certificate_mode_status.json for public issuance status (RC/legacy/disabled/experimental)." }, "theorem_inventory": { "type": "array", @@ -112,6 +113,67 @@ } }, "default_contract_ref": { "type": "string", "minLength": 1 }, + "selected_contract_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Explicitly selected contract IDs bound via evidence_selection.contract_ids" + }, + "contract_source_file_digests": { + "type": "object", + "additionalProperties": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "description": "SHA-256 digests of selected contract source files (or embedded payloads)" + }, + "contract_evidence_digest": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "Digest binding selected contracts, source digests, effective layers, and theorems" + }, + "contract_theorem_names": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Concrete contract theorem names discharged for ContractCheckedCertificate" + }, + "selected_handoff_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Explicitly selected handoff IDs bound via evidence_selection.handoff_ids" + }, + "handoff_source_file_digests": { + "type": "object", + "additionalProperties": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "description": "SHA-256 digests of selected handoff source files (or embedded payloads)" + }, + "handoff_evidence_digest": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "Digest binding selected handoffs, source digests, and theorems" + }, + "handoff_theorem_names": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Concrete handoff theorem names discharged for HandoffSafeCertificate" + }, + "effect_frame_id": { + "type": "string", + "minLength": 1, + "description": "Independent PFCoreEffectFrame.v0 frame_id bound for EffectFrameCertificate" + }, + "effect_frame_path": { + "type": "string", + "minLength": 1, + "description": "Exact on-disk path of the declared effect-frame artifact" + }, + "effect_frame_digest": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "SHA-256 digest of the declared effect-frame artifact bytes" + }, + "transition_chain_digest": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "Digest binding FramePreservedCertificate initial + post-states and event order" + }, + "transition_event_count": { + "type": "integer", + "minimum": 0, + "description": "Number of proved operational transitions for FramePreservedCertificate" + }, "event_count": { "type": "integer", "minimum": 0 }, "replay_match": { "type": "boolean" }, "original_trace_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, diff --git a/schemas/pf_core.certificate_mode_status.json b/schemas/pf_core.certificate_mode_status.json new file mode 100644 index 0000000..53bcc3a --- /dev/null +++ b/schemas/pf_core.certificate_mode_status.json @@ -0,0 +1,80 @@ +{ + "schema_version": "v0", + "artifact_type": "PFCoreCertificateModeStatus.v0", + "description": "Machine-readable PF-Core public claim surface. Issuance of modes with allowed_issuance=false fails closed under --release-grade and the default public CLI.", + "status_enum": [ + "release_candidate", + "legacy", + "disabled", + "experimental", + "preview" + ], + "modes": [ + { + "mode": "TraceSafeRCertificate", + "status": "release_candidate", + "description": "Sole release-candidate LeanKernelChecked path for tool-use traces (resource-pattern refined TraceSafeR).", + "allowed_issuance": true + }, + { + "mode": "TraceSafeCertificate", + "status": "legacy", + "description": "Legacy base TraceSafe path for non-tool-use traces only; not release-grade for tool-use.", + "allowed_issuance": true + }, + { + "mode": "HandoffSafeCertificate", + "status": "disabled", + "description": "Disabled pending handoff evidence fidelity repair.", + "allowed_issuance": false + }, + { + "mode": "ContractCheckedCertificate", + "status": "disabled", + "description": "Contract evidence fidelity repaired (semantics_layer projection + explicit contract_ids); remains disabled for public RC until a later enablement pass. Issuable via --allow-non-public-modes.", + "allowed_issuance": false + }, + { + "mode": "EffectFrameCertificate", + "status": "disabled", + "description": "Independent PFCoreEffectFrame.v0 + non-tautological frame membership repaired; remains disabled for public RC until a later enablement pass. Issuable via --allow-non-public-modes.", + "allowed_issuance": false + }, + { + "mode": "FramePreservedCertificate", + "status": "disabled", + "description": "Transition redesign landed (explicit stepState witnesses; applyEvent no-ops rejected); remains disabled for public RC until a later enablement pass. Issuable via --allow-non-public-modes.", + "allowed_issuance": false + }, + { + "mode": "CompositionalExtensionCertificate", + "status": "experimental", + "description": "Experimental A6 compositional extension: safe prefix + EventSafe + successful stepState + preserved FrameValid frames => TraceSafe extended trace. Not release_candidate. Prefix-only TraceSafe chaining is the narrower TracePrefixSafe claim (see scaffolded_modes).", + "allowed_issuance": true + } + ], + "scaffolded_modes": [ + { + "mode": "TracePrefixSafeCertificate", + "status": "experimental", + "description": "Experimental alias for certificates that only prove each prefix is TraceSafe (no operational Applies / frame preservation). Prefer CompositionalExtensionCertificate for the A6 substantive predicate. Not in CERTIFICATE_MODES issuance surface yet.", + "allowed_issuance": false, + "formal_predicate": "TracePrefixSafe" + }, + { + "mode": "DenyClosedCertificate", + "status": "disabled", + "description": "Scaffolded only. Runtime evidence does not yet support post-deny closure of tool/mutation/network/message/code/release/state/delegation effects beyond declared footprints (EventSafeDenyClosed). Do not ship as a public claim. Base TraceSafe unchanged.", + "allowed_issuance": false, + "formal_predicate": "EventSafeDenyClosed / DenyClosedBundle (declared footprint only)" + } + ], + "external_claim_classes": [ + { + "claim_class": "CertificateChecked", + "status": "preview", + "description": "External CertifyEdge CertificateChecked attestation is preview until authenticated CertifyEdge pin.", + "allowed_issuance": true + } + ] +} diff --git a/schemas/pf_core.defs.json b/schemas/pf_core.defs.json index dbfeab0..6abcf17 100644 --- a/schemas/pf_core.defs.json +++ b/schemas/pf_core.defs.json @@ -43,6 +43,17 @@ "HandoffSafeCertificate", "CompositionalExtensionCertificate", "ContractCheckedCertificate" + ], + "description": "Issuance status is machine-readable in pf_core.certificate_mode_status.json (RC/legacy/disabled/experimental)." + }, + "certificate_mode_status": { + "type": "string", + "enum": [ + "release_candidate", + "legacy", + "disabled", + "experimental", + "preview" ] }, "decision": { From a427a463a84b8aeb6ab513616616dbf2ad3ec2f2 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:59:41 -0700 Subject: [PATCH 02/24] Propagate lean-check-result fields through release validation. Extend LeanCheckResult and trust/catalog plumbing so release workflows can gate on structured check outcomes instead of opaque exit codes alone. --- .../lean_check_result.v0.json | 5 +- python/pcs_core/lean_catalog.py | 39 +- python/pcs_core/lean_check.py | 336 +++++++++++++----- python/pcs_core/lean_trust.py | 178 +++++----- python/pcs_core/lean_validate.py | 8 + schemas/LeanCheckResult.v0.schema.json | 12 + 6 files changed, 390 insertions(+), 188 deletions(-) diff --git a/examples/computation-release/lean_check_result.v0.json b/examples/computation-release/lean_check_result.v0.json index aa39fe9..5188eae 100644 --- a/examples/computation-release/lean_check_result.v0.json +++ b/examples/computation-release/lean_check_result.v0.json @@ -6,7 +6,7 @@ "lean_theorem": "ReleaseChainAdmissible", "status": "ProofChecked", "claim_class": "ProofChecked", - "checked_at": "2026-07-22T15:01:17Z", + "checked_at": "2026-07-23T16:06:57Z", "lean_version": "leanprover/lean4:stable", "source_repo": "https://github.com/SentinelOps-CI/pcs-core", "source_commit": "d444444444444444444444444444444444444444", @@ -36,5 +36,6 @@ ], "lean_proof_checked": false, "disclaimer": "PCS release-envelope consistency check validates ProofObligation.v0 release-envelope consistency against the PCS theorem catalog. A `ProofChecked` or `EnvelopeLeanChecked` LeanCheckResult does not imply PF-Core trace safety. Use `pcs pf-core lean-check --trace ` for PF-Core kernel assurance.", - "signature_or_digest": "sha256:f80e85e7d24a28f44cdb57029ec4442ddf19debb70c2881e8a2a9dc7b1a38d3a" + "pcs_projection_manifest_hash": "sha256:1c6b86c12bbae1504d582a5533a31eeb56add9a2e855bcb3dc8e26ddb28f7914", + "signature_or_digest": "sha256:8c40c0b81927baff1be179058352fb86be3d42ee84682d604db4cdaee09cef9d" } diff --git a/python/pcs_core/lean_catalog.py b/python/pcs_core/lean_catalog.py index e0adb65..7e61b74 100644 --- a/python/pcs_core/lean_catalog.py +++ b/python/pcs_core/lean_catalog.py @@ -11,7 +11,7 @@ ), "ToolTraceHashMatchesCertificate": "tool_trace_hash_matches_certificate", "ComputationWitnessHashAlignment": "witness_result_hashes_admissible", - "ReleaseChainAdmissible": "concrete_release_chain_admissible_prop", + "ReleaseChainAdmissible": "concrete_envelope_release_admissible_prop", } PCS_UNTRUSTED_OBLIGATION_KIND_THEOREM: dict[str, str] = {} @@ -76,6 +76,19 @@ "EventSafeDenyClosedImpliesEventSafe": "eventSafeDenyClosed_implies_eventSafe", "TraceSafeDenyClosedImpliesTraceSafe": "traceSafeDenyClosed_implies_traceSafe", "TraceSafeImpliesTenantProjectionIsolation": ("traceSafe_implies_tenant_projection_isolation"), + "CompositionalSafeExtensionYieldsSafeExtendedTrace": ( + "compositional_safe_extension_yields_safe_extended_trace" + ), + "CompositionalSafeExtensionPreservesContractInvariant": ( + "compositional_safe_extension_preserves_contract_invariant" + ), + "TracePrefixSafeExtension": "trace_prefix_safe_extension", + "TrustedInstrumentationImpliesObservationSoundness": ( + "trusted_instrumentation_implies_observation_soundness" + ), + "ObservationSoundnessNotTrustedWithoutAuthenticity": ( + "observation_soundness_not_trusted_without_authenticity" + ), } PF_CORE_SOUNDNESS_THEOREMS = frozenset( @@ -158,6 +171,15 @@ "low_output_equivalent_refl", "low_equivalent_states_refl", "tenant_projection_isolation_of_trace_safe", + "compositional_safe_extension_yields_safe_extended_trace", + "compositional_safe_extension_preserves_contract_invariant", + "trace_prefix_safe_extension", + "trusted_instrumentation_implies_observation_soundness", + "trusted_instrumentation_implies_observations_agree", + "observation_soundness_not_trusted_without_authenticity", + "observation_soundness_kinds_declared", + "attested_execution_no_undeclared_sensitive_observation", + "paired_run_same_execution_low_output", } ) @@ -187,10 +209,25 @@ PCS_CONCRETE_PROOF_THEOREMS = frozenset( { "concrete_certificate_matches_runtime", + "concrete_certificate_matches_runtime_prop", "concrete_verification_admits_bundle", + "concrete_verification_admits_bundle_prop", "concrete_signed_bundle_admissible", + "concrete_signed_bundle_admissible_prop", "concrete_release_chain_admissible", "concrete_release_chain_admissible_prop", + "concrete_envelope_release_admissible", + "concrete_envelope_release_admissible_prop", + "concrete_envelope_projection_bound", + "concrete_envelope_projection_bound_prop", + "concrete_tool_trace_hash_matches", + "concrete_tool_trace_hash_matches_prop", + "concrete_tool_use_release_admissible_prop", + "concrete_witness_result_hashes_admissible", + "concrete_witness_result_hashes_admissible_prop", + "concrete_witness_result_hash_listed", + "concrete_witness_result_hash_listed_prop", + "concrete_computation_release_admissible_prop", } ) diff --git a/python/pcs_core/lean_check.py b/python/pcs_core/lean_check.py index 7eefd42..0575954 100644 --- a/python/pcs_core/lean_check.py +++ b/python/pcs_core/lean_check.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import Any, Mapping +from pcs_core.asset_resolver import lean_root as resolve_lean_root +from pcs_core.asset_resolver import pf_core_generated_root, pf_core_kernel_root from pcs_core.hash import canonical_hash from pcs_core.lean_catalog import ( PF_CORE_CONCRETE_PROOF_THEOREMS, @@ -21,7 +23,8 @@ PF_CORE_LEAN_KERNEL_THEOREM_CATALOG, PF_CORE_THEOREM_CATALOG, ) -from pcs_core.paths import package_dir, repo_root +from pcs_core.paths import repo_root +from pcs_core.pf_core_certificate_mode_status import enforce_certificate_mode_issuance from pcs_core.pf_core_contract import ( DEFAULT_TRACE_SAFE_CONTRACT_ID, default_trace_safe_contract_hash, @@ -31,7 +34,6 @@ from pcs_core.pf_core_lean_codegen import ( CertificateModeEvidenceMissing, certificate_mode_obligations, - collect_contracts_for_trace, compute_lean_environment_hash, compute_pfcore_kernel_hash, enforce_tool_use_certificate_mode_policy, @@ -41,6 +43,11 @@ theorem_inventory_hash, validate_contracts_before_codegen, ) +from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + PFCoreResolvedEvidence, + resolve_pf_core_evidence, +) from pcs_core.pf_core_runtime import ( compute_trace_hash, expand_principal_capabilities, @@ -96,18 +103,28 @@ def print_lean_check_disclaimer(*, stream=None) -> None: def lean_dir() -> Path: - bundled = package_dir() / "lean" - if (bundled / "lakefile.lean").is_file(): - return bundled + """Lake project root via the authoritative asset resolver.""" + root = resolve_lean_root() + if root is not None: + return root + # Preserve historical fallback for incomplete trees / early imports. + from pcs_core.paths import repo_root + return repo_root() / "lean" def pfcore_lean_dir() -> Path: - return lean_dir() / "PFCore" + try: + return pf_core_kernel_root() + except FileNotFoundError: + return lean_dir() / "PFCore" def pfcore_generated_dir() -> Path: - return pfcore_lean_dir() / "Generated" + try: + return pf_core_generated_root() + except FileNotFoundError: + return pfcore_lean_dir() / "Generated" def pfcore_theorems_checked(*, lean_kernel: bool = False) -> list[str]: @@ -299,11 +316,11 @@ def action_within_tenant_d(principal: Mapping[str, Any], action: Mapping[str, An def action_admissible_d(principal: Mapping[str, Any], action: Mapping[str, Any]) -> bool: + """Mirror Lean ``actionAdmissibleD`` (excludes resource-pattern scope).""" from pcs_core.pf_core_runtime import ( validate_action_capabilities_known, validate_action_capability_effects, validate_action_effects_known, - validate_resource_scope, ) capability = action.get("capability") @@ -314,7 +331,6 @@ def action_admissible_d(principal: Mapping[str, Any], action: Mapping[str, Any]) validate_action_capabilities_known(action) validate_action_effects_known(action) validate_action_capability_effects(action) - validate_resource_scope(action) except Exception: return False return has_capability_d(principal, cap_id) and action_within_tenant_d(principal, action) @@ -409,8 +425,10 @@ def action_resources_within_capability_pattern_d(action: Mapping[str, Any]) -> b def action_admissible_with_resource_pattern_d( principal: Mapping[str, Any], action: Mapping[str, Any] ) -> bool: - """Mirror Lean ``actionAdmissibleWithResourcePatternD`` (kernel + catalog scope).""" - return action_admissible_d(principal, action) + """Mirror Lean ``actionAdmissibleWithResourcePatternD`` (base + resource scope).""" + return action_admissible_d(principal, action) and action_resources_within_capability_pattern_d( + action + ) def event_safe_rd(event: Mapping[str, Any]) -> bool: @@ -610,9 +628,11 @@ def build_pfcore_certificate( certificate_mode: str | None = None, theorem_inventory: frozenset[str] | set[str] | list[str] | None = None, theorem_inventory_hash: str | None = None, + theorem_manifest_hash: str | None = None, certificate_mode_witness: dict[str, str] | None = None, semantic_projection_hash: str | None = None, concrete_theorems_compiled: frozenset[str] | set[str] | list[str] | None = None, + resolved_evidence: PFCoreResolvedEvidence | None = None, ) -> dict[str, Any]: events = _trace_events(trace) trace_hash = str(trace.get("trace_hash") or compute_trace_hash(dict(trace))) @@ -627,7 +647,10 @@ def build_pfcore_certificate( claim_class = "RuntimeChecked" proof_ref = None - contracts = collect_contracts_for_trace(trace) + if resolved_evidence is not None: + contracts = resolved_evidence.contracts_by_id + else: + contracts = {} contract_semantics = build_contract_semantics_checked(trace, contracts) runtime_checks = list(contract_semantics.get("runtime", [])) runtime_checks.append("resource_pattern_scope") @@ -665,6 +688,51 @@ def build_pfcore_certificate( concrete_compiled=compiled_list, ) + selected_contract_ids: list[str] = [] + contract_digests: dict[str, str] = {} + contract_theorem_names: list[str] = [] + contract_evidence_digest: str | None = None + if resolved_evidence is not None and ( + certificate_mode == "ContractCheckedCertificate" or resolved_evidence.selected_contract_ids + ): + from pcs_core.pf_core_resolved_evidence import ( + collect_contract_theorem_names, + compute_contract_evidence_digest, + contract_source_file_digests, + ) + + selected_contract_ids = list(resolved_evidence.selected_contract_ids) + contract_digests = contract_source_file_digests(resolved_evidence) + contract_theorem_names = collect_contract_theorem_names(theorem_inventory) + contract_evidence_digest = compute_contract_evidence_digest( + selected_contract_ids=selected_contract_ids, + contract_source_file_digests=contract_digests, + effective_layers=resolved_evidence.effective_contract_semantic_layers, + contract_theorem_names=contract_theorem_names, + ) + + selected_handoff_ids: list[str] = [] + handoff_digests: dict[str, str] = {} + handoff_theorem_names: list[str] = [] + handoff_evidence_digest: str | None = None + if resolved_evidence is not None and ( + certificate_mode == "HandoffSafeCertificate" or resolved_evidence.selected_handoff_ids + ): + from pcs_core.pf_core_resolved_evidence import ( + collect_handoff_theorem_names, + compute_handoff_evidence_digest, + handoff_source_file_digests, + ) + + selected_handoff_ids = list(resolved_evidence.selected_handoff_ids) + handoff_digests = handoff_source_file_digests(resolved_evidence) + handoff_theorem_names = collect_handoff_theorem_names(theorem_inventory) + handoff_evidence_digest = compute_handoff_evidence_digest( + selected_handoff_ids=selected_handoff_ids, + handoff_source_file_digests=handoff_digests, + handoff_theorem_names=handoff_theorem_names, + ) + cert: dict[str, Any] = { "schema_version": "v0", "artifact_type": "PFCoreCertificate.v0", @@ -705,14 +773,54 @@ def build_pfcore_certificate( inventory_hash = theorem_inventory_hash or _inventory_hash(frozenset(inventory_list)) cert["theorem_inventory_hash"] = inventory_hash - # Dedicated theorem-manifest hash bound to the generated inventory. - cert["theorem_manifest_hash"] = inventory_hash + # Theorem-manifest digest binds propositions + metadata (not name inventory alone). + if theorem_manifest_hash: + cert["theorem_manifest_hash"] = theorem_manifest_hash + elif theorem_manifest_hash is None: + # Fail closed for lean-checked paths: callers must supply the real digest. + pass if certificate_mode_witness: cert["certificate_mode_witness"] = certificate_mode_witness if semantic_projection_hash: cert["semantic_projection_hash"] = semantic_projection_hash if default_contract_ref: cert["default_contract_ref"] = default_contract_ref + if certificate_mode == "ContractCheckedCertificate" or selected_contract_ids: + cert["selected_contract_ids"] = selected_contract_ids + cert["contract_source_file_digests"] = contract_digests + if contract_evidence_digest: + cert["contract_evidence_digest"] = contract_evidence_digest + cert["contract_theorem_names"] = contract_theorem_names + if certificate_mode == "HandoffSafeCertificate" or selected_handoff_ids: + cert["selected_handoff_ids"] = selected_handoff_ids + cert["handoff_source_file_digests"] = handoff_digests + if handoff_evidence_digest: + cert["handoff_evidence_digest"] = handoff_evidence_digest + cert["handoff_theorem_names"] = handoff_theorem_names + if ( + resolved_evidence is not None + and resolved_evidence.effect_frame is not None + and ( + certificate_mode == "EffectFrameCertificate" + or resolved_evidence.effect_frame_path is not None + ) + ): + from pcs_core.pf_core_resolved_evidence import effect_frame_source_digest + + frame = resolved_evidence.effect_frame + cert["effect_frame_id"] = str(frame.get("frame_id") or "") + if resolved_evidence.effect_frame_path is not None: + cert["effect_frame_path"] = str(resolved_evidence.effect_frame_path) + cert["effect_frame_digest"] = effect_frame_source_digest(resolved_evidence) + if ( + resolved_evidence is not None + and certificate_mode == "FramePreservedCertificate" + and resolved_evidence.initial_state is not None + ): + from pcs_core.pf_core_resolved_evidence import transition_chain_digest + + cert["transition_chain_digest"] = transition_chain_digest(resolved_evidence) + cert["transition_event_count"] = len(resolved_evidence.transition_states) if proof_ref: cert["proof_ref"] = proof_ref cert["proof_term_ref"] = proof_ref @@ -722,6 +830,35 @@ def build_pfcore_certificate( return cert +def resolve_lean_check_artifact_paths( + *, + trace_path: Path, + out_path: Path | None, + result_out_path: Path | None, + generated_proof_path: Path | None = None, +) -> dict[str, str]: + """Deterministic artifact paths for lean-check outputs.""" + certificate = (out_path or trace_path.with_name("PFCoreCertificate.v0.json")).resolve() + if result_out_path is not None: + lean_check_result = result_out_path.resolve() + elif out_path is not None: + lean_check_result = out_path.with_name("LeanCheckResult.v0.json").resolve() + else: + lean_check_result = trace_path.with_name("LeanCheckResult.v0.json").resolve() + proof_anchor = certificate.parent + if generated_proof_path is not None: + generated_proof = generated_proof_path.resolve() + else: + generated_proof = (proof_anchor / "PFCoreGeneratedProof.placeholder.lean").resolve() + return { + "certificate": str(certificate), + "lean_check_result": str(lean_check_result), + "generated_proof": str(generated_proof), + "semantic_projection": str((proof_anchor / "PFCoreSemanticProjection.v0.json").resolve()), + "theorem_manifest": str((proof_anchor / "PFCoreTheoremManifest.v0.json").resolve()), + } + + def build_lean_check_result( *, trace_path: Path, @@ -736,6 +873,7 @@ def build_lean_check_result( obligations: list[dict[str, Any]], lean_environment_hash: str | None = None, certificate: dict[str, Any] | None = None, + artifact_paths: dict[str, str] | None = None, ) -> dict[str, Any]: claim_class = "OutOfScope" status = "Rejected" @@ -780,6 +918,8 @@ def build_lean_check_result( } if lean_environment_hash: result["lean_environment_hash"] = lean_environment_hash + if artifact_paths: + result["artifact_paths"] = dict(artifact_paths) result["signature_or_digest"] = canonical_hash(result) return result @@ -793,6 +933,7 @@ def run_pfcore_lean_check( skip_lean_proof: bool = False, certificate_mode: str | None = None, release_grade: bool = False, + allow_non_public_modes: bool = False, ) -> tuple[int, dict[str, Any]]: """Validate trace semantics, optionally prove concrete trace safety in Lean.""" print_lean_check_disclaimer() @@ -809,6 +950,13 @@ def run_pfcore_lean_check( ) issues = check_pfcore_trace_lean_semantics(data) + issuance_error = enforce_certificate_mode_issuance( + mode, + release_grade=release_grade, + allow_non_public=allow_non_public_modes, + ) + if issuance_error: + issues.append(PFCoreLeanCheckIssue("CertificateModeIssuanceDenied", issuance_error)) policy_error = enforce_tool_use_certificate_mode_policy( data, mode, @@ -817,7 +965,22 @@ def run_pfcore_lean_check( ) if policy_error: issues.append(PFCoreLeanCheckIssue("CertificateModePolicyViolation", policy_error)) - contract_errors = validate_contracts_before_codegen(data, trace_path=trace_path) + + resolved_evidence: PFCoreResolvedEvidence | None = None + try: + resolved_evidence = resolve_pf_core_evidence( + data, + trace_path=trace_path, + certificate_mode=mode, + ) + except EvidenceResolutionError as exc: + issues.append(PFCoreLeanCheckIssue("EvidenceResolutionFailed", str(exc))) + + contract_errors = validate_contracts_before_codegen( + data, + trace_path=trace_path, + resolved_evidence=resolved_evidence, + ) for err in contract_errors: issues.append(PFCoreLeanCheckIssue("ContractViolation", err)) no_sorry_errors = audit_pfcore_lean_no_sorry() @@ -831,21 +994,28 @@ def run_pfcore_lean_check( generated_proof = None inventory: frozenset[str] = frozenset() inventory_hash: str | None = None + theorem_manifest_hash: str | None = None mode_witness: dict[str, str] | None = None compiled_theorems: frozenset[str] = frozenset() if not issues and not no_sorry_errors and not skip_lean_proof: try: + if resolved_evidence is None: + raise CertificateModeEvidenceMissing( + "resolved evidence required before Lean codegen" + ) generated_proof = generate_proof_obligation_file( data, pfcore_generated_dir(), trace_path=trace_path, certificate_mode=mode, release_grade=release_grade, + resolved_evidence=resolved_evidence, ) proof_path = generated_proof.path inventory = generated_proof.theorem_names inventory_hash = theorem_inventory_hash(inventory) + theorem_manifest_hash = generated_proof.theorem_manifest_hash semantic_projection_hash = generated_proof.semantic_projection_hash mode_witness = { "theorem": generated_proof.mode_witness_theorem, @@ -921,6 +1091,31 @@ def run_pfcore_lean_check( except ValueError as exc: issues.append(PFCoreLeanCheckIssue("LeanCodegenFailed", str(exc))) + def _artifact_paths() -> dict[str, str]: + proof_path = generated_proof.path if generated_proof is not None else None + return resolve_lean_check_artifact_paths( + trace_path=trace_path, + out_path=out_path, + result_out_path=result_out_path, + generated_proof_path=proof_path, + ) + + def _result(**kwargs: Any) -> dict[str, Any]: + return build_lean_check_result( + trace_path=trace_path, + no_sorry_errors=kwargs.pop("no_sorry_errors", no_sorry_errors), + build_ok=kwargs.pop("build_ok", build_ok), + build_detail=kwargs.pop("build_detail", build_detail), + proof_ok=kwargs.pop("proof_ok", proof_ok), + proof_detail=kwargs.pop("proof_detail", proof_detail), + skip_build=skip_build, + skip_lean_proof=skip_lean_proof, + obligations=kwargs.pop("obligations", obligations), + lean_environment_hash=lean_environment_hash, + artifact_paths=_artifact_paths(), + **kwargs, + ) + def _emit(code: int, result: dict[str, Any]) -> tuple[int, dict[str, Any]]: if result_out_path: result_out_path.write_text(json.dumps(result, indent=2), encoding="utf-8") @@ -930,54 +1125,15 @@ def _emit(code: int, result: dict[str, Any]) -> tuple[int, dict[str, Any]]: return code, result if issues or no_sorry_errors: - result = build_lean_check_result( - trace_path=trace_path, - issues=issues, - no_sorry_errors=no_sorry_errors, - build_ok=build_ok, - build_detail=build_detail, - proof_ok=proof_ok, - proof_detail=proof_detail, - skip_build=skip_build, - skip_lean_proof=skip_lean_proof, - obligations=obligations, - lean_environment_hash=lean_environment_hash, - ) - return _emit(1, result) + return _emit(1, _result(issues=issues)) if not build_ok and not skip_build: issues.append(PFCoreLeanCheckIssue("LeanBuildFailed", build_detail)) - result = build_lean_check_result( - trace_path=trace_path, - issues=issues, - no_sorry_errors=no_sorry_errors, - build_ok=build_ok, - build_detail=build_detail, - proof_ok=proof_ok, - proof_detail=proof_detail, - skip_build=skip_build, - skip_lean_proof=skip_lean_proof, - obligations=obligations, - lean_environment_hash=lean_environment_hash, - ) - return _emit(1, result) + return _emit(1, _result(issues=issues)) if not skip_lean_proof and not skip_build and not proof_ok: issues.append(PFCoreLeanCheckIssue("LeanProofFailed", proof_detail)) - result = build_lean_check_result( - trace_path=trace_path, - issues=issues, - no_sorry_errors=no_sorry_errors, - build_ok=build_ok, - build_detail=build_detail, - proof_ok=proof_ok, - proof_detail=proof_detail, - skip_build=skip_build, - skip_lean_proof=skip_lean_proof, - obligations=obligations, - lean_environment_hash=lean_environment_hash, - ) - return _emit(1, result) + return _emit(1, _result(issues=issues)) if ( proof_ok @@ -1000,20 +1156,14 @@ def _emit(code: int, result: dict[str, Any]) -> tuple[int, dict[str, Any]]: f"{sorted(missing_r)!r}; base traceSafeD alone is insufficient", ) ) - result = build_lean_check_result( - trace_path=trace_path, - issues=issues, - no_sorry_errors=no_sorry_errors, - build_ok=build_ok, - build_detail=build_detail, - proof_ok=False, - proof_detail="trace-safe-r-obligations-missing", - skip_build=skip_build, - skip_lean_proof=skip_lean_proof, - obligations=obligations, - lean_environment_hash=lean_environment_hash, + return _emit( + 1, + _result( + issues=issues, + proof_ok=False, + proof_detail="trace-safe-r-obligations-missing", + ), ) - return _emit(1, result) cert = build_pfcore_certificate( data, @@ -1031,9 +1181,11 @@ def _emit(code: int, result: dict[str, Any]) -> tuple[int, dict[str, Any]]: certificate_mode=mode if generated_proof is None else generated_proof.certificate_mode, theorem_inventory=inventory if inventory else None, theorem_inventory_hash=inventory_hash, + theorem_manifest_hash=theorem_manifest_hash, certificate_mode_witness=mode_witness, semantic_projection_hash=semantic_projection_hash, concrete_theorems_compiled=compiled_theorems if compiled_theorems else None, + resolved_evidence=resolved_evidence, ) from pcs_core.validate import ValidationError, validate_artifact @@ -1042,35 +1194,23 @@ def _emit(code: int, result: dict[str, Any]) -> tuple[int, dict[str, Any]]: except ValidationError as exc: for err in exc.errors or [str(exc)]: issues.append(PFCoreLeanCheckIssue("CertificateInvalid", err)) - result = build_lean_check_result( - trace_path=trace_path, - issues=issues, - no_sorry_errors=no_sorry_errors, - build_ok=build_ok, - build_detail=build_detail, - proof_ok=proof_ok, - proof_detail=proof_detail, - skip_build=skip_build, - skip_lean_proof=skip_lean_proof, - obligations=obligations, - lean_environment_hash=lean_environment_hash, - ) - return _emit(1, result) + return _emit(1, _result(issues=issues)) + + result = _result(issues=[], no_sorry_errors=[], certificate=cert) + artifact_paths = result.get("artifact_paths") + if isinstance(artifact_paths, dict) and generated_proof is not None: + if isinstance(generated_proof.semantic_projection, Mapping): + projection_out = Path(str(artifact_paths["semantic_projection"])) + projection_out.parent.mkdir(parents=True, exist_ok=True) + projection_out.write_text( + json.dumps(dict(generated_proof.semantic_projection), indent=2) + "\n", + encoding="utf-8", + ) + if generated_proof.theorem_manifest is not None: + from pcs_core.pf_core_theorem_manifest import write_theorem_manifest - result = build_lean_check_result( - trace_path=trace_path, - issues=[], - no_sorry_errors=[], - build_ok=build_ok, - build_detail=build_detail, - proof_ok=proof_ok, - proof_detail=proof_detail, - skip_build=skip_build, - skip_lean_proof=skip_lean_proof, - obligations=obligations, - lean_environment_hash=lean_environment_hash, - certificate=cert, - ) + manifest_out = Path(str(artifact_paths["theorem_manifest"])) + write_theorem_manifest(generated_proof.theorem_manifest, manifest_out) if out_path: out_path.write_text(json.dumps(cert, indent=2), encoding="utf-8") return _emit(0, result) diff --git a/python/pcs_core/lean_trust.py b/python/pcs_core/lean_trust.py index 867abda..4c9c0ff 100644 --- a/python/pcs_core/lean_trust.py +++ b/python/pcs_core/lean_trust.py @@ -8,9 +8,11 @@ from pathlib import Path from typing import Any +from pcs_core.asset_resolver import pcs_generated_root, require_lean_root from pcs_core.hash import PLACEHOLDER_DIGEST, canonical_hash from pcs_core.lean_catalog import OBLIGATION_KIND_THEOREM from pcs_core.obligation_extraction_errors import ( + InvalidProofInputDigest, MissingArtifactStatus, MissingCertificateId, MissingCertifiedBundleHash, @@ -21,13 +23,15 @@ MissingVerificationChecks, MissingVerifiedBundleHash, MissingWitnessId, + ObligationExtractionError, ) -from pcs_core.paths import repo_root from pcs_core.pcs_projection import ( + PAYLOAD_SHA256_POINTER, ProjectionManifestBuilder, assert_no_unknown_or_empty, projection_manifest_hash, require_sha256_digest, + validate_projection_against_release, ) from pcs_core.protocol_fixtures import PCS_CORE_REPO from pcs_core.release_chain_profiles import detect_workflow_profile_id @@ -46,8 +50,8 @@ PCS_ENVELOPE_LEAN_PROOF_DISCLAIMER = ( "EnvelopeLeanChecked means a generated PCS release-chain module compiled with `lake env lean` " - "and discharged `ReleaseChainAdmissible` deciders for the concrete obligation bundle. " - "This is not LeanKernelChecked PF-Core trace safety." + "and discharged EnvelopeReleaseAdmissible (projection-bound) for the concrete obligation " + "bundle. This is not LeanKernelChecked PF-Core trace safety." ) @@ -530,75 +534,11 @@ def _extract_tool_use_obligations( def _extract_declared_result_artifact_hashes(release_dir: Path) -> list[str]: - """Collect result artifact digests from ResultArtifact.v0 files and release manifest. + """Collect verified ResultArtifact.v0 payload digests (never from the witness alone).""" + from pcs_core.computation_validate import verify_all_result_artifact_payloads - Digests are taken from independently verified result artifacts / manifest entries — - never from the computation witness itself. - """ - declared: list[str] = [] - seen: set[str] = set() - - def _add(digest: str) -> None: - if digest and digest not in seen: - seen.add(digest) - declared.append(digest) - - # Primary ResultArtifact.v0 beside the release. - result_path = release_dir / "result_artifact.json" - if result_path.is_file(): - result = _load_json(result_path) - if isinstance(result, dict): - sha = str(result.get("sha256") or "") - if sha.startswith("sha256:"): - _add(sha) - - # Additional ResultArtifact.v0 files if present. - for path in sorted(release_dir.glob("result_artifact*.json")): - if path.name == "result_artifact.json": - continue - doc = _load_json(path) - if isinstance(doc, dict) and str(doc.get("artifact_type") or "") in { - "ResultArtifact.v0", - "", - }: - # Accept schema-typed or legacy untyped result artifacts with sha256. - sha = str(doc.get("sha256") or "") - if sha.startswith("sha256:"): - _add(sha) - - manifest = _load_json(release_dir / "release_manifest.v0.json") - if isinstance(manifest, dict): - artifacts = manifest.get("artifacts") - if isinstance(artifacts, dict): - for name, meta in artifacts.items(): - if not isinstance(meta, dict): - continue - artifact_type = str(meta.get("artifact_type") or "") - if artifact_type != "ResultArtifact.v0" and not str(name).startswith( - "result_artifact" - ): - continue - # Prefer content hash of the result file when present; else manifest sha. - rel = str(name) - candidate = release_dir / rel - if candidate.is_file(): - doc = _load_json(candidate) - if isinstance(doc, dict): - sha = str(doc.get("sha256") or "") - if sha.startswith("sha256:"): - _add(sha) - continue - sha = str(meta.get("sha256") or "") - # Manifest file digests are content hashes of JSON files, not result - # payload digests — only accept payload digests from ResultArtifact bodies. - del sha - - if not declared: - raise ValueError( - f"{release_dir}: no independent ResultArtifact.v0 digests for " - "declared_result_artifact_hashes" - ) - return declared + verified = verify_all_result_artifact_payloads(release_dir) + return [item.digest for item in verified] def _extract_computation_obligations( @@ -636,16 +576,32 @@ def _extract_computation_obligations( field="/run_receipt_hash", artifact="computation_witness.json", ) + # B3: obligations bind verified payload digests, not envelope-only declarations. + from pcs_core.computation_validate import verify_all_result_artifact_payloads + + try: + verified_payloads = verify_all_result_artifact_payloads(release_dir) + except ValueError as exc: + raise InvalidProofInputDigest( + str(exc), + field_path="/sha256", + artifact="result_artifact.json", + ) from exc + if not verified_payloads: + raise InvalidProofInputDigest( + "no verified ResultArtifact payloads", + field_path="/sha256", + artifact="result_artifact.json", + ) + primary = verified_payloads[0] result_sha = require_sha256_digest( - result.get("sha256"), + primary.digest, field="/sha256", - artifact="result_artifact.json", + artifact=primary.result_artifact_relpath, ) witness_status = _require_status(witness, artifact="computation_witness.json") witness_hashes = witness.get("result_hashes") if not isinstance(witness_hashes, list) or not witness_hashes: - from pcs_core.obligation_extraction_errors import InvalidProofInputDigest - raise InvalidProofInputDigest( "computation_witness.result_hashes is required and must be non-empty", field_path="/result_hashes", @@ -660,7 +616,7 @@ def _extract_computation_obligations( for index, item in enumerate(witness_hashes) ] - declared_hashes = _extract_declared_result_artifact_hashes(release_dir) + declared_hashes = [item.digest for item in verified_payloads] certified_bundle_hash = _resolve_certified_bundle_hash(release_dir) verified_bundle = _require_verified_bundle_hash(verification) signed_bundle_hash = _require_signed_bundle_hash(signed) @@ -695,12 +651,25 @@ def _extract_computation_obligations( require_digest=True, ) projection.add( - artifact_path="result_artifact.json", + artifact_path=primary.result_artifact_relpath, json_pointer="/sha256", normalized_value=result_sha, lean_identifier="concreteResultArtifactHash", require_digest=True, ) + for index, item in enumerate(verified_payloads): + lean_id = ( + "concreteVerifiedResultPayloadHash" + if index == 0 + else f"concreteVerifiedResultPayloadHash_{index}" + ) + projection.add( + artifact_path=item.payload_relpath, + json_pointer=PAYLOAD_SHA256_POINTER, + normalized_value=item.digest, + lean_identifier=lean_id, + require_digest=True, + ) projection.add( artifact_path="verification_result.json", json_pointer="/verified_input/bundle_hash", @@ -832,6 +801,17 @@ def extract_proof_obligations_from_release( projection_doc = projection.build() proj_hash = projection_manifest_hash(projection_doc) + replay_errors = validate_projection_against_release( + projection_doc, + release_dir, + expected_hash=proj_hash, + ) + if replay_errors: + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message="; ".join(replay_errors), + artifact="PCSProjectionManifest.v0", + ) body: dict[str, Any] = { "schema_version": "v0", @@ -852,7 +832,10 @@ def extract_proof_obligations_from_release( def run_lean_build() -> tuple[bool, str]: - lean_dir = repo_root() / "lean" + try: + lean_dir = require_lean_root() + except FileNotFoundError as exc: + return False, str(exc) try: proc = subprocess.run( ["lake", "build"], @@ -928,7 +911,12 @@ def run_lean_check( from pcs_core.lean_check import run_pcs_lean_concrete_proof lean_environment_hash = compute_lean_environment_hash() - generated_dir = repo_root() / "lean" / "PCS" / "Generated" + try: + generated_dir = pcs_generated_root() + except FileNotFoundError: + from pcs_core.paths import repo_root + + generated_dir = repo_root() / "lean" / "PCS" / "Generated" module = generated_module_name(obligations_doc) proof_path = generate_proof_obligation_file(obligations_doc, generated_dir) proof_term_ref = proof_term_ref_from_path(proof_path) @@ -944,10 +932,16 @@ def run_lean_check( proof_term_hash = compute_proof_term_hash(proof_path) workflow_id = workflow_id_from_obligations(obligations_doc) aggregate_theorem = aggregate_lean_theorem_for_workflow(workflow_id) + catalog_envelope = OBLIGATION_KIND_THEOREM.get("ReleaseChainAdmissible") + aggregate_kind = ( + "ReleaseChainAdmissible" + if aggregate_theorem == catalog_envelope + else aggregate_theorem + ) obligation_results.append( { "obligation_id": f"generated_{module}", - "kind": "ReleaseChainAdmissible", + "kind": aggregate_kind, "status": "passed", "lean_theorem": aggregate_theorem, "failure_reason": "", @@ -978,11 +972,22 @@ def run_lean_check( if lean_proof: disclaimer = f"{PCS_LEAN_CHECK_DISCLAIMER} {PCS_ENVELOPE_LEAN_PROOF_DISCLAIMER}" - projection_hash = obligations_doc.get("pcs_projection_manifest_hash") - if projection_hash is not None: - projection_hash = require_sha256_digest( - projection_hash, - field="/pcs_projection_manifest_hash", + projection_hash = require_sha256_digest( + obligations_doc.get("pcs_projection_manifest_hash"), + field="/pcs_projection_manifest_hash", + artifact="ProofObligation.v0", + ) + projection_doc = obligations_doc.get("pcs_projection_manifest") + if not isinstance(projection_doc, dict): + raise InvalidProofInputDigest( + "ProofObligation.v0.pcs_projection_manifest is required", + field_path="/pcs_projection_manifest", + artifact="ProofObligation.v0", + ) + if projection_manifest_hash(projection_doc) != projection_hash: + raise InvalidProofInputDigest( + "pcs_projection_manifest_hash does not match projection digest", + field_path="/pcs_projection_manifest_hash", artifact="ProofObligation.v0", ) @@ -1003,10 +1008,9 @@ def run_lean_check( "obligation_results": obligation_results, "lean_proof_checked": lean_proof_checked, "disclaimer": disclaimer, + "pcs_projection_manifest_hash": projection_hash, "signature_or_digest": PLACEHOLDER_DIGEST, } - if projection_hash: - body["pcs_projection_manifest_hash"] = projection_hash if proof_term_ref: body["proof_term_ref"] = proof_term_ref if proof_term_hash: diff --git a/python/pcs_core/lean_validate.py b/python/pcs_core/lean_validate.py index 57b50eb..64aa47b 100644 --- a/python/pcs_core/lean_validate.py +++ b/python/pcs_core/lean_validate.py @@ -13,6 +13,9 @@ def validate_proof_obligation_semantics(data: dict[str, Any]) -> list[str]: errors: list[str] = [] + from pcs_core.pcs_projection import validate_proof_obligation_projection + + errors.extend(validate_proof_obligation_projection(data)) obligations = data.get("obligations") if not isinstance(obligations, list) or not obligations: errors.append("ProofObligation.v0 requires non-empty obligations") @@ -54,6 +57,11 @@ def validate_lean_check_result_semantics(data: dict[str, Any]) -> list[str]: errors.append( "LeanCheckResult.v0 EnvelopeLeanChecked requires proof_term_ref", ) + proj_hash = data.get("pcs_projection_manifest_hash") + if not isinstance(proj_hash, str) or not proj_hash.startswith("sha256:"): + errors.append( + "LeanCheckResult.v0 EnvelopeLeanChecked requires pcs_projection_manifest_hash", + ) results = data.get("obligation_results") if not isinstance(results, list): errors.append("LeanCheckResult.v0 obligation_results must be an array") diff --git a/schemas/LeanCheckResult.v0.schema.json b/schemas/LeanCheckResult.v0.schema.json index 3dd14dd..5c06492 100644 --- a/schemas/LeanCheckResult.v0.schema.json +++ b/schemas/LeanCheckResult.v0.schema.json @@ -305,6 +305,18 @@ "certificate": { "$ref": "PFCoreCertificate.v0.schema.json" }, + "artifact_paths": { + "type": "object", + "additionalProperties": false, + "description": "Deterministic lean-check output paths (projection/manifest may be placeholders until later PRs).", + "properties": { + "certificate": { "type": "string", "minLength": 1 }, + "lean_check_result": { "type": "string", "minLength": 1 }, + "generated_proof": { "type": "string", "minLength": 1 }, + "semantic_projection": { "type": "string", "minLength": 1 }, + "theorem_manifest": { "type": "string", "minLength": 1 } + } + }, "signature_or_digest": { "$ref": "common.defs.json#/$defs/hex_digest" } From 1e472050bc71ff1c767e97a47b5dd8e8a139e2af Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:59:50 -0700 Subject: [PATCH 03/24] Tighten handoff delegated_capabilities and evidence selection. Require handoff manifests to declare delegated capabilities against resolved evidence so CertifyEdge bridges cannot inherit undeclared authority. --- ...off_manifest.certificate_to_bundle.v0.json | 4 +- ...ff_manifest.runtime_to_certificate.v0.json | 4 +- .../handoff_to_certifyedge.json | 4 +- python/pcs_core/capabilities.py | 15 +- python/pcs_core/pf_core_claims.py | 22 +- python/tests/test_pf_core_handoff_evidence.py | 507 ++++++++++++++++++ 6 files changed, 538 insertions(+), 18 deletions(-) create mode 100644 python/tests/test_pf_core_handoff_evidence.py diff --git a/examples/computation-release/handoff_manifest.certificate_to_bundle.v0.json b/examples/computation-release/handoff_manifest.certificate_to_bundle.v0.json index bf485ef..b00213f 100644 --- a/examples/computation-release/handoff_manifest.certificate_to_bundle.v0.json +++ b/examples/computation-release/handoff_manifest.certificate_to_bundle.v0.json @@ -10,7 +10,7 @@ "input_artifacts": { "computation_witness.json": { "artifact_type": "ComputationWitness.v0", - "sha256": "sha256:b89def93118f055abb45b8b0187e2aaeb452ec6eae502c9ba9bbf7ded83377cb" + "sha256": "sha256:944ab3a7bcc6ecd3b7b702c122fc3aa4e1048f2d885d14350894bb18618e3bf2" } }, "expected_outputs": { @@ -23,5 +23,5 @@ "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828" }, "status": "Validated", - "signature_or_digest": "sha256:170b7a0dfa9132870f4a7f96de1b4e9cb483c7ea6a354d8162e452a6b151543c" + "signature_or_digest": "sha256:c6db50d8dfc1107391ac173ad1412027234a378ddacfeb5dada479d7f1014fcf" } diff --git a/examples/computation-release/handoff_manifest.runtime_to_certificate.v0.json b/examples/computation-release/handoff_manifest.runtime_to_certificate.v0.json index e2ee508..bad657b 100644 --- a/examples/computation-release/handoff_manifest.runtime_to_certificate.v0.json +++ b/examples/computation-release/handoff_manifest.runtime_to_certificate.v0.json @@ -22,7 +22,7 @@ }, "result_artifact.json": { "artifact_type": "ResultArtifact.v0", - "sha256": "sha256:a2b8d26f9d0e056e7fd963156021a88b43c764c84357e2ff8ae70cd2c2d99acc" + "sha256": "sha256:b3f437010792f1f1f70ade9912374a1795c1458bf35309d6e1f888d875d09f3c" } }, "expected_outputs": { @@ -35,5 +35,5 @@ "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3" }, "status": "Validated", - "signature_or_digest": "sha256:00a3731d61029e4ae124bd4503e3eae35eb7b1c271cf3f5f3930cfe2a89e137f" + "signature_or_digest": "sha256:c16aa7c4b90a2400e24ee945277e816b82a4fde2cf6ba8ce1fd3257f3fb4528d" } diff --git a/examples/computation-release/handoff_to_certifyedge.json b/examples/computation-release/handoff_to_certifyedge.json index e2ee508..bad657b 100644 --- a/examples/computation-release/handoff_to_certifyedge.json +++ b/examples/computation-release/handoff_to_certifyedge.json @@ -22,7 +22,7 @@ }, "result_artifact.json": { "artifact_type": "ResultArtifact.v0", - "sha256": "sha256:a2b8d26f9d0e056e7fd963156021a88b43c764c84357e2ff8ae70cd2c2d99acc" + "sha256": "sha256:b3f437010792f1f1f70ade9912374a1795c1458bf35309d6e1f888d875d09f3c" } }, "expected_outputs": { @@ -35,5 +35,5 @@ "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3" }, "status": "Validated", - "signature_or_digest": "sha256:00a3731d61029e4ae124bd4503e3eae35eb7b1c271cf3f5f3930cfe2a89e137f" + "signature_or_digest": "sha256:c16aa7c4b90a2400e24ee945277e816b82a4fde2cf6ba8ce1fd3257f3fb4528d" } diff --git a/python/pcs_core/capabilities.py b/python/pcs_core/capabilities.py index 6eab373..2741d7a 100644 --- a/python/pcs_core/capabilities.py +++ b/python/pcs_core/capabilities.py @@ -10,6 +10,8 @@ from pathlib import Path from typing import Any +from pcs_core.asset_resolver import lean_root as resolve_lean_root +from pcs_core.asset_resolver import resolver_report from pcs_core.paths import package_dir, repo_root CAPABILITY_KEYS = ( @@ -39,12 +41,9 @@ def _jsonschema_available() -> bool: def _lean_checkout_dir() -> Path | None: """Locate Lean project sources (checkout or wheel-bundled verifier assets).""" - bundled = package_dir() / "lean" - if (bundled / "lakefile.lean").is_file(): - return bundled - checkout = repo_root() / "lean" - if (checkout / "lakefile.lean").is_file(): - return checkout + root = resolve_lean_root() + if root is not None and (root / "lakefile.lean").is_file(): + return root return None @@ -150,6 +149,7 @@ def detect_capabilities() -> dict[str, Any]: "dev format checks only and are not release attestations." ) + assets = resolver_report() return { "product": product, "version": _package_version(), @@ -159,6 +159,9 @@ def detect_capabilities() -> dict[str, Any]: "paths": { "package_dir": str(package_dir()), "lean_dir": str(lean_root) if lean_root else None, + "distribution_root": assets.get("distribution_root"), + "pins_dir": assets.get("pins_dir"), + "catalog_dir": assets.get("catalog_dir"), "schemas_available": schema_ok, }, "notes": notes, diff --git a/python/pcs_core/pf_core_claims.py b/python/pcs_core/pf_core_claims.py index cd94148..83e6003 100644 --- a/python/pcs_core/pf_core_claims.py +++ b/python/pcs_core/pf_core_claims.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from pathlib import Path +from pcs_core.asset_resolver import lean_root as resolve_lean_root +from pcs_core.asset_resolver import pf_core_kernel_root from pcs_core.lean_catalog import ( LEAN_THEOREM_CATALOG, PF_CORE_THEOREM_CATALOG, @@ -140,7 +142,9 @@ def audit_boundary() -> list[BoundaryIssue]: "PFCoreTrace.v0", "PFCoreContract.v0", "PFCoreHandoff.v0", + "PFCoreEffectFrame.v0", "PFCoreCertificate.v0", + "PFCoreTheoremManifest.v0", "PFCoreRuntimeObservation.v0", } missing_registry = expected - registry_types @@ -172,20 +176,23 @@ def audit_boundary() -> list[BoundaryIssue]: def _lean_sources() -> list[Path]: - lean_dir = repo_root() / "lean" - if not lean_dir.is_dir(): + lean_dir = resolve_lean_root() + if lean_dir is None or not lean_dir.is_dir(): return [] return sorted(lean_dir.rglob("*.lean")) def _collect_lean_theorem_names(*, pfcore_only: bool = False) -> set[str]: names: set[str] = set() - lean_dir = repo_root() / "lean" - if not lean_dir.is_dir(): + lean_dir = resolve_lean_root() + if lean_dir is None or not lean_dir.is_dir(): return names sources = sorted(lean_dir.rglob("*.lean")) if pfcore_only: - pfcore = lean_dir / "PFCore" + try: + pfcore = pf_core_kernel_root() + except FileNotFoundError: + pfcore = lean_dir / "PFCore" sources = sorted(pfcore.glob("*.lean")) if pfcore.is_dir() else [] for path in sources: try: @@ -211,7 +218,10 @@ def audit_lean_catalog() -> list[str]: ) pfcore_theorems = _collect_lean_theorem_names(pfcore_only=True) - pfcore_dir = repo_root() / "lean" / "PFCore" + try: + pfcore_dir = pf_core_kernel_root() + except FileNotFoundError: + pfcore_dir = (resolve_lean_root() or Path()) / "PFCore" if not pfcore_dir.is_dir(): errors.append(f"PF-Core Lean directory missing: {PF_CORE_TRUSTED_LEAN_DIR}/") elif not pfcore_theorems: diff --git a/python/tests/test_pf_core_handoff_evidence.py b/python/tests/test_pf_core_handoff_evidence.py new file mode 100644 index 0000000..5e0276d --- /dev/null +++ b/python/tests/test_pf_core_handoff_evidence.py @@ -0,0 +1,507 @@ +"""PR2 handoff evidence fidelity + PFCoreResolvedEvidence tests.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from typing import Any, Mapping + +import pytest + +from pcs_core.hash import canonical_hash +from pcs_core.obligation_extraction_errors import ObligationExtractionError +from pcs_core.pf_core_bundle import bundle_release, validate_bundle +from pcs_core.pf_core_lean_codegen import ( + CertificateModeEvidenceMissing, + generate_proof_obligation_file, + handoff_to_lean, +) +from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + assert_handoff_capability_fidelity, + delegated_capability_ids, + resolve_pf_core_evidence, +) +from pcs_core.pf_core_semantic_projection import ( + build_semantic_projection, + extract_lean_delegated_capability_sequences, + projection_handoffs, +) +from pcs_core.validate import validate_artifact + +REPO = Path(__file__).resolve().parents[2] +FILE_READ = REPO / "examples" / "pf-core-valid" / "file_read_allowed" / "trace.json" +HANDOFF_FIXTURE = REPO / "examples" / "pf-core-valid" / "handoff_subset_authority" / "handoff.json" + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, data: dict[str, Any]) -> None: + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def _base_principal(*, tenant: str = "tenant-a", capabilities: list[str] | None = None) -> dict: + return { + "principal_id": "agent-1", + "principal_kind": "agent", + "tenant": tenant, + "roles": ["agent"], + "capabilities": capabilities + or ["cap:file-read", "cap:email-send", "cap:handoff", "cap:mcp-invoke"], + } + + +def _to_principal(*, tenant: str = "tenant-a") -> dict: + return { + "principal_id": "agent-2", + "principal_kind": "agent", + "tenant": tenant, + "roles": ["handoff_delegate"], + "capabilities": [], + } + + +def _capability(cap_id: str) -> dict[str, str]: + from pcs_core.pf_core_catalog import CAPABILITY_CATALOG + + entry = CAPABILITY_CATALOG[cap_id] + return { + "capability_id": entry["capability_id"], + "effect_kind": entry["effect_kind"], + "resource_pattern": entry["resource_pattern"], + } + + +def _make_handoff( + *, + handoff_id: str, + delegated: list[str], + from_caps: list[str] | None = None, + from_tenant: str = "tenant-a", + to_tenant: str = "tenant-a", +) -> dict[str, Any]: + body = { + "schema_version": "v0", + "artifact_type": "PFCoreHandoff.v0", + "handoff_id": handoff_id, + "from_principal": _base_principal(tenant=from_tenant, capabilities=from_caps), + "to_principal": _to_principal(tenant=to_tenant), + "delegated_capabilities": [_capability(cap_id) for cap_id in delegated], + "reason": f"test handoff {handoff_id}", + "evidence_refs": ["evidence/handoff.v0"], + "signature_or_digest": "sha256:" + "0" * 64, + } + body["signature_or_digest"] = canonical_hash(body) + return body + + +def _prepare_case( + tmp_path: Path, + *, + handoffs: list[dict[str, Any]], + selected_ids: list[str], + mode: str = "HandoffSafeCertificate", +) -> tuple[Path, dict[str, Any]]: + work = tmp_path / "case" + work.mkdir(parents=True, exist_ok=True) + trace = dict(_load(FILE_READ)) + trace["required_certificate_mode"] = mode + trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "handoff_ids": selected_ids, + } + # Rebind digests after mutation. + from pcs_core.pf_core_runtime import compute_trace_hash + + trace.pop("trace_hash", None) + trace.pop("signature_or_digest", None) + trace["trace_hash"] = compute_trace_hash(trace) + trace["signature_or_digest"] = trace["trace_hash"] + trace_path = work / "trace.json" + _write_json(trace_path, trace) + for handoff in handoffs: + _write_json(work / f"{handoff['handoff_id']}.json", handoff) + return trace_path, trace + + +def _assert_fidelity(source: list[dict], projection: dict, lean_source: str) -> None: + projected = projection_handoffs(projection) + lean_ids = extract_lean_delegated_capability_sequences(lean_source) + assert_handoff_capability_fidelity( + source_handoffs=source, + projected_handoffs=projected, + lean_capability_sequences=lean_ids, + ) + + +def test_one_capability_safe_delegation(tmp_path: Path) -> None: + handoff = _make_handoff(handoff_id="handoff-one", delegated=["cap:handoff"]) + trace_path, trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=["handoff-one"] + ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + assert generated.semantic_projection is not None + text = generated.path.read_text(encoding="utf-8") + assert 'delegatedCapabilities := ["cap:handoff"]' in text + _assert_fidelity([handoff], dict(generated.semantic_projection), text) + validate_artifact(dict(generated.semantic_projection), "PFCoreSemanticProjection.v0") + + +def test_multi_capability_safe_delegation(tmp_path: Path) -> None: + caps = ["cap:file-read", "cap:handoff", "cap:mcp-invoke"] + handoff = _make_handoff( + handoff_id="handoff-multi", + delegated=caps, + from_caps=["cap:file-read", "cap:email-send", "cap:handoff", "cap:mcp-invoke"], + ) + trace_path, trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=["handoff-multi"] + ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + text = generated.path.read_text(encoding="utf-8") + assert delegated_capability_ids(projection_handoffs(generated.semantic_projection)[0]) == caps + _assert_fidelity([handoff], dict(generated.semantic_projection), text) + + +def _handoff_safe_python(handoff: Mapping[str, Any]) -> bool: + """Mirror Lean ``handoffSafeD``: delegated ⊆ from.capabilities and same tenant.""" + from_p = handoff.get("from_principal") + to_p = handoff.get("to_principal") + if not isinstance(from_p, Mapping) or not isinstance(to_p, Mapping): + return False + allowed = {str(cap) for cap in (from_p.get("capabilities") or []) if str(cap)} + if not all(cap_id in allowed for cap_id in delegated_capability_ids(handoff)): + return False + return str(from_p.get("tenant") or "") == str(to_p.get("tenant") or "") + + +def test_capability_absent_from_source_principal(tmp_path: Path) -> None: + handoff = _make_handoff( + handoff_id="handoff-absent", + delegated=["cap:network"], + from_caps=["cap:handoff"], + ) + assert _handoff_safe_python(handoff) is False + trace_path, trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=["handoff-absent"] + ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + text = generated.path.read_text(encoding="utf-8") + assert "handoffSafeD" in text + assert 'delegatedCapabilities := ["cap:network"]' in text + # Lean decide cannot discharge handoffSafeD when the capability is absent. + if shutil.which("lake") is not None: + from pcs_core.lean_check import run_lean_concrete_proof + + ok, detail = run_lean_concrete_proof(generated.path, skip_build=False) + if "lake unavailable" in detail or "timed out" in detail.lower(): + pytest.skip(detail) + assert ok is False + + +def test_cross_tenant_delegation(tmp_path: Path) -> None: + handoff = _make_handoff( + handoff_id="handoff-xtenant", + delegated=["cap:handoff"], + from_tenant="tenant-a", + to_tenant="tenant-b", + ) + assert _handoff_safe_python(handoff) is False + trace_path, trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=["handoff-xtenant"] + ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + assert "handoffSafeD" in generated.path.read_text(encoding="utf-8") + if shutil.which("lake") is not None: + from pcs_core.lean_check import run_lean_concrete_proof + + ok, detail = run_lean_concrete_proof(generated.path, skip_build=False) + if "lake unavailable" in detail or "timed out" in detail.lower(): + pytest.skip(detail) + assert ok is False + + +def test_empty_projected_delegation(tmp_path: Path) -> None: + handoff = _make_handoff(handoff_id="handoff-empty", delegated=["cap:handoff"]) + handoff["delegated_capabilities"] = [] + with pytest.raises(ObligationExtractionError, match="non-empty"): + build_semantic_projection( + {"trace_id": "t", "events": []}, + certificate_mode="HandoffSafeCertificate", + handoffs=[handoff], + ) + + +def test_reordered_delegation_preserves_sequence(tmp_path: Path) -> None: + order_a = ["cap:file-read", "cap:handoff"] + order_b = ["cap:handoff", "cap:file-read"] + handoff = _make_handoff( + handoff_id="handoff-order", + delegated=order_a, + from_caps=["cap:file-read", "cap:handoff"], + ) + trace_path, trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=["handoff-order"] + ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + text = generated.path.read_text(encoding="utf-8") + lean_seqs = extract_lean_delegated_capability_sequences(text) + assert lean_seqs == [order_a] + assert lean_seqs != [order_b] + # Explicit Lean emitter order check. + lean_fragment = handoff_to_lean( + projection_handoffs(generated.semantic_projection)[0], name="handoffOrder" + ) + assert 'delegatedCapabilities := ["cap:file-read", "cap:handoff"]' in lean_fragment + + +def test_unrelated_sibling_handoff_not_selected(tmp_path: Path) -> None: + selected = _make_handoff(handoff_id="handoff-selected", delegated=["cap:handoff"]) + sibling = _make_handoff( + handoff_id="handoff-unrelated", + delegated=["cap:file-read"], + from_caps=["cap:file-read", "cap:handoff"], + ) + trace_path, trace = _prepare_case( + tmp_path, + handoffs=[selected, sibling], + selected_ids=["handoff-selected"], + ) + evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + assert evidence.selected_handoff_ids == ("handoff-selected",) + assert [item.handoff_id for item in evidence.handoffs] == ["handoff-selected"] + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + resolved_evidence=evidence, + ) + projected = projection_handoffs(generated.semantic_projection) + assert len(projected) == 1 + assert projected[0]["handoff_id"] == "handoff-selected" + assert "handoff-unrelated" not in generated.path.read_text(encoding="utf-8") + + +def test_projection_mutation_after_proof_generation(tmp_path: Path) -> None: + handoff = _make_handoff(handoff_id="handoff-mut-proj", delegated=["cap:handoff"]) + trace_path, trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=["handoff-mut-proj"] + ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + projection_path = tmp_path / "out" / "PFCoreSemanticProjection.v0.json" + assert projection_path.is_file() + original_hash = generated.semantic_projection_hash + mutated = dict(generated.semantic_projection) + mutated["handoffs"][0]["delegated_capabilities"] = [ + _capability("cap:file-read"), + _capability("cap:handoff"), + ] + mutated.pop("projection_hash", None) + mutated["projection_hash"] = canonical_hash(mutated) + _write_json(projection_path, mutated) + reloaded = _load(projection_path) + assert reloaded["projection_hash"] != original_hash + lean_ids = extract_lean_delegated_capability_sequences( + generated.path.read_text(encoding="utf-8") + ) + with pytest.raises(EvidenceResolutionError, match="fidelity"): + assert_handoff_capability_fidelity( + source_handoffs=[handoff], + projected_handoffs=projection_handoffs(reloaded), + lean_capability_sequences=lean_ids, + ) + + +def test_source_handoff_mutation_after_projection(tmp_path: Path) -> None: + handoff = _make_handoff( + handoff_id="handoff-mut-src", + delegated=["cap:handoff"], + from_caps=["cap:file-read", "cap:handoff"], + ) + trace_path, trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=["handoff-mut-src"] + ) + evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + projection = build_semantic_projection( + trace, + certificate_mode="HandoffSafeCertificate", + resolved_evidence=evidence, + ) + source_path = tmp_path / "case" / "handoff-mut-src.json" + mutated = deepcopy(handoff) + mutated["delegated_capabilities"] = [ + _capability("cap:file-read"), + _capability("cap:handoff"), + ] + mutated["signature_or_digest"] = canonical_hash(mutated) + _write_json(source_path, mutated) + assert delegated_capability_ids(handoff) != delegated_capability_ids(mutated) + with pytest.raises(EvidenceResolutionError, match="fidelity"): + assert_handoff_capability_fidelity( + source_handoffs=[mutated], + projected_handoffs=projection_handoffs(projection), + lean_capability_sequences=[delegated_capability_ids(handoff)], + ) + + +def test_cli_issuance_bundle_isolated_verify_and_lean(tmp_path: Path) -> None: + if shutil.which("lake") is None: + pytest.skip("lake not available for full Lean execution path") + handoff = _make_handoff(handoff_id="handoff-cli", delegated=["cap:handoff"]) + trace_path, _trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=["handoff-cli"] + ) + out_cert = tmp_path / "PFCoreCertificate.v0.json" + result_out = tmp_path / "LeanCheckResult.v0.json" + proc = subprocess.run( + [ + sys.executable, + "-m", + "pcs_core.cli", + "pf-core", + "lean-check", + "--trace", + str(trace_path), + "--out", + str(out_cert), + "--result-out", + str(result_out), + "--certificate-mode", + "HandoffSafeCertificate", + "--allow-non-public-modes", + ], + cwd=REPO / "python", + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + assert out_cert.is_file() + cert = _load(out_cert) + assert cert["certificate_mode"] == "HandoffSafeCertificate" + assert cert.get("lean_proof_checked") is True + result_payload = _load(result_out) + projection_path = Path(result_payload["artifact_paths"]["semantic_projection"]) + assert projection_path.is_file() + projection = _load(projection_path) + validate_artifact(projection, "PFCoreSemanticProjection.v0") + lean_path = Path(result_payload["artifact_paths"]["generated_proof"]) + _assert_fidelity([handoff], projection, lean_path.read_text(encoding="utf-8")) + + bundle_dir = tmp_path / "bundle" + bundle_release(trace_path, out_cert, bundle_dir, lean_check_result_path=result_out) + assert validate_bundle(bundle_dir).ok + + isolated = tmp_path / "isolated" / "bundle" + isolated.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(bundle_dir, isolated) + assert validate_bundle(isolated).ok + + bind = subprocess.run( + [ + sys.executable, + "-m", + "pcs_core.cli", + "pf-core", + "verify-proof-binding", + "--certificate", + str(out_cert), + "--trace", + str(trace_path), + ], + cwd=REPO / "python", + capture_output=True, + text=True, + check=False, + ) + assert bind.returncode == 0, bind.stderr + bind.stdout + + +def test_handoff_safe_requires_explicit_selection(tmp_path: Path) -> None: + handoff = _make_handoff(handoff_id="handoff-need-sel", delegated=["cap:handoff"]) + work = tmp_path / "case" + work.mkdir() + trace = dict(_load(FILE_READ)) + trace_path = work / "trace.json" + _write_json(trace_path, trace) + _write_json(work / "handoff-need-sel.json", handoff) + with pytest.raises(EvidenceResolutionError, match="handoff_ids"): + resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + + +def test_legacy_fixture_handoff_still_usable_with_selection(tmp_path: Path) -> None: + handoff = _load(HANDOFF_FIXTURE) + # Fixture from_principal only lists cap:handoff; keep that. + trace_path, trace = _prepare_case( + tmp_path, handoffs=[handoff], selected_ids=[str(handoff["handoff_id"])] + ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="HandoffSafeCertificate", + ) + assert "cap:handoff" in generated.path.read_text(encoding="utf-8") + + +def test_missing_selection_still_errors_in_codegen(tmp_path: Path) -> None: + trace = dict(_load(FILE_READ)) + trace_file = tmp_path / "trace.json" + _write_json(trace_file, trace) + with pytest.raises(CertificateModeEvidenceMissing, match="handoff_ids"): + generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_file, + certificate_mode="HandoffSafeCertificate", + ) From 3d329e2a5f6e2564ca3c6a30eaa6651e65e94953 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:00:16 -0700 Subject: [PATCH 04/24] Add PFCoreResolvedEvidence for explicit evidence selection. Centralize resolved evidence manifests so handoff and certificate paths share a single, schema-validated view of what evidence was selected and why. --- python/pcs_core/pf_core_resolved_evidence.py | 910 ++++++++++++++++++ schemas/PFCoreEvidenceManifest.v0.schema.json | 71 ++ 2 files changed, 981 insertions(+) create mode 100644 python/pcs_core/pf_core_resolved_evidence.py create mode 100644 schemas/PFCoreEvidenceManifest.v0.schema.json diff --git a/python/pcs_core/pf_core_resolved_evidence.py b/python/pcs_core/pf_core_resolved_evidence.py new file mode 100644 index 0000000..c8d6772 --- /dev/null +++ b/python/pcs_core/pf_core_resolved_evidence.py @@ -0,0 +1,910 @@ +"""Single-resolution PF-Core evidence for lean-check and certificate construction. + +``PFCoreResolvedEvidence`` is resolved once at the start of ``run_pfcore_lean_check`` +and threaded into every downstream stage. Downstream stages must not rediscover +handoffs, contracts, or policy frames via directory scans. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Mapping + +from pcs_core.pf_core_contract_semantics import resolve_semantics_layer + +EVIDENCE_SELECTION_POLICY = "explicit_ids" +EVIDENCE_SELECTION_POLICY_VERSION = "v0" +EVIDENCE_SELECTION_FILENAME = "evidence_selection.json" + + +class EvidenceResolutionError(ValueError): + """Raised when evidence selection or artifact binding cannot be resolved.""" + + +@dataclass(frozen=True) +class ResolvedHandoff: + handoff_id: str + path: Path | None + artifact: Mapping[str, Any] + + +@dataclass(frozen=True) +class ResolvedContract: + contract_id: str + path: Path | None + artifact: Mapping[str, Any] + + +@dataclass(frozen=True) +class PFCoreResolvedEvidence: + """Immutable snapshot of all evidence selected for a lean-check run.""" + + source_trace_path: Path + canonical_trace: Mapping[str, Any] + certificate_mode: str + selected_events: tuple[Mapping[str, Any], ...] + handoffs: tuple[ResolvedHandoff, ...] + contracts: tuple[ResolvedContract, ...] + effective_contract_semantic_layers: Mapping[str, Mapping[str, str]] + effect_frame: Mapping[str, Any] | None + effect_frame_path: Path | None + initial_state: Mapping[str, Any] | None + transition_states: tuple[Mapping[str, Any], ...] + source_file_digests: Mapping[str, str] + evidence_selection_policy: str + evidence_selection_policy_version: str + selected_handoff_ids: tuple[str, ...] + selected_contract_ids: tuple[str, ...] + + @property + def handoff_artifacts(self) -> list[dict[str, Any]]: + return [dict(item.artifact) for item in self.handoffs] + + @property + def handoff_paths(self) -> list[Path]: + return [item.path for item in self.handoffs if item.path is not None] + + @property + def contracts_by_id(self) -> dict[str, dict[str, Any]]: + return {item.contract_id: dict(item.artifact) for item in self.contracts} + + @property + def contract_paths(self) -> list[Path]: + return [item.path for item in self.contracts if item.path is not None] + + +def _freeze_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]: + return MappingProxyType(dict(value)) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + return f"sha256:{digest}" + + +def _sha256_bytes(data: bytes) -> str: + return f"sha256:{hashlib.sha256(data).hexdigest()}" + + +def load_evidence_selection( + trace: Mapping[str, Any], + *, + trace_path: Path | None = None, +) -> dict[str, Any]: + """Load evidence-selection policy from the trace or a sibling JSON file.""" + embedded = trace.get("evidence_selection") + if isinstance(embedded, Mapping): + return dict(embedded) + if trace_path is not None: + sibling = trace_path.parent / EVIDENCE_SELECTION_FILENAME + if sibling.is_file(): + try: + data = json.loads(sibling.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise EvidenceResolutionError( + f"unreadable evidence selection file {sibling}: {exc}" + ) from exc + if isinstance(data, dict): + return data + return {} + + +def _selected_ids(selection: Mapping[str, Any], key: str) -> tuple[str, ...] | None: + raw = selection.get(key) + if raw is None: + return None + if not isinstance(raw, list): + raise EvidenceResolutionError(f"evidence_selection.{key} must be an array") + ids: list[str] = [] + seen: set[str] = set() + for index, item in enumerate(raw): + value = str(item or "").strip() + if not value: + raise EvidenceResolutionError( + f"evidence_selection.{key}[{index}] must be a non-empty string" + ) + if value in seen: + raise EvidenceResolutionError( + f"evidence_selection.{key} contains duplicate id {value!r}" + ) + seen.add(value) + ids.append(value) + return tuple(ids) + + +def _selected_effect_frame_id(selection: Mapping[str, Any]) -> str | None: + """v0: one global frame id (``effect_frame_id``). Reject multi-id arrays.""" + if "effect_frame_ids" in selection: + raise EvidenceResolutionError( + "evidence_selection.effect_frame_ids is not supported in v0; " + "use effect_frame_id for the single global frame" + ) + raw = selection.get("effect_frame_id") + if raw is None: + return None + value = str(raw or "").strip() + if not value: + raise EvidenceResolutionError( + "evidence_selection.effect_frame_id must be a non-empty string" + ) + return value + + +def _index_effect_frame_candidates( + *, + trace_path: Path | None, +) -> dict[str, tuple[dict[str, Any], Path]]: + """Map frame_id -> (artifact, path) from sibling PFCoreEffectFrame.v0 files.""" + indexed: dict[str, tuple[dict[str, Any], Path]] = {} + if trace_path is None: + return indexed + case_dir = trace_path.parent + for path in sorted(case_dir.glob("*.json")): + if path.name == trace_path.name: + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(data, dict): + continue + if data.get("artifact_type") != "PFCoreEffectFrame.v0": + continue + frame_id = str(data.get("frame_id") or "").strip() + if not frame_id: + raise EvidenceResolutionError( + f"effect frame artifact {path} missing frame_id" + ) + if frame_id in indexed: + raise EvidenceResolutionError( + f"ambiguous effect frame id {frame_id!r}: multiple candidate artifacts" + ) + indexed[frame_id] = (dict(data), path.resolve()) + return indexed + + +def effect_frame_allowed_kinds(frame: Mapping[str, Any]) -> list[str]: + """Normalized allowed effect-kind sequence from a declared frame artifact.""" + raw = frame.get("allowed_effect_kinds") + if not isinstance(raw, list): + return [] + kinds: list[str] = [] + for item in raw: + value = str(item or "").strip() + if value: + kinds.append(value) + return kinds + + +def action_effect_kinds(action: Mapping[str, Any]) -> list[str]: + """Declared effect kinds on an action (order-preserving).""" + raw = action.get("effects") + if not isinstance(raw, list): + return [] + kinds: list[str] = [] + for item in raw: + if isinstance(item, Mapping): + kind = str(item.get("effect_kind") or "").strip() + if kind: + kinds.append(kind) + elif isinstance(item, str) and item.strip(): + kinds.append(item.strip()) + return kinds + + +def action_effects_in_declared_frame( + action: Mapping[str, Any], + frame: Mapping[str, Any], +) -> bool: + """True iff every action effect kind is permitted by the independent frame.""" + allowed = set(effect_frame_allowed_kinds(frame)) + if not allowed: + return False + for kind in action_effect_kinds(action): + if kind not in allowed: + return False + return True + + +def assert_actions_in_declared_frame( + *, + frame: Mapping[str, Any], + events: list[Mapping[str, Any]] | tuple[Mapping[str, Any], ...], +) -> None: + """Fail closed when any event action declares an effect omitted from the frame.""" + for index, event in enumerate(events): + if not isinstance(event, Mapping): + continue + action = event.get("action") + if not isinstance(action, Mapping): + continue + if action_effects_in_declared_frame(action, frame): + continue + event_id = str(event.get("event_id") or index) + missing = sorted( + set(action_effect_kinds(action)) - set(effect_frame_allowed_kinds(frame)) + ) + raise EvidenceResolutionError( + f"action effects not in declared frame for event {event_id!r}: " + f"undeclared effect kinds {missing!r}" + ) + + +def _resource_structural_key(resource: Mapping[str, Any]) -> tuple[str, str, tuple[str, ...]]: + """Lean ``Resource`` DecidableEq key: uri, tenant, labels.""" + labels_raw = resource.get("labels") + labels: tuple[str, ...] + if isinstance(labels_raw, list): + labels = tuple(str(item) for item in labels_raw) + else: + labels = () + return ( + str(resource.get("uri") or ""), + str(resource.get("tenant") or ""), + labels, + ) + + +def insert_resource( + frame: list[Mapping[str, Any]], + resource: Mapping[str, Any], +) -> list[dict[str, Any]]: + """Mirror Lean ``insertResource`` (prepend when absent).""" + key = _resource_structural_key(resource) + for existing in frame: + if _resource_structural_key(existing) == key: + return [dict(item) for item in frame] + return [dict(resource), *[dict(item) for item in frame]] + + +def expand_resource_frame( + frame: list[Mapping[str, Any]], + action: Mapping[str, Any], +) -> list[dict[str, Any]]: + """Mirror Lean ``expandResourceFrame`` over ``reads ++ writes``.""" + reads = action.get("reads") + writes = action.get("writes") + footprint: list[Mapping[str, Any]] = [] + if isinstance(reads, list): + footprint.extend(item for item in reads if isinstance(item, Mapping)) + if isinstance(writes, list): + footprint.extend(item for item in writes if isinstance(item, Mapping)) + result: list[dict[str, Any]] = [dict(item) for item in frame] + for resource in footprint: + result = insert_resource(result, resource) + return result + + +def initial_state_from_principal(principal: Mapping[str, Any]) -> dict[str, Any]: + """Mirror Lean ``initialState`` (empty resource frame).""" + caps = principal.get("capabilities") + capability_frame = ( + [str(cap) for cap in caps] if isinstance(caps, list) else [] + ) + return { + "tenant": str(principal.get("tenant") or ""), + "active_principal": dict(principal), + "resource_frame": [], + "capability_frame": capability_frame, + } + + +def step_state( + state: Mapping[str, Any], + event: Mapping[str, Any], +) -> dict[str, Any] | None: + """Mirror Lean ``stepState``: deny is identity; allow succeeds or returns ``None``. + + Returning ``None`` is the operational failure that ``applyEvent`` would silently + collapse to a no-op. FramePreservedCertificate must reject that path. + """ + decision = str(event.get("decision") or "") + if decision == "deny": + return { + "tenant": str(state.get("tenant") or ""), + "active_principal": dict(state.get("active_principal") or {}), + "resource_frame": [dict(item) for item in (state.get("resource_frame") or [])], + "capability_frame": [str(cap) for cap in (state.get("capability_frame") or [])], + } + if decision != "allow": + return None + principal = event.get("principal") + action = event.get("action") + if not isinstance(principal, Mapping) or not isinstance(action, Mapping): + return None + from pcs_core.lean_check import action_allowed_d + + if not action_allowed_d(principal, action): + return None + if str(state.get("tenant") or "") != str(principal.get("tenant") or ""): + return None + caps = principal.get("capabilities") + capability_frame = ( + [str(cap) for cap in caps] if isinstance(caps, list) else [] + ) + prior_frame = state.get("resource_frame") + frame_list: list[Mapping[str, Any]] = ( + [item for item in prior_frame if isinstance(item, Mapping)] + if isinstance(prior_frame, list) + else [] + ) + return { + "tenant": str(principal.get("tenant") or ""), + "active_principal": dict(principal), + "resource_frame": expand_resource_frame(frame_list, action), + "capability_frame": capability_frame, + } + + +def simulate_frame_preserved_transitions( + events: list[Mapping[str, Any]] | tuple[Mapping[str, Any], ...], +) -> tuple[dict[str, Any], tuple[dict[str, Any], ...]]: + """Compute initial + post-states; reject allow events that would become applyEvent no-ops.""" + if not events: + raise EvidenceResolutionError( + "FramePreservedCertificate requires ≥1 event and concrete initial state" + ) + first = events[0] + if not isinstance(first, Mapping): + raise EvidenceResolutionError( + "FramePreservedCertificate requires concrete initial state (event principal)" + ) + principal = first.get("principal") + if not isinstance(principal, Mapping): + raise EvidenceResolutionError( + "FramePreservedCertificate requires concrete initial state (event principal)" + ) + state = initial_state_from_principal(principal) + initial = dict(state) + posts: list[dict[str, Any]] = [] + for index, event in enumerate(events): + if not isinstance(event, Mapping): + raise EvidenceResolutionError( + f"FramePreservedCertificate event at index {index} is not an object" + ) + event_id = str(event.get("event_id") or index) + decision = str(event.get("decision") or "") + next_state = step_state(state, event) + if next_state is None: + raise EvidenceResolutionError( + f"stepState failed for allow event {event_id!r}: " + "operational transition is none (applyEvent no-op rejected; " + "cross-tenant or actionAllowed gate)" + ) + if decision == "deny": + # Identity must hold exactly for deny semantics. + if ( + next_state.get("tenant") != state.get("tenant") + or next_state.get("active_principal") != state.get("active_principal") + or next_state.get("resource_frame") != state.get("resource_frame") + or next_state.get("capability_frame") != state.get("capability_frame") + ): + raise EvidenceResolutionError( + f"deny identity violated for event {event_id!r}" + ) + posts.append(next_state) + state = next_state + return initial, tuple(posts) + + +def compute_transition_chain_digest( + *, + initial_state: Mapping[str, Any], + transition_states: tuple[Mapping[str, Any], ...] | list[Mapping[str, Any]], + event_ids: list[str] | tuple[str, ...], +) -> str: + """Digest binding initial state, event order, and proved post-states.""" + from pcs_core.hash import canonical_hash + + payload = { + "initial_state": dict(initial_state), + "event_ids": list(event_ids), + "transition_states": [dict(state) for state in transition_states], + } + return canonical_hash(payload) + + +def _index_handoff_candidates( + candidates: list[dict[str, Any]], + *, + trace_path: Path | None, +) -> dict[str, tuple[dict[str, Any], Path | None]]: + """Map handoff_id -> (artifact, path). Path is best-effort from sibling files.""" + indexed: dict[str, tuple[dict[str, Any], Path | None]] = {} + path_by_id: dict[str, Path] = {} + if trace_path is not None: + case_dir = trace_path.parent + for path in sorted(case_dir.glob("*.json")): + if path.name == trace_path.name: + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if isinstance(data, dict) and data.get("artifact_type") == "PFCoreHandoff.v0": + handoff_id = str(data.get("handoff_id") or "") + if handoff_id: + path_by_id[handoff_id] = path.resolve() + + for item in candidates: + handoff_id = str(item.get("handoff_id") or "") + if not handoff_id: + raise EvidenceResolutionError("handoff candidate missing handoff_id") + if handoff_id in indexed: + raise EvidenceResolutionError( + f"ambiguous handoff id {handoff_id!r}: multiple candidate artifacts" + ) + indexed[handoff_id] = (dict(item), path_by_id.get(handoff_id)) + return indexed + + +def _index_contract_paths( + contracts: Mapping[str, Mapping[str, Any]], + *, + trace_path: Path | None, +) -> dict[str, Path]: + path_by_id: dict[str, Path] = {} + if trace_path is None: + return path_by_id + search_dirs = [trace_path.parent] + nested = trace_path.parent / "contracts" + if nested.is_dir(): + search_dirs.append(nested) + for directory in search_dirs: + for path in sorted(directory.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(data, dict): + continue + if data.get("artifact_type") != "PFCoreContract.v0": + continue + contract_id = str(data.get("contract_id") or "") + if contract_id and contract_id in contracts: + path_by_id[contract_id] = path.resolve() + return path_by_id + + +def _referenced_contract_ids(trace: Mapping[str, Any]) -> set[str]: + from pcs_core.pf_core_lean_codegen import trace_events + + refs: set[str] = set() + for event in trace_events(trace): + raw = event.get("contract_refs") + if isinstance(raw, list): + for item in raw: + value = str(item or "").strip() + if value: + refs.add(value) + return refs + + +def resolve_pf_core_evidence( + trace: Mapping[str, Any], + *, + trace_path: Path, + certificate_mode: str, + selection: Mapping[str, Any] | None = None, +) -> PFCoreResolvedEvidence: + """Resolve handoffs/contracts/events once for the given mode and selection policy.""" + from pcs_core.pf_core_lean_codegen import ( + collect_contracts_for_trace, + collect_handoffs_near_trace, + trace_events, + ) + + resolved_path = trace_path.resolve() + if not resolved_path.is_file(): + raise EvidenceResolutionError(f"trace file not found: {resolved_path}") + + selection_obj = ( + dict(selection) + if selection is not None + else load_evidence_selection(trace, trace_path=resolved_path) + ) + policy = str(selection_obj.get("policy") or EVIDENCE_SELECTION_POLICY) + policy_version = str( + selection_obj.get("policy_version") or EVIDENCE_SELECTION_POLICY_VERSION + ) + selected_handoff_ids = _selected_ids(selection_obj, "handoff_ids") + selected_contract_ids = _selected_ids(selection_obj, "contract_ids") + selected_effect_frame_id = _selected_effect_frame_id(selection_obj) + + # Handoffs: never auto-accept every sibling. Explicit IDs only. + candidates = collect_handoffs_near_trace(trace, trace_path=resolved_path) + candidate_index = _index_handoff_candidates(candidates, trace_path=resolved_path) + if selected_handoff_ids is None: + if certificate_mode == "HandoffSafeCertificate": + raise EvidenceResolutionError( + "HandoffSafeCertificate requires evidence_selection.handoff_ids " + "(explicit handoff binding; sibling auto-scan is not accepted)" + ) + handoff_id_order: tuple[str, ...] = () + else: + handoff_id_order = selected_handoff_ids + + resolved_handoffs: list[ResolvedHandoff] = [] + for handoff_id in handoff_id_order: + found = candidate_index.get(handoff_id) + if found is None: + raise EvidenceResolutionError( + f"selected handoff id {handoff_id!r} not found near trace {resolved_path}" + ) + artifact, path = found + resolved_handoffs.append( + ResolvedHandoff( + handoff_id=handoff_id, + path=path, + artifact=_freeze_mapping(artifact), + ) + ) + + # Contracts: never auto-accept every sibling for ContractChecked. + # Explicit IDs required for ContractCheckedCertificate; other modes may + # bind referenced artifacts or an explicit selection. + loaded_contracts = collect_contracts_for_trace(trace, trace_path=resolved_path) + contract_paths = _index_contract_paths(loaded_contracts, trace_path=resolved_path) + if selected_contract_ids is None: + if certificate_mode == "ContractCheckedCertificate": + raise EvidenceResolutionError( + "ContractCheckedCertificate requires evidence_selection.contract_ids " + "(explicit contract binding; sibling auto-scan is not accepted)" + ) + referenced = _referenced_contract_ids(trace) + if referenced: + # contract_refs may include non-contract policy IDs on tool-use traces. + # Bind only refs that resolve to PFCoreContract.v0 artifacts. + contract_id_order = tuple( + contract_id + for contract_id in sorted(loaded_contracts) + if contract_id in referenced + ) + else: + contract_id_order = () + else: + contract_id_order = selected_contract_ids + for contract_id in contract_id_order: + if contract_id not in loaded_contracts: + raise EvidenceResolutionError( + f"selected contract id {contract_id!r} not found near trace {resolved_path}" + ) + + if certificate_mode == "ContractCheckedCertificate": + if not contract_id_order: + raise EvidenceResolutionError( + "ContractCheckedCertificate requires ≥1 explicitly selected contract" + ) + referenced = _referenced_contract_ids(trace) + missing_refs = sorted(referenced - set(contract_id_order)) + if missing_refs: + raise EvidenceResolutionError( + f"trace contract_refs unresolved against selected contracts: {missing_refs}" + ) + unresolved_selected = sorted(set(contract_id_order) - set(loaded_contracts)) + if unresolved_selected: + raise EvidenceResolutionError( + f"selected contracts missing artifacts: {unresolved_selected}" + ) + + resolved_contracts: list[ResolvedContract] = [] + layers: dict[str, Mapping[str, str]] = {} + for contract_id in contract_id_order: + artifact = dict(loaded_contracts[contract_id]) + resolved_contracts.append( + ResolvedContract( + contract_id=contract_id, + path=contract_paths.get(contract_id), + artifact=_freeze_mapping(artifact), + ) + ) + layers[contract_id] = MappingProxyType(dict(resolve_semantics_layer(artifact))) + + events = tuple(_freeze_mapping(event) for event in trace_events(trace)) + + # Effect frame: one global declared frame when required / explicitly selected. + frame_candidates = _index_effect_frame_candidates(trace_path=resolved_path) + effect_frame_obj: Mapping[str, Any] | None = None + effect_frame_path: Path | None = None + if selected_effect_frame_id is None: + if certificate_mode == "EffectFrameCertificate": + raise EvidenceResolutionError( + "EffectFrameCertificate requires evidence_selection.effect_frame_id " + "(explicit independent frame binding; action.effects is not a frame)" + ) + else: + found_frame = frame_candidates.get(selected_effect_frame_id) + if found_frame is None: + raise EvidenceResolutionError( + f"selected effect_frame_id {selected_effect_frame_id!r} not found " + f"near trace {resolved_path}" + ) + frame_body, frame_path = found_frame + if str(frame_body.get("frame_scope_policy") or "") != "global": + raise EvidenceResolutionError( + "v0 effect frames must declare frame_scope_policy='global' " + "(one global frame per trace)" + ) + if certificate_mode == "EffectFrameCertificate": + assert_actions_in_declared_frame(frame=frame_body, events=events) + effect_frame_obj = _freeze_mapping(frame_body) + effect_frame_path = frame_path + + digests: dict[str, str] = {str(resolved_path): _sha256_file(resolved_path)} + for handoff in resolved_handoffs: + if handoff.path is not None: + digests[str(handoff.path)] = _sha256_file(handoff.path) + else: + payload = json.dumps(dict(handoff.artifact), sort_keys=True, separators=(",", ":")) + digests[f"embedded:handoff:{handoff.handoff_id}"] = _sha256_bytes( + payload.encode("utf-8") + ) + for contract in resolved_contracts: + if contract.path is not None: + digests[str(contract.path)] = _sha256_file(contract.path) + else: + payload = json.dumps(dict(contract.artifact), sort_keys=True, separators=(",", ":")) + digests[f"embedded:contract:{contract.contract_id}"] = _sha256_bytes( + payload.encode("utf-8") + ) + if effect_frame_path is not None: + digests[str(effect_frame_path)] = _sha256_file(effect_frame_path) + elif effect_frame_obj is not None: + payload = json.dumps(dict(effect_frame_obj), sort_keys=True, separators=(",", ":")) + digests[f"embedded:effect_frame:{effect_frame_obj.get('frame_id')}"] = _sha256_bytes( + payload.encode("utf-8") + ) + + selection_path = resolved_path.parent / EVIDENCE_SELECTION_FILENAME + if selection_path.is_file() and not isinstance(trace.get("evidence_selection"), Mapping): + digests[str(selection_path.resolve())] = _sha256_file(selection_path) + + initial_state_obj: Mapping[str, Any] | None = None + transition_states_tuple: tuple[Mapping[str, Any], ...] = () + if certificate_mode == "FramePreservedCertificate": + initial_raw, posts_raw = simulate_frame_preserved_transitions(events) + initial_state_obj = _freeze_mapping(initial_raw) + transition_states_tuple = tuple(_freeze_mapping(post) for post in posts_raw) + chain_digest = compute_transition_chain_digest( + initial_state=initial_raw, + transition_states=posts_raw, + event_ids=[ + str(event.get("event_id") or index) for index, event in enumerate(events) + ], + ) + digests["embedded:transition_chain"] = chain_digest + + return PFCoreResolvedEvidence( + source_trace_path=resolved_path, + canonical_trace=_freeze_mapping(dict(trace)), + certificate_mode=certificate_mode, + selected_events=events, + handoffs=tuple(resolved_handoffs), + contracts=tuple(resolved_contracts), + effective_contract_semantic_layers=MappingProxyType(layers), + effect_frame=effect_frame_obj, + effect_frame_path=effect_frame_path, + initial_state=initial_state_obj, + transition_states=transition_states_tuple, + source_file_digests=MappingProxyType(digests), + evidence_selection_policy=policy, + evidence_selection_policy_version=policy_version, + selected_handoff_ids=handoff_id_order, + selected_contract_ids=contract_id_order, + ) + + +def transition_chain_digest(evidence: PFCoreResolvedEvidence) -> str: + """Digest for the resolved FramePreserved transition chain.""" + digest = evidence.source_file_digests.get("embedded:transition_chain") + if digest is None: + raise EvidenceResolutionError("missing transition chain digest in resolved evidence") + return digest + + +def effect_frame_source_digest(evidence: PFCoreResolvedEvidence) -> str: + """Digest for the selected independent effect-frame artifact.""" + if evidence.effect_frame is None: + raise EvidenceResolutionError("no declared effect frame in resolved evidence") + if evidence.effect_frame_path is not None: + key = str(evidence.effect_frame_path) + else: + frame_id = str(evidence.effect_frame.get("frame_id") or "") + key = f"embedded:effect_frame:{frame_id}" + digest = evidence.source_file_digests.get(key) + if digest is None: + raise EvidenceResolutionError(f"missing source digest for effect frame {key!r}") + return digest + + +def delegated_capability_ids(handoff: Mapping[str, Any]) -> list[str]: + """Exact delegated capability ID sequence from a source or projected handoff.""" + raw = handoff.get("delegated_capabilities") + if not isinstance(raw, list): + return [] + ids: list[str] = [] + for item in raw: + if isinstance(item, Mapping): + cap_id = str(item.get("capability_id") or "").strip() + if cap_id: + ids.append(cap_id) + elif isinstance(item, str) and item.strip(): + ids.append(item.strip()) + return ids + + +def assert_handoff_capability_fidelity( + *, + source_handoffs: list[Mapping[str, Any]], + projected_handoffs: list[Mapping[str, Any]], + lean_capability_sequences: list[list[str]], +) -> None: + """Enforce source IDs = projected IDs = Lean delegatedCapabilities sequences.""" + if len(source_handoffs) != len(projected_handoffs): + raise EvidenceResolutionError( + "handoff fidelity: source/projected handoff counts differ " + f"({len(source_handoffs)} != {len(projected_handoffs)})" + ) + if len(projected_handoffs) != len(lean_capability_sequences): + raise EvidenceResolutionError( + "handoff fidelity: projected/Lean handoff counts differ " + f"({len(projected_handoffs)} != {len(lean_capability_sequences)})" + ) + for index, (source, projected, lean_ids) in enumerate( + zip(source_handoffs, projected_handoffs, lean_capability_sequences) + ): + source_ids = delegated_capability_ids(source) + projected_ids = delegated_capability_ids(projected) + if source_ids != projected_ids: + raise EvidenceResolutionError( + f"handoff fidelity mismatch at index {index}: " + f"source={source_ids!r} projected={projected_ids!r}" + ) + if projected_ids != list(lean_ids): + raise EvidenceResolutionError( + f"handoff fidelity mismatch at index {index}: " + f"projected={projected_ids!r} lean={list(lean_ids)!r}" + ) + + +def contract_source_file_digests(evidence: PFCoreResolvedEvidence) -> dict[str, str]: + """Digests for selected contract source files (or embedded payloads).""" + out: dict[str, str] = {} + for contract in evidence.contracts: + if contract.path is not None: + key = str(contract.path) + else: + key = f"embedded:contract:{contract.contract_id}" + digest = evidence.source_file_digests.get(key) + if digest is None: + raise EvidenceResolutionError( + f"missing source digest for selected contract {contract.contract_id!r}" + ) + out[key] = digest + return out + + +def collect_contract_theorem_names( + theorem_inventory: frozenset[str] | set[str] | list[str] | None, +) -> list[str]: + """Concrete contract theorem names from a generated inventory.""" + if theorem_inventory is None: + return [] + names = sorted(str(name) for name in theorem_inventory) + prefixes = ( + "concrete_trace_satisfies_contract", + "concrete_satisfies_contract", + "concrete_contract_pre_", + "concrete_contract_post_", + "concrete_contract_checked", + ) + return [name for name in names if name.startswith(prefixes)] + + +def handoff_source_file_digests(evidence: PFCoreResolvedEvidence) -> dict[str, str]: + """Digests for selected handoff source files (or embedded payloads).""" + out: dict[str, str] = {} + for handoff in evidence.handoffs: + if handoff.path is not None: + key = str(handoff.path) + else: + key = f"embedded:handoff:{handoff.handoff_id}" + digest = evidence.source_file_digests.get(key) + if digest is None: + raise EvidenceResolutionError( + f"missing source digest for selected handoff {handoff.handoff_id!r}" + ) + out[key] = digest + return out + + +def collect_handoff_theorem_names( + theorem_inventory: frozenset[str] | set[str] | list[str] | None, +) -> list[str]: + """Concrete handoff theorem names from a generated inventory.""" + if theorem_inventory is None: + return [] + names = sorted(str(name) for name in theorem_inventory) + return [ + name + for name in names + if name == "concrete_handoff_safe" or name.startswith("concrete_handoff_safe_") + ] + + +def compute_handoff_evidence_digest( + *, + selected_handoff_ids: tuple[str, ...] | list[str], + handoff_source_file_digests: Mapping[str, str], + handoff_theorem_names: list[str] | tuple[str, ...], +) -> str: + """Digest binding selected handoffs, source digests, and theorems.""" + from pcs_core.hash import canonical_hash + + payload = { + "selected_handoff_ids": list(selected_handoff_ids), + "handoff_source_file_digests": dict(sorted(handoff_source_file_digests.items())), + "handoff_theorem_names": list(handoff_theorem_names), + } + return canonical_hash(payload) + + +def compute_contract_evidence_digest( + *, + selected_contract_ids: tuple[str, ...] | list[str], + contract_source_file_digests: Mapping[str, str], + effective_layers: Mapping[str, Mapping[str, str]], + contract_theorem_names: list[str] | tuple[str, ...], +) -> str: + """Digest binding selected contracts, source digests, layers, and theorems.""" + from pcs_core.hash import canonical_hash + + payload = { + "selected_contract_ids": list(selected_contract_ids), + "contract_source_file_digests": dict(sorted(contract_source_file_digests.items())), + "effective_contract_semantic_layers": { + contract_id: dict(sorted(dict(layers).items())) + for contract_id, layers in sorted(effective_layers.items()) + }, + "contract_theorem_names": list(contract_theorem_names), + } + return canonical_hash(payload) + + +def assert_contract_projection_ids( + *, + selected_contract_ids: tuple[str, ...] | list[str], + projected_contract_ids: list[str] | tuple[str, ...], +) -> None: + """Require projection contract IDs to match the explicitly selected set.""" + selected = list(selected_contract_ids) + projected = list(projected_contract_ids) + if selected != projected: + raise EvidenceResolutionError( + "contract fidelity: selected/projected contract ids differ " + f"(selected={selected!r} projected={projected!r})" + ) diff --git a/schemas/PFCoreEvidenceManifest.v0.schema.json b/schemas/PFCoreEvidenceManifest.v0.schema.json new file mode 100644 index 0000000..7508310 --- /dev/null +++ b/schemas/PFCoreEvidenceManifest.v0.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/PFCoreEvidenceManifest.v0.schema.json", + "title": "PFCoreEvidenceManifest.v0", + "description": "Closed list of selected evidence artifacts copied into a PF-Core release bundle evidence/ directory. Every file digest is recorded.", + "type": "object", + "required": [ + "schema_version", + "artifact_type", + "evidence_selection_policy", + "evidence_selection_policy_version", + "files", + "evidence_manifest_digest" + ], + "additionalProperties": false, + "properties": { + "schema_version": { "$ref": "common.defs.json#/$defs/schema_version" }, + "artifact_type": { "const": "PFCoreEvidenceManifest.v0" }, + "canonicalization_version": { + "$ref": "common.defs.json#/$defs/canonicalization_version" + }, + "evidence_selection_policy": { + "type": "string", + "minLength": 1 + }, + "evidence_selection_policy_version": { + "type": "string", + "minLength": 1 + }, + "files": { + "type": "array", + "items": { "$ref": "#/$defs/evidence_file" } + }, + "evidence_manifest_digest": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "Canonical digest of this manifest excluding evidence_manifest_digest itself" + } + }, + "$defs": { + "evidence_file": { + "type": "object", + "required": ["path", "sha256", "role"], + "additionalProperties": false, + "properties": { + "path": { + "$ref": "common.defs.json#/$defs/relative_posix_path", + "description": "Path relative to the release bundle root" + }, + "sha256": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "role": { + "type": "string", + "enum": [ + "handoff", + "contract", + "effect_frame", + "policy", + "embedded" + ] + }, + "artifact_id": { + "type": "string", + "minLength": 1 + }, + "artifact_type": { + "type": "string", + "minLength": 1 + } + } + } + } +} From 50edead1bc88b8e31336f16affcf1cd93d4f0480 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:00:31 -0700 Subject: [PATCH 05/24] Project contract semantics_layer into semantic evidence. Make contract checks emit a stable semantics_layer projection so certificate modes can cite contract outcomes without re-deriving them ad hoc. --- docs/pf-core/semantic-projection.md | 11 +- .../PFCoreSemanticProjection.v0.json | 44 +++ python/pcs_core/pf_core_contract.py | 2 + python/pcs_core/pf_core_contract_semantics.py | 116 +++++++ .../pcs_core/pf_core_semantic_projection.py | 316 +++++++++++++++++- .../tests/test_pf_core_contract_evidence.py | 295 ++++++++++++++++ .../PFCoreSemanticProjection.v0.schema.json | 118 ++++++- 7 files changed, 880 insertions(+), 22 deletions(-) create mode 100644 lean/PFCore/Generated/PFCoreSemanticProjection.v0.json create mode 100644 python/tests/test_pf_core_contract_evidence.py diff --git a/docs/pf-core/semantic-projection.md b/docs/pf-core/semantic-projection.md index e0e3156..c9db7fa 100644 --- a/docs/pf-core/semantic-projection.md +++ b/docs/pf-core/semantic-projection.md @@ -11,8 +11,15 @@ artifact (`PFCoreSemanticProjection.v0`) rather than a full Lean JSON decoder. 2. Hash the projection independently (`projection_hash` / certificate `semantic_projection_hash`). 3. Emit concrete Lean terms from the projection (Python codegen bridge). -4. Bind generated theorem inventory via `theorem_inventory_hash` / - `theorem_manifest_hash`. +4. Bind generated theorem inventory via `theorem_inventory_hash` and the + structured `PFCoreTheoremManifest.v0` digest (`theorem_manifest_hash`). + The manifest hash covers normalized propositions and metadata; it is **not** + an alias of the name-only inventory hash. +5. Close the release bundle with `semantic_projection_*`, `theorem_manifest_*`, + `evidence_manifest_*`, and `lean_check_result_*` path/hash fields. Copy + selected evidence into `evidence/` and verify independently with + `pcs pf-core verify-bundle` (stable releases must not rely on + `validate-bundle` alone). Envelope fields that are not Lean-relevant (source commit metadata, unused extensions) must not change the projection hash. diff --git a/lean/PFCore/Generated/PFCoreSemanticProjection.v0.json b/lean/PFCore/Generated/PFCoreSemanticProjection.v0.json new file mode 100644 index 0000000..f9101dd --- /dev/null +++ b/lean/PFCore/Generated/PFCoreSemanticProjection.v0.json @@ -0,0 +1,44 @@ +{ + "schema_version": "v0", + "artifact_type": "PFCoreSemanticProjection.v0", + "trace_id": "trace-file-read-1", + "certificate_mode": "CompositionalExtensionCertificate", + "events": [ + { + "sequence": 0, + "event_id": "ev-file-read-1", + "decision": "allow", + "principal": { + "principal_id": "agent-1", + "tenant": "tenant-a", + "roles": [ + "agent" + ], + "capabilities": [ + "cap:file-read", + "cap:email-send", + "cap:handoff", + "cap:mcp-invoke" + ] + }, + "action": { + "action_id": "act-1", + "tool_name": "filesystem.read", + "capability_id": "cap:file-read", + "capability_effect_kind": "file.read", + "resource_pattern": "/data/*", + "effects": [ + "file.read" + ], + "reads": [ + { + "uri": "/data/report.txt", + "tenant": "tenant-a" + } + ], + "writes": [] + } + } + ], + "projection_hash": "sha256:5115f144cd79dc0b8037ca2b3a016f552ac5de59fd1d9ef85e0ac7c357bbbf44" +} \ No newline at end of file diff --git a/python/pcs_core/pf_core_contract.py b/python/pcs_core/pf_core_contract.py index 6f88939..934b80e 100644 --- a/python/pcs_core/pf_core_contract.py +++ b/python/pcs_core/pf_core_contract.py @@ -14,6 +14,7 @@ build_contract_semantics_checked, default_semantics_layer_for_contract, field_semantics_layer, + materialize_contract_semantics_layer, resolve_semantics_layer, validate_semantics_layer, ) @@ -34,6 +35,7 @@ "load_contract", "load_contracts", "load_contracts_from_dir", + "materialize_contract_semantics_layer", "resolve_semantics_layer", "trace_has_contract_binding", "validate_event_against_contract", diff --git a/python/pcs_core/pf_core_contract_semantics.py b/python/pcs_core/pf_core_contract_semantics.py index 458f1bd..9572442 100644 --- a/python/pcs_core/pf_core_contract_semantics.py +++ b/python/pcs_core/pf_core_contract_semantics.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +import re from dataclasses import dataclass from typing import Any, Mapping @@ -165,6 +167,120 @@ def field_semantics_layer(contract: Mapping[str, Any], *, section: str, field: s return resolve_semantics_layer(contract).get(field, DEFAULT_FIELD_LAYERS.get(field, "runtime")) +_LEAN_IDENT_RE = re.compile(r"[^a-zA-Z0-9_]") + +OUT_OF_SCOPE_RATIONALES: dict[str, str] = { + "require_capability": "Marked out_of_scope; not discharged by Lean or runtime checkers", + "require_effect": "Marked out_of_scope; not discharged by Lean or runtime checkers", + "require_tenant_match": "Marked out_of_scope; not discharged by Lean or runtime checkers", + "require_role": "Not mapped to Lean ContractDecide; runtime role membership only", + "require_policy_ref": "Not mapped to Lean ContractDecide; runtime contract_refs membership only", + "require_evidence_ref": "Not mapped to Lean ContractDecide; runtime evidence_refs membership only", + "require_decision": "Marked out_of_scope; not discharged by Lean or runtime checkers", + "require_event_safe": "Marked out_of_scope; not discharged by Lean or runtime checkers", + "require_trace_safe": "Marked out_of_scope; not discharged by Lean or runtime checkers", +} + + +def _lean_ident(prefix: str, raw: str) -> str: + slug = _LEAN_IDENT_RE.sub("_", raw).strip("_") + if not slug or slug[0].isdigit(): + slug = f"{prefix}_{slug or 'x'}" + return slug + + +def runtime_check_id(contract_id: str, *, section: str, field: str) -> str: + """Stable runtime check identifier recorded on certificates and projections.""" + return f"{contract_id}.{section}.{field}" + + +def lean_theorem_for_contract_field( + contract_id: str, + *, + section: str, + field: str, + event_id: str | None, +) -> str | None: + """Deterministic Lean theorem name that discharges a lean-layer contract field.""" + _ = field + base = _lean_ident("contract", contract_id) + if section == "invariant": + return f"concrete_trace_satisfies_{base}" + if event_id is None: + return None + event_name = _lean_ident("ev", event_id) + if section == "pre": + return f"concrete_contract_pre_{base}_{event_name}" + if section == "post": + return f"concrete_contract_post_{base}_{event_name}" + return None + + +def normalize_contract_field_value(value: Any) -> str | bool | None: + """Normalize an active contract field value for projection records.""" + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + return value + if isinstance(value, (int, float)): + return str(value) + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def materialize_contract_semantics_layer( + contract: Mapping[str, Any], + *, + contract_id: str, + referencing_event_ids: list[str] | tuple[str, ...] | None = None, + effective_layers: Mapping[str, str] | None = None, +) -> list[dict[str, Any]]: + """Materialize effective semantics_layer records after defaults are applied. + + Each active contract field records section, field name, normalized value, + effective layer, and the applicable Lean theorem / runtime check id / + out-of-scope rationale. + """ + layers = dict(effective_layers) if effective_layers is not None else resolve_semantics_layer(contract) + event_ids = [str(item) for item in (referencing_event_ids or []) if str(item)] + primary_event = event_ids[0] if event_ids else None + records: list[dict[str, Any]] = [] + for field, section in sorted( + contract_fields_in_use(contract).items(), + key=lambda item: (item[1], item[0]), + ): + block = contract.get(section) + raw_value = block.get(field) if isinstance(block, Mapping) else None + layer = layers.get(field, DEFAULT_FIELD_LAYERS.get(field, "runtime")) + record: dict[str, Any] = { + "section": section, + "field": field, + "normalized_value": normalize_contract_field_value(raw_value), + "effective_layer": layer, + } + if layer == "lean": + theorem = lean_theorem_for_contract_field( + contract_id, + section=section, + field=field, + event_id=primary_event, + ) + if theorem: + record["lean_theorem"] = theorem + elif layer == "runtime": + record["runtime_check_id"] = runtime_check_id( + contract_id, section=section, field=field + ) + elif layer == "out_of_scope": + record["out_of_scope_rationale"] = OUT_OF_SCOPE_RATIONALES.get( + field, + "Field marked out_of_scope for this contract", + ) + records.append(record) + return records + + def _trace_events(trace: Mapping[str, Any]) -> list[dict[str, Any]]: events = trace.get("events") if not isinstance(events, list): diff --git a/python/pcs_core/pf_core_semantic_projection.py b/python/pcs_core/pf_core_semantic_projection.py index ae38239..ca2da5f 100644 --- a/python/pcs_core/pf_core_semantic_projection.py +++ b/python/pcs_core/pf_core_semantic_projection.py @@ -7,11 +7,16 @@ from __future__ import annotations +import json from pathlib import Path -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from pcs_core.hash import canonical_hash from pcs_core.obligation_extraction_errors import ObligationExtractionError +from pcs_core.pf_core_catalog import CAPABILITY_CATALOG, EFFECT_KINDS + +if TYPE_CHECKING: + from pcs_core.pf_core_resolved_evidence import PFCoreResolvedEvidence def _require_mapping(value: Any, *, field: str) -> Mapping[str, Any]: @@ -56,8 +61,6 @@ def _project_action(action: Mapping[str, Any], *, field: str) -> dict[str, Any]: cap_effect = str(capability.get("effect_kind") or "file.read") resource_pattern = str(capability.get("resource_pattern") or "") if not resource_pattern and cap_id: - from pcs_core.pf_core_catalog import CAPABILITY_CATALOG - catalog_entry = CAPABILITY_CATALOG.get(cap_id) if isinstance(catalog_entry, Mapping): resource_pattern = str(catalog_entry.get("resource_pattern") or "") @@ -119,6 +122,81 @@ def _project_event(event: Mapping[str, Any], *, index: int) -> dict[str, Any]: return projected +def _project_delegated_capabilities( + raw: Any, + *, + field: str, +) -> list[dict[str, str]]: + if not isinstance(raw, list) or not raw: + raise ObligationExtractionError( + code="EmptyProjectedDelegation", + message="handoff delegated_capabilities must be a non-empty array", + field_path=field, + ) + projected: list[dict[str, str]] = [] + for index, item in enumerate(raw): + item_field = f"{field}[{index}]" + if not isinstance(item, Mapping): + raise ObligationExtractionError( + code="InvalidSemanticProjection", + message="delegated capability must be an object", + field_path=item_field, + ) + cap_id = str(item.get("capability_id") or "").strip() + effect_kind = str(item.get("effect_kind") or "").strip() + resource_pattern = str(item.get("resource_pattern") or "").strip() + if not cap_id or not effect_kind or not resource_pattern: + raise ObligationExtractionError( + code="InvalidSemanticProjection", + message=( + "delegated capability requires capability_id, effect_kind, " + "and resource_pattern" + ), + field_path=item_field, + ) + catalog_entry = CAPABILITY_CATALOG.get(cap_id) + if catalog_entry is None: + raise ObligationExtractionError( + code="UnknownDelegatedCapability", + message=f"capability_id {cap_id!r} is not in the PF-Core catalog", + field_path=item_field, + ) + if effect_kind not in EFFECT_KINDS: + raise ObligationExtractionError( + code="UnknownDelegatedEffectKind", + message=f"effect_kind {effect_kind!r} is not in the PF-Core catalog", + field_path=item_field, + ) + expected_effect = str(catalog_entry.get("effect_kind") or "") + expected_pattern = str(catalog_entry.get("resource_pattern") or "") + if effect_kind != expected_effect: + raise ObligationExtractionError( + code="DelegatedCapabilityCatalogMismatch", + message=( + f"capability {cap_id!r} effect_kind {effect_kind!r} does not match " + f"catalog {expected_effect!r}" + ), + field_path=item_field, + ) + if resource_pattern != expected_pattern: + raise ObligationExtractionError( + code="DelegatedCapabilityCatalogMismatch", + message=( + f"capability {cap_id!r} resource_pattern {resource_pattern!r} does not " + f"match catalog {expected_pattern!r}" + ), + field_path=item_field, + ) + projected.append( + { + "capability_id": cap_id, + "effect_kind": effect_kind, + "resource_pattern": resource_pattern, + } + ) + return projected + + def _project_handoff(handoff: Mapping[str, Any], *, index: int) -> dict[str, Any]: from_p = handoff.get("from_principal") to_p = handoff.get("to_principal") @@ -132,18 +210,138 @@ def _project_handoff(handoff: Mapping[str, Any], *, index: int) -> dict[str, Any "handoff_id": str(handoff.get("handoff_id") or f"handoff_{index}"), "from_principal": _project_principal(from_p, field=f"handoffs[{index}].from_principal"), "to_principal": _project_principal(to_p, field=f"handoffs[{index}].to_principal"), + "delegated_capabilities": _project_delegated_capabilities( + handoff.get("delegated_capabilities"), + field=f"handoffs[{index}].delegated_capabilities", + ), } -def _project_contract(contract_id: str, contract: Mapping[str, Any]) -> dict[str, Any]: +def _project_contract( + contract_id: str, + contract: Mapping[str, Any], + *, + referencing_event_ids: list[str] | None = None, + effective_layers: Mapping[str, str] | None = None, +) -> dict[str, Any]: + from pcs_core.pf_core_contract_semantics import materialize_contract_semantics_layer + projected: dict[str, Any] = {"contract_id": contract_id} - for key in ("pre", "post", "invariant", "field_semantics"): + for key in ("pre", "post", "invariant"): value = contract.get(key) if isinstance(value, Mapping): projected[key] = dict(value) + projected["semantics_layer"] = materialize_contract_semantics_layer( + contract, + contract_id=contract_id, + referencing_event_ids=referencing_event_ids, + effective_layers=effective_layers, + ) return projected +def _project_effect_frame(frame: Mapping[str, Any]) -> dict[str, Any]: + """Project Lean-relevant fields from an independently declared effect frame.""" + from pcs_core.pf_core_resolved_evidence import effect_frame_allowed_kinds + + kinds = effect_frame_allowed_kinds(frame) + for kind in kinds: + if kind not in EFFECT_KINDS: + raise ObligationExtractionError( + code="UnknownEffectKind", + message=f"effect frame lists unknown effect kind {kind!r}", + field_path="effect_frame.allowed_effect_kinds", + ) + projected: dict[str, Any] = { + "frame_id": str(frame.get("frame_id") or ""), + "allowed_effect_kinds": kinds, + "frame_scope_policy": "global", + } + workflow_id = str(frame.get("workflow_id") or "").strip() + if workflow_id: + projected["workflow_id"] = workflow_id + contract_id = str(frame.get("contract_id") or "").strip() + if contract_id: + projected["contract_id"] = contract_id + policy_ref = str(frame.get("source_policy_ref") or "").strip() + if policy_ref: + projected["source_policy_ref"] = policy_ref + constraints_raw = frame.get("resource_constraints") + if isinstance(constraints_raw, list) and constraints_raw: + constraints: list[dict[str, str]] = [] + for item in constraints_raw: + if not isinstance(item, Mapping): + continue + effect_kind = str(item.get("effect_kind") or "").strip() + pattern = str(item.get("resource_pattern") or "").strip() + if effect_kind and pattern: + constraints.append( + {"effect_kind": effect_kind, "resource_pattern": pattern} + ) + if constraints: + projected["resource_constraints"] = constraints + return projected + + +def _project_operational_state(state: Mapping[str, Any]) -> dict[str, Any]: + """Project a rich operational State for FramePreservedCertificate binding.""" + principal = state.get("active_principal") + if not isinstance(principal, Mapping): + principal = {} + resource_frame_raw = state.get("resource_frame") + resources: list[dict[str, str]] = [] + if isinstance(resource_frame_raw, list): + for item in resource_frame_raw: + if not isinstance(item, Mapping): + continue + resources.append( + { + "uri": str(item.get("uri") or ""), + "tenant": str(item.get("tenant") or ""), + } + ) + caps_raw = state.get("capability_frame") + capabilities = ( + [str(cap) for cap in caps_raw] if isinstance(caps_raw, list) else [] + ) + return { + "tenant": str(state.get("tenant") or ""), + "active_principal": { + "principal_id": str(principal.get("principal_id") or ""), + "tenant": str(principal.get("tenant") or ""), + "roles": ( + [str(role) for role in principal.get("roles") or []] + if isinstance(principal.get("roles"), list) + else [] + ), + "capabilities": ( + [str(cap) for cap in principal.get("capabilities") or []] + if isinstance(principal.get("capabilities"), list) + else [] + ), + }, + "resource_frame": resources, + "capability_frame": capabilities, + } + + +def _contract_referencing_event_ids( + events: list[Mapping[str, Any]], + contract_id: str, +) -> list[str]: + ids: list[str] = [] + for index, event in enumerate(events): + refs = event.get("contract_refs") + if not isinstance(refs, list): + continue + if contract_id not in {str(ref) for ref in refs}: + continue + event_id = str(event.get("event_id") or index) + if event_id: + ids.append(event_id) + return ids + + def build_semantic_projection( trace: Mapping[str, Any], *, @@ -151,8 +349,14 @@ def build_semantic_projection( trace_path: Path | None = None, handoffs: list[Mapping[str, Any]] | None = None, contracts: Mapping[str, Mapping[str, Any]] | None = None, + resolved_evidence: PFCoreResolvedEvidence | None = None, ) -> dict[str, Any]: - """Extract Lean-relevant fields and bind an independent projection hash.""" + """Extract Lean-relevant fields and bind an independent projection hash. + + When ``resolved_evidence`` is provided, handoffs/contracts come from that + snapshot only — no secondary directory rediscovery. + """ + del trace_path # retained for call-site compatibility; discovery is via resolved evidence events_raw = trace.get("events") if not isinstance(events_raw, list): events_raw = [] @@ -162,26 +366,65 @@ def build_semantic_projection( _project_event(event, index=index) for index, event in enumerate(typed_events) ] - if handoffs is None: - from pcs_core.pf_core_lean_codegen import collect_handoffs_near_trace + effective_layers_by_id: Mapping[str, Mapping[str, str]] = {} + contract_items: list[tuple[str, Mapping[str, Any]]] + if resolved_evidence is not None: + handoffs = resolved_evidence.handoff_artifacts + # Preserve explicit selection order (do not silently reorder/pick siblings). + contract_items = [ + (item.contract_id, item.artifact) for item in resolved_evidence.contracts + ] + effective_layers_by_id = resolved_evidence.effective_contract_semantic_layers + else: + # Without resolved evidence, do not scan siblings. Callers must pass + # explicit handoffs/contracts or resolve evidence first. + if handoffs is None: + handoffs = [] + if contracts is None: + contracts = {} + contract_items = [ + (contract_id, contract) + for contract_id, contract in sorted(contracts.items()) + if isinstance(contract, Mapping) + ] - handoffs = collect_handoffs_near_trace(trace, trace_path=trace_path) projected_handoffs = [ _project_handoff(item, index=index) for index, item in enumerate(handoffs) if isinstance(item, Mapping) ] - if contracts is None: - from pcs_core.pf_core_lean_codegen import collect_contracts_for_trace - - contracts = collect_contracts_for_trace(trace, trace_path=trace_path) projected_contracts = [ - _project_contract(contract_id, contract) - for contract_id, contract in sorted(contracts.items()) + _project_contract( + contract_id, + contract, + referencing_event_ids=_contract_referencing_event_ids(typed_events, contract_id), + effective_layers=( + dict(effective_layers_by_id[contract_id]) + if contract_id in effective_layers_by_id + else None + ), + ) + for contract_id, contract in contract_items if isinstance(contract, Mapping) ] + projected_effect_frame: dict[str, Any] | None = None + if resolved_evidence is not None and resolved_evidence.effect_frame is not None: + projected_effect_frame = _project_effect_frame(resolved_evidence.effect_frame) + + projected_initial_state: dict[str, Any] | None = None + projected_transition_states: list[dict[str, Any]] | None = None + if ( + resolved_evidence is not None + and resolved_evidence.initial_state is not None + and certificate_mode == "FramePreservedCertificate" + ): + projected_initial_state = _project_operational_state(resolved_evidence.initial_state) + projected_transition_states = [ + _project_operational_state(state) for state in resolved_evidence.transition_states + ] + body: dict[str, Any] = { "schema_version": "v0", "artifact_type": "PFCoreSemanticProjection.v0", @@ -193,6 +436,12 @@ def build_semantic_projection( body["handoffs"] = projected_handoffs if projected_contracts: body["contracts"] = projected_contracts + if projected_effect_frame is not None: + body["effect_frame"] = projected_effect_frame + if projected_initial_state is not None: + body["initial_state"] = projected_initial_state + if projected_transition_states is not None: + body["transition_states"] = projected_transition_states # Hash without projection_hash, then bind. projection_hash = canonical_hash(body) @@ -264,3 +513,40 @@ def projection_contracts(projection: Mapping[str, Any]) -> dict[str, dict[str, A continue out[contract_id] = dict(item) return out + + +def projection_contract_ids(projection: Mapping[str, Any]) -> list[str]: + """Contract IDs in projection order (selection order when resolved evidence was used).""" + raw = projection.get("contracts") + if not isinstance(raw, list): + return [] + ids: list[str] = [] + for item in raw: + if not isinstance(item, Mapping): + continue + contract_id = str(item.get("contract_id") or "") + if contract_id: + ids.append(contract_id) + return ids + + +def extract_lean_delegated_capability_sequences(lean_source: str) -> list[list[str]]: + """Parse ``delegatedCapabilities := [...]`` sequences from generated Lean source.""" + import re + + sequences: list[list[str]] = [] + pattern = re.compile( + r"delegatedCapabilities\s*:=\s*(\[[^\]]*\])", + re.MULTILINE, + ) + string_lit = re.compile(r'"((?:\\.|[^"\\])*)"') + for match in pattern.finditer(lean_source): + expr = match.group(1).strip() + if expr == "[]": + sequences.append([]) + continue + ids: list[str] = [] + for raw in string_lit.findall(expr): + ids.append(json.loads(f'"{raw}"')) + sequences.append(ids) + return sequences diff --git a/python/tests/test_pf_core_contract_evidence.py b/python/tests/test_pf_core_contract_evidence.py new file mode 100644 index 0000000..0162d53 --- /dev/null +++ b/python/tests/test_pf_core_contract_evidence.py @@ -0,0 +1,295 @@ +"""PR3 contract evidence fidelity: semantics_layer projection + ContractChecked binding.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from typing import Any + +import pytest + +from pcs_core.hash import canonical_hash +from pcs_core.pf_core_bundle import bundle_release, validate_bundle +from pcs_core.pf_core_contract_semantics import ( + materialize_contract_semantics_layer, + resolve_semantics_layer, +) +from pcs_core.pf_core_lean_codegen import ( + CertificateModeEvidenceMissing, + generate_proof_obligation_file, +) +from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + assert_contract_projection_ids, + collect_contract_theorem_names, + compute_contract_evidence_digest, + contract_source_file_digests, + resolve_pf_core_evidence, +) +from pcs_core.pf_core_semantic_projection import ( + build_semantic_projection, + projection_contract_ids, + projection_contracts, +) +from pcs_core.validate import validate_artifact + +REPO = Path(__file__).resolve().parents[2] +CONTRACT_FIXTURE = REPO / "examples" / "pf-core-valid" / "contract_checked" +CONTRACT_TRACE = CONTRACT_FIXTURE / "trace.json" +CONTRACT_JSON = CONTRACT_FIXTURE / "contract.json" + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, data: dict[str, Any]) -> None: + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def _prepare_case( + tmp_path: Path, + *, + selected_ids: list[str] | None, + include_unrelated_sibling: bool = False, + contract: dict[str, Any] | None = None, +) -> tuple[Path, dict[str, Any]]: + work = tmp_path / "case" + work.mkdir(parents=True, exist_ok=True) + trace = dict(_load(CONTRACT_TRACE)) + if selected_ids is None: + trace.pop("evidence_selection", None) + else: + trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "contract_ids": selected_ids, + } + from pcs_core.pf_core_runtime import compute_event_hash, compute_trace_hash + + events = trace.get("events") or [] + prev = "sha256:" + "0" * 64 + for event in events: + if not isinstance(event, dict): + continue + event["previous_event_hash"] = prev + event.pop("event_hash", None) + event.pop("signature_or_digest", None) + digest = compute_event_hash(event) + event["event_hash"] = digest + event["signature_or_digest"] = digest + prev = digest + trace.pop("trace_hash", None) + trace.pop("signature_or_digest", None) + trace["trace_hash"] = compute_trace_hash(trace) + trace["signature_or_digest"] = trace["trace_hash"] + trace_path = work / "trace.json" + _write_json(trace_path, trace) + body = dict(contract) if contract is not None else _load(CONTRACT_JSON) + _write_json(work / "contract.json", body) + if include_unrelated_sibling: + sibling = deepcopy(body) + sibling["contract_id"] = "contract-unrelated-sibling-v0" + sibling["name"] = "Unrelated sibling" + sibling.pop("signature_or_digest", None) + sibling["signature_or_digest"] = canonical_hash(sibling) + _write_json(work / "contract-unrelated.json", sibling) + return trace_path, trace + + +def test_semantics_layer_materialized_after_defaults() -> None: + contract = _load(CONTRACT_JSON) + layers = resolve_semantics_layer(contract) + records = materialize_contract_semantics_layer( + contract, + contract_id="contract-file-read-v0", + referencing_event_ids=["ev-file-read-1"], + effective_layers=layers, + ) + assert records + by_field = {item["field"]: item for item in records} + assert by_field["require_capability"]["effective_layer"] == "lean" + assert by_field["require_capability"]["section"] == "pre" + assert by_field["require_capability"]["normalized_value"] == "cap:file-read" + assert by_field["require_capability"]["lean_theorem"].startswith("concrete_contract_pre_") + assert by_field["require_trace_safe"]["effective_layer"] == "lean" + assert by_field["require_trace_safe"]["lean_theorem"].startswith("concrete_trace_satisfies_") + + +def test_projection_uses_semantics_layer_not_field_semantics(tmp_path: Path) -> None: + trace_path, trace = _prepare_case(tmp_path, selected_ids=["contract-file-read-v0"]) + evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="ContractCheckedCertificate", + ) + projection = build_semantic_projection( + trace, + certificate_mode="ContractCheckedCertificate", + resolved_evidence=evidence, + ) + validate_artifact(projection, "PFCoreSemanticProjection.v0") + contracts = projection_contracts(projection) + projected = contracts["contract-file-read-v0"] + assert "field_semantics" not in projected + assert isinstance(projected["semantics_layer"], list) + assert projected["semantics_layer"] + lean_fields = [ + item for item in projected["semantics_layer"] if item["effective_layer"] == "lean" + ] + assert lean_fields + assert all("lean_theorem" in item for item in lean_fields) + + +def test_contract_checked_requires_explicit_selection(tmp_path: Path) -> None: + trace_path, trace = _prepare_case(tmp_path, selected_ids=None) + with pytest.raises(EvidenceResolutionError, match="contract_ids"): + resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="ContractCheckedCertificate", + ) + + +def test_unrelated_sibling_not_auto_selected(tmp_path: Path) -> None: + trace_path, trace = _prepare_case( + tmp_path, + selected_ids=["contract-file-read-v0"], + include_unrelated_sibling=True, + ) + evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="ContractCheckedCertificate", + ) + assert evidence.selected_contract_ids == ("contract-file-read-v0",) + assert "contract-unrelated-sibling-v0" not in evidence.contracts_by_id + projection = build_semantic_projection( + trace, + certificate_mode="ContractCheckedCertificate", + resolved_evidence=evidence, + ) + assert projection_contract_ids(projection) == ["contract-file-read-v0"] + + +def test_unresolved_contract_ref_rejected(tmp_path: Path) -> None: + trace_path, trace = _prepare_case(tmp_path, selected_ids=["contract-file-read-v0"]) + for event in trace.get("events") or []: + if isinstance(event, dict): + event["contract_refs"] = ["contract-file-read-v0", "contract-missing-v0"] + _write_json(trace_path, trace) + with pytest.raises(EvidenceResolutionError, match="unresolved"): + resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="ContractCheckedCertificate", + ) + + +def test_certificate_binds_digests_and_theorems(tmp_path: Path) -> None: + trace_path, trace = _prepare_case(tmp_path, selected_ids=["contract-file-read-v0"]) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="ContractCheckedCertificate", + ) + evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="ContractCheckedCertificate", + ) + digests = contract_source_file_digests(evidence) + assert digests + theorem_names = collect_contract_theorem_names(generated.theorem_names) + assert theorem_names + assert any(name.startswith("concrete_contract_pre_") for name in theorem_names) + digest = compute_contract_evidence_digest( + selected_contract_ids=evidence.selected_contract_ids, + contract_source_file_digests=digests, + effective_layers=evidence.effective_contract_semantic_layers, + contract_theorem_names=theorem_names, + ) + assert digest.startswith("sha256:") + assert_contract_projection_ids( + selected_contract_ids=evidence.selected_contract_ids, + projected_contract_ids=projection_contract_ids(generated.semantic_projection or {}), + ) + + +def test_missing_selection_still_errors_in_codegen(tmp_path: Path) -> None: + work = tmp_path / "case" + work.mkdir() + trace = dict(_load(CONTRACT_TRACE)) + trace.pop("evidence_selection", None) + trace_path = work / "trace.json" + _write_json(trace_path, trace) + shutil.copy2(CONTRACT_JSON, work / "contract.json") + with pytest.raises(CertificateModeEvidenceMissing, match="contract_ids"): + generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="ContractCheckedCertificate", + ) + + +def test_cli_issuance_bundle_and_semantic_validation(tmp_path: Path) -> None: + if shutil.which("lake") is None: + pytest.skip("lake not available for full Lean execution path") + case = tmp_path / "case" + shutil.copytree(CONTRACT_FIXTURE, case) + trace_path = case / "trace.json" + out_cert = tmp_path / "PFCoreCertificate.v0.json" + result_out = tmp_path / "LeanCheckResult.v0.json" + proc = subprocess.run( + [ + sys.executable, + "-m", + "pcs_core.cli", + "pf-core", + "lean-check", + "--trace", + str(trace_path), + "--out", + str(out_cert), + "--result-out", + str(result_out), + "--certificate-mode", + "ContractCheckedCertificate", + "--allow-non-public-modes", + ], + cwd=REPO / "python", + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + assert out_cert.is_file() + cert = _load(out_cert) + validate_artifact(cert, "PFCoreCertificate.v0") + assert cert["certificate_mode"] == "ContractCheckedCertificate" + assert cert.get("lean_proof_checked") is True + assert cert["selected_contract_ids"] == ["contract-file-read-v0"] + assert cert["contract_source_file_digests"] + assert str(cert["contract_evidence_digest"]).startswith("sha256:") + assert cert["contract_theorem_names"] + result_payload = _load(result_out) + projection_path = Path(result_payload["artifact_paths"]["semantic_projection"]) + assert projection_path.is_file() + projection = _load(projection_path) + validate_artifact(projection, "PFCoreSemanticProjection.v0") + assert projection_contract_ids(projection) == ["contract-file-read-v0"] + for item in projection_contracts(projection)["contract-file-read-v0"]["semantics_layer"]: + assert "effective_layer" in item + assert "section" in item + assert "field" in item + assert "normalized_value" in item + + bundle_dir = tmp_path / "bundle" + bundle_release(trace_path, out_cert, bundle_dir, lean_check_result_path=result_out) + assert validate_bundle(bundle_dir).ok diff --git a/schemas/PFCoreSemanticProjection.v0.schema.json b/schemas/PFCoreSemanticProjection.v0.schema.json index e442d07..699c773 100644 --- a/schemas/PFCoreSemanticProjection.v0.schema.json +++ b/schemas/PFCoreSemanticProjection.v0.schema.json @@ -26,7 +26,8 @@ "HandoffSafeCertificate", "CompositionalExtensionCertificate", "ContractCheckedCertificate" - ] + ], + "description": "Issuance status: schemas/pf_core.certificate_mode_status.json" }, "events": { "type": "array", @@ -92,12 +93,22 @@ "type": "array", "items": { "type": "object", - "required": ["handoff_id", "from_principal", "to_principal"], + "required": [ + "handoff_id", + "from_principal", + "to_principal", + "delegated_capabilities" + ], "additionalProperties": false, "properties": { "handoff_id": { "type": "string", "minLength": 1 }, "from_principal": { "$ref": "#/$defs/principal" }, - "to_principal": { "$ref": "#/$defs/principal" } + "to_principal": { "$ref": "#/$defs/principal" }, + "delegated_capabilities": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/delegated_capability" } + } } } }, @@ -105,20 +116,80 @@ "type": "array", "items": { "type": "object", - "required": ["contract_id"], + "required": ["contract_id", "semantics_layer"], "additionalProperties": false, "properties": { "contract_id": { "type": "string", "minLength": 1 }, "pre": { "type": "object", "additionalProperties": true }, "post": { "type": "object", "additionalProperties": true }, "invariant": { "type": "object", "additionalProperties": true }, - "field_semantics": { "type": "object", "additionalProperties": true } + "semantics_layer": { + "type": "array", + "description": "Effective per-field semantic layer after defaults are applied", + "items": { "$ref": "#/$defs/semantics_layer_field" } + } + } + } + }, + "effect_frame": { + "type": "object", + "description": "Projected independent declared effect frame (v0: one global frame)", + "required": ["frame_id", "allowed_effect_kinds", "frame_scope_policy"], + "additionalProperties": false, + "properties": { + "frame_id": { "type": "string", "minLength": 1 }, + "allowed_effect_kinds": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "frame_scope_policy": { "type": "string", "const": "global" }, + "workflow_id": { "type": "string", "minLength": 1 }, + "contract_id": { "type": "string", "minLength": 1 }, + "source_policy_ref": { "type": "string", "minLength": 1 }, + "resource_constraints": { + "type": "array", + "items": { + "type": "object", + "required": ["effect_kind", "resource_pattern"], + "additionalProperties": false, + "properties": { + "effect_kind": { "type": "string", "minLength": 1 }, + "resource_pattern": { "type": "string", "minLength": 1 } + } + } } } }, + "initial_state": { + "$ref": "#/$defs/operational_state", + "description": "Projected initial operational state for FramePreservedCertificate" + }, + "transition_states": { + "type": "array", + "description": "Projected post-states after each event for FramePreservedCertificate", + "items": { "$ref": "#/$defs/operational_state" } + }, "projection_hash": { "$ref": "common.defs.json#/$defs/hex_digest" } }, "$defs": { + "operational_state": { + "type": "object", + "required": ["tenant", "active_principal", "resource_frame", "capability_frame"], + "additionalProperties": false, + "properties": { + "tenant": { "type": "string" }, + "active_principal": { "$ref": "#/$defs/principal" }, + "resource_frame": { + "type": "array", + "items": { "$ref": "#/$defs/resource" } + }, + "capability_frame": { + "type": "array", + "items": { "type": "string" } + } + } + }, "principal": { "type": "object", "required": ["principal_id", "tenant", "roles", "capabilities"], @@ -138,6 +209,43 @@ "uri": { "type": "string" }, "tenant": { "type": "string" } } + }, + "delegated_capability": { + "type": "object", + "required": ["capability_id", "effect_kind", "resource_pattern"], + "additionalProperties": false, + "properties": { + "capability_id": { "type": "string", "minLength": 1 }, + "effect_kind": { "type": "string", "minLength": 1 }, + "resource_pattern": { "type": "string", "minLength": 1 } + } + }, + "semantics_layer_field": { + "type": "object", + "required": ["section", "field", "normalized_value", "effective_layer"], + "additionalProperties": false, + "properties": { + "section": { + "type": "string", + "enum": ["pre", "post", "invariant"] + }, + "field": { "type": "string", "minLength": 1 }, + "normalized_value": { + "description": "Normalized active field value from pre/post/invariant", + "oneOf": [ + { "type": "string" }, + { "type": "boolean" }, + { "type": "null" } + ] + }, + "effective_layer": { + "type": "string", + "enum": ["lean", "runtime", "out_of_scope"] + }, + "lean_theorem": { "type": "string", "minLength": 1 }, + "runtime_check_id": { "type": "string", "minLength": 1 }, + "out_of_scope_rationale": { "type": "string", "minLength": 1 } + } } } } From 54dd7d51ea8410beae295f242fb1e8cf414b1f9c Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:00:39 -0700 Subject: [PATCH 06/24] Add PFCoreEffectFrame.v0 and effect-frame certificate evidence. Give EffectFrameCertificate a first-class frame artifact and negative fixtures so extra observed effects fail closed instead of being ignored. --- .../README.md | 4 + .../effect_frame.json | 21 ++ .../manifest.json | 7 + .../trace.json | 79 +++++ .../README.md | 10 +- .../effect_frame.json | 21 ++ .../trace.json | 14 +- lean/PFCore/ObservedEffect.lean | 181 +++++++++-- .../test_pf_core_effect_frame_evidence.py | 289 ++++++++++++++++++ schemas/PFCoreEffectFrame.v0.schema.json | 66 ++++ 10 files changed, 653 insertions(+), 39 deletions(-) create mode 100644 examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/README.md create mode 100644 examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/effect_frame.json create mode 100644 examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/manifest.json create mode 100644 examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/trace.json create mode 100644 examples/pf-core-valid/certificate_mode_effectframecertificate/effect_frame.json create mode 100644 python/tests/test_pf_core_effect_frame_evidence.py create mode 100644 schemas/PFCoreEffectFrame.v0.schema.json diff --git a/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/README.md b/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/README.md new file mode 100644 index 0000000..cfce966 --- /dev/null +++ b/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/README.md @@ -0,0 +1,4 @@ +# Invalid EffectFrameCertificate: extra undeclared effect + +Action declares `file.write` in addition to `file.read`; the independent +`PFCoreEffectFrame.v0` allows only `file.read`. Membership must fail. diff --git a/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/effect_frame.json b/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/effect_frame.json new file mode 100644 index 0000000..4fd3849 --- /dev/null +++ b/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/effect_frame.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "artifact_type": "PFCoreEffectFrame.v0", + "frame_id": "frame-file-read-only-v0", + "allowed_effect_kinds": [ + "file.read" + ], + "resource_constraints": [ + { + "effect_kind": "file.read", + "resource_pattern": "/data/*" + } + ], + "workflow_id": "agent_tool_use.safety_v0", + "frame_scope_policy": "global", + "source_policy_ref": "policy:agent_tool_use.safety_v0#effect-frame", + "source_repo": "https://github.com/example/agent-runtime", + "source_commit": "abc1234567890abc1234567890abc1234567890", + "evidence_refs": [], + "signature_or_digest": "sha256:f88d613f466d78deb05482c2d413dde67ecc8e509eb291fabd106ce7fa27f9f5" +} diff --git a/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/manifest.json b/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/manifest.json new file mode 100644 index 0000000..9b91fdf --- /dev/null +++ b/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/manifest.json @@ -0,0 +1,7 @@ +{ + "expected_error": "undeclared effect", + "must_fail_at": "lean_check", + "certificate_mode": "EffectFrameCertificate", + "artifact_file": "trace.json", + "artifact_type": "PFCoreTrace.v0" +} diff --git a/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/trace.json b/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/trace.json new file mode 100644 index 0000000..861b676 --- /dev/null +++ b/examples/pf-core-invalid/certificate_mode_effectframecertificate_extra_effect/trace.json @@ -0,0 +1,79 @@ +{ + "schema_version": "v0", + "artifact_type": "PFCoreTrace.v0", + "trace_id": "trace-effect-frame-extra-effect", + "workflow_id": "agent_tool_use.safety_v0", + "events": [ + { + "schema_version": "v0", + "artifact_type": "PFCoreEvent.v0", + "event_id": "ev-extra-effect-1", + "trace_id": "trace-effect-frame-extra-effect", + "sequence": 0, + "timestamp": "2026-06-18T00:00:00Z", + "principal": { + "principal_id": "agent-1", + "principal_kind": "agent", + "tenant": "tenant-a", + "roles": [ + "agent" + ], + "capabilities": [ + "cap:file-read", + "cap:email-send", + "cap:handoff", + "cap:mcp-invoke" + ] + }, + "action": { + "action_id": "act-1", + "tool_name": "filesystem.read", + "capability": { + "capability_id": "cap:file-read", + "effect_kind": "file.read", + "resource_pattern": "/data/*" + }, + "effects": [ + { + "effect_kind": "file.read" + }, + { + "effect_kind": "file.write" + } + ], + "reads": [ + { + "resource_id": "res-1", + "uri": "/data/report.txt", + "tenant": "tenant-a" + } + ], + "writes": [], + "input_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "output_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "decision": "allow", + "decision_reason": "authorized", + "contract_refs": [], + "evidence_refs": [], + "previous_event_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "source_repo": "https://github.com/example/agent-runtime", + "source_commit": "abc1234567890abc1234567890abc1234567890", + "event_hash": "sha256:0cd48d91a396223255fc91e583079516586f3eabbd9f4075c34afc5135bac048", + "signature_or_digest": "sha256:0cd48d91a396223255fc91e583079516586f3eabbd9f4075c34afc5135bac048" + } + ], + "policy_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "contract_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "claim_class": "RuntimeChecked", + "source_repo": "https://github.com/example/agent-runtime", + "source_commit": "abc1234567890abc1234567890abc1234567890", + "required_certificate_mode": "EffectFrameCertificate", + "evidence_selection": { + "policy": "explicit_ids", + "policy_version": "v0", + "effect_frame_id": "frame-file-read-only-v0" + }, + "trace_hash": "sha256:17c21da0fe61750edbb6e372afb8fe3d804734c6a18bd3f53034a4b68e399e56", + "signature_or_digest": "sha256:17c21da0fe61750edbb6e372afb8fe3d804734c6a18bd3f53034a4b68e399e56" +} diff --git a/examples/pf-core-valid/certificate_mode_effectframecertificate/README.md b/examples/pf-core-valid/certificate_mode_effectframecertificate/README.md index 6143ead..9cb4447 100644 --- a/examples/pf-core-valid/certificate_mode_effectframecertificate/README.md +++ b/examples/pf-core-valid/certificate_mode_effectframecertificate/README.md @@ -1,7 +1,9 @@ # Valid EffectFrameCertificate fixture -Valid PF-Core trace exercising **`EffectFrameCertificate`** certificate-mode codegen obligations. +Valid PF-Core trace exercising **`EffectFrameCertificate`** with an independently +declared `PFCoreEffectFrame.v0` (`effect_frame.json`). -Effect frame discharge for allow events. - -Regenerate via `python/scripts/gen_certificate_mode_fixtures.py` when certificate-mode obligations change. +v0 policy: one global frame (`frame_scope_policy: global`) bound via +`evidence_selection.effect_frame_id`. Lean obligations prove +`actionEffectsInFrameD concreteAction concreteDeclaredFrame = true` where +`concreteDeclaredFrame` is emitted from the frame artifact, not `action.effects`. diff --git a/examples/pf-core-valid/certificate_mode_effectframecertificate/effect_frame.json b/examples/pf-core-valid/certificate_mode_effectframecertificate/effect_frame.json new file mode 100644 index 0000000..0379d3e --- /dev/null +++ b/examples/pf-core-valid/certificate_mode_effectframecertificate/effect_frame.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "artifact_type": "PFCoreEffectFrame.v0", + "frame_id": "frame-file-read-global-v0", + "allowed_effect_kinds": [ + "file.read" + ], + "resource_constraints": [ + { + "effect_kind": "file.read", + "resource_pattern": "/data/*" + } + ], + "workflow_id": "agent_tool_use.safety_v0", + "frame_scope_policy": "global", + "source_policy_ref": "policy:agent_tool_use.safety_v0#effect-frame", + "source_repo": "https://github.com/example/agent-runtime", + "source_commit": "abc1234567890abc1234567890abc1234567890", + "evidence_refs": [], + "signature_or_digest": "sha256:c4f3e2379ddba473c0380e86ad055f092f182be0b9c959080bc373b766162e68" +} diff --git a/examples/pf-core-valid/certificate_mode_effectframecertificate/trace.json b/examples/pf-core-valid/certificate_mode_effectframecertificate/trace.json index 33859c0..a59d3d8 100644 --- a/examples/pf-core-valid/certificate_mode_effectframecertificate/trace.json +++ b/examples/pf-core-valid/certificate_mode_effectframecertificate/trace.json @@ -54,17 +54,23 @@ "contract_refs": [], "evidence_refs": [], "previous_event_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "event_hash": "sha256:4f54951a4b008bdb24f2bb88438cff876fadd84259ad6d83e8211980303a214b", "source_repo": "https://github.com/example/agent-runtime", "source_commit": "abc1234567890abc1234567890abc1234567890", - "signature_or_digest": "sha256:e6ae0e0c4c702dd1f83a6adb29a97e7d89b9741537b3ebd95bb476f754ea4960" + "event_hash": "sha256:4f54951a4b008bdb24f2bb88438cff876fadd84259ad6d83e8211980303a214b", + "signature_or_digest": "sha256:4f54951a4b008bdb24f2bb88438cff876fadd84259ad6d83e8211980303a214b" } ], - "trace_hash": "sha256:7c586dc277783547e76b3b78a5357cd4bb12e20627df11fa55e3f82c920ac6de", "policy_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "contract_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "claim_class": "RuntimeChecked", "source_repo": "https://github.com/example/agent-runtime", "source_commit": "abc1234567890abc1234567890abc1234567890", - "signature_or_digest": "sha256:bc26bbf4e65c1722cf2dd56723238ff13b72526ee6450d0bb9e4e54a4c3a4d30" + "required_certificate_mode": "EffectFrameCertificate", + "evidence_selection": { + "policy": "explicit_ids", + "policy_version": "v0", + "effect_frame_id": "frame-file-read-global-v0" + }, + "trace_hash": "sha256:1656ae88b02397bed988605261177f66b639997cebd16a9d78017578954e287d", + "signature_or_digest": "sha256:1656ae88b02397bed988605261177f66b639997cebd16a9d78017578954e287d" } diff --git a/lean/PFCore/ObservedEffect.lean b/lean/PFCore/ObservedEffect.lean index 3e19167..b0ee0db 100644 --- a/lean/PFCore/ObservedEffect.lean +++ b/lean/PFCore/ObservedEffect.lean @@ -7,12 +7,23 @@ import PFCore.Transition Declared action effects and effect frames constrain what a principal *may* do. This module models **observed** effects attributed to an execution step by -trusted runtime instrumentation or an external attestation. +runtime instrumentation or an external attestation. -**Trusted-boundary assumption:** Observation lists are faithful only when -`TrustedInstrumentation` (or an equivalent attestation) holds. PF-Core does -**not** prove that an untrusted producer emitted complete observations. See -`docs/pf-core/assumptions.md` and `docs/pf-core/runtime-semantics.md`. +## Claim separation (Workstream C1) + +| Predicate | Meaning | +|-----------|---------| +| `ObservationSoundness` | Every observation agrees with the declared action footprint | +| `ObservationCompleteness` | Every frame-sensitive *actual* effect appears in observations | +| `EffectAttribution` | Observations are attributed to the given action (declared footprint) | +| `InstrumentationAuthenticity` | TCB / attestation assumption (not proved from untrusted logs) | +| `AttestedExecution` | Relates actual runtime effects to observed effects under authenticity | +| `TrustedInstrumentation` | Full attested-execution relation — **not** mere `ObservationsAgree` | + +**Trusted-boundary assumption:** Observation faithfulness is only as strong as the +authenticity hypothesis. PF-Core does **not** prove that an untrusted producer +emitted complete observations. See `docs/pf-core/assumptions.md` and +`docs/pf-core/runtime-semantics.md`. -/ namespace PFCore @@ -74,14 +85,92 @@ theorem observationsAgreeD_sound (a : Action) (obs : List ObservedEffect) : simp [observationsAgreeD, ObservationsAgree, List.all_eq_true, observedEffectAgreesD_sound] /-- -**Assumption (trusted instrumentation / attestation):** Observations are a -faithful projection of actual effects for action `a`. This is **not** proved -from untrusted producer logs; it must be discharged by runtime TCB or -external attestation. +**Observation soundness:** Observed effects do not invent undeclared kinds/resources. +This is **not** trusted instrumentation by itself. +-/ +def ObservationSoundness (a : Action) (obs : List ObservedEffect) : Prop := + ObservationsAgree a obs + +/-- +**Observation completeness:** Every frame-sensitive actual runtime effect appears +in the observation list (kind + resource). Requires a separate actual-effect record; +not discharged from observation lists alone. -/ -def TrustedInstrumentation (a : Action) (obs : List ObservedEffect) : Prop := +def ObservationCompleteness + (actual : List ObservedEffect) (obs : List ObservedEffect) : Prop := + ∀ e ∈ actual, Effect.IsFrameSensitive e.kind → + ∃ o ∈ obs, o.kind = e.kind ∧ o.resource = e.resource + +/-- +**Effect attribution:** Observations are attributed to action `a` (declared footprint). +Distinct from completeness over actual runtime effects. +-/ +def EffectAttribution (a : Action) (obs : List ObservedEffect) : Prop := ObservationsAgree a obs +/-- +**Instrumentation authenticity:** Hypothesis discharged by runtime TCB or external +attestation. Boolean flag is an **assumption switch**, not a proved theorem. +-/ +def InstrumentationAuthenticity (authenticated : Bool) : Prop := + authenticated = true + +/-- +Context connecting a declared action, observed effects, claimed actual effects, and +an authenticity hypothesis for attested execution. +-/ +structure InstrumentationContext where + action : Action + observed : List ObservedEffect + actual : List ObservedEffect + /-- `true` only when TCB / attestation discharges authenticity. -/ + authenticated : Bool := false +deriving Repr + +/-- +**Attested execution relation:** actual runtime effects and observations agree under +soundness, completeness, attribution, and authenticity. +-/ +def AttestedExecution (ctx : InstrumentationContext) : Prop := + ObservationSoundness ctx.action ctx.observed ∧ + ObservationCompleteness ctx.actual ctx.observed ∧ + EffectAttribution ctx.action ctx.observed ∧ + InstrumentationAuthenticity ctx.authenticated + +/-- +**Trusted instrumentation** is the attested-execution relation. + +It is **definitionally distinct** from `ObservationsAgree` / `ObservationSoundness`. +Agreement alone never establishes trust. +-/ +def TrustedInstrumentation (ctx : InstrumentationContext) : Prop := + AttestedExecution ctx + +/-- Agreement / soundness is strictly weaker than trusted instrumentation. -/ +theorem trusted_instrumentation_implies_observation_soundness + (ctx : InstrumentationContext) + (h : TrustedInstrumentation ctx) : + ObservationSoundness ctx.action ctx.observed := + h.left + +theorem trusted_instrumentation_implies_observations_agree + (ctx : InstrumentationContext) + (h : TrustedInstrumentation ctx) : + ObservationsAgree ctx.action ctx.observed := + trusted_instrumentation_implies_observation_soundness ctx h + +/-- +Soundness alone does not imply authenticity. Counterexample shape: agreeing +observations with `authenticated = false` fail `TrustedInstrumentation`. +-/ +theorem observation_soundness_not_trusted_without_authenticity + (a : Action) (obs : List ObservedEffect) + (_hSound : ObservationSoundness a obs) : + ¬ TrustedInstrumentation + { action := a, observed := obs, actual := obs, authenticated := false } := by + intro hTrusted + exact Bool.false_ne_true hTrusted.right.right.right + /-- Instrumented operational step carrying observed effects. -/ structure InstrumentedTransition where pre : State @@ -96,62 +185,92 @@ def InstrumentedTransition.Accepted (it : InstrumentedTransition) : Prop := Applies it.event it.pre it.post ∧ EventSafe it.event +/-- Build an instrumentation context for an instrumented transition. -/ +def InstrumentedTransition.toInstrumentationContext + (it : InstrumentedTransition) + (actual : List ObservedEffect) + (authenticated : Bool) : InstrumentationContext := + { action := it.event.action + observed := it.observations + actual := actual + authenticated := authenticated } + /-- -**Meaning:** Under trusted instrumentation, every observed effect kind is declared +**Meaning:** Under observation soundness, every observed effect kind is declared on the action. **Trusted use:** Bridging observation lists to declared `Action.effects`. -**Does not imply:** Completeness of observations without `TrustedInstrumentation`. +**Does not imply:** Completeness or authenticity (`TrustedInstrumentation`). -/ -theorem trusted_instrumentation_kinds_declared +theorem observation_soundness_kinds_declared (a : Action) (obs : List ObservedEffect) - (hInstr : TrustedInstrumentation a obs) : + (hSound : ObservationSoundness a obs) : ∀ o ∈ obs, o.kind ∈ a.effects := by intro o hMem - exact (hInstr o hMem).left + exact (hSound o hMem).left + +/-- Compatibility name: kinds declared under soundness. -/ +theorem trusted_instrumentation_kinds_declared + (ctx : InstrumentationContext) + (hInstr : TrustedInstrumentation ctx) : + ∀ o ∈ ctx.observed, o.kind ∈ ctx.action.effects := + observation_soundness_kinds_declared ctx.action ctx.observed + (trusted_instrumentation_implies_observation_soundness ctx hInstr) /-- **Meaning:** Declared effects inside a frame imply observed sensitive kinds stay -in the frame when instrumentation is trusted. +in the frame when observations are sound wrt the declaration. -**Trusted use:** Primary Phase 5.1 undeclared-observation lemma for -write/network/message/codeExecution/stateChange. +**Trusted use:** Undeclared-observation lemma for write/network/message/codeExecution/stateChange. -**Does not imply:** Uninstrumented runs, covert channels, or deny-path closure. +**Does not imply:** Completeness, authenticity, covert channels, or deny-path closure. -/ theorem observed_sensitive_effects_in_frame (a : Action) (frame : List Effect) (obs : List ObservedEffect) - (hInstr : TrustedInstrumentation a obs) + (hSound : ObservationSoundness a obs) (hFrame : ActionEffectsInFrame a frame) : ∀ o ∈ obs, Effect.IsFrameSensitive o.kind → o.kind ∈ frame := by intro o hMem _hSens - exact hFrame o.kind (trusted_instrumentation_kinds_declared a obs hInstr o hMem) + exact hFrame o.kind (observation_soundness_kinds_declared a obs hSound o hMem) /-- **Meaning:** An accepted instrumented allow-transition cannot carry an observed -frame-sensitive effect absent from the declared effect frame, assuming trusted -instrumentation. +frame-sensitive effect absent from the declared effect frame, assuming observation +soundness (declared-footprint agreement). -**Trusted use:** Runtime attestation / instrumentation discharge for effect-frame -certificates. +**Trusted use:** Runtime attestation path should discharge full `TrustedInstrumentation`; +this lemma only needs soundness. -**Does not imply:** Observations without attestation, scheduler NI, or deny-path +**Does not imply:** Completeness without authenticity, scheduler NI, or deny-path side-effect freedom. -/ theorem accepted_transition_no_undeclared_sensitive_observation (it : InstrumentedTransition) (frame : List Effect) (_hAcc : InstrumentedTransition.Accepted it) - (hInstr : TrustedInstrumentation it.event.action it.observations) + (hSound : ObservationSoundness it.event.action it.observations) + (hFrame : ActionEffectsInFrame it.event.action frame) : + ∀ o ∈ it.observations, Effect.IsFrameSensitive o.kind → o.kind ∈ frame := + observed_sensitive_effects_in_frame it.event.action frame it.observations hSound hFrame + +/-- Attested-execution packaging of the undeclared-sensitive observation bound. -/ +theorem attested_execution_no_undeclared_sensitive_observation + (it : InstrumentedTransition) (frame : List Effect) (actual : List ObservedEffect) + (hAcc : InstrumentedTransition.Accepted it) + (hTrusted : TrustedInstrumentation + (it.toInstrumentationContext actual true)) (hFrame : ActionEffectsInFrame it.event.action frame) : ∀ o ∈ it.observations, Effect.IsFrameSensitive o.kind → o.kind ∈ frame := - observed_sensitive_effects_in_frame it.event.action frame it.observations hInstr hFrame + accepted_transition_no_undeclared_sensitive_observation it frame hAcc + (trusted_instrumentation_implies_observation_soundness + (it.toInstrumentationContext actual true) hTrusted) + hFrame -/-- Specialize: no observed write outside a write-free frame under instrumentation. -/ +/-- Specialize: no observed write outside a write-free frame under sound observations. -/ theorem accepted_transition_no_undeclared_write_observation (it : InstrumentedTransition) (frame : List Effect) (hAcc : InstrumentedTransition.Accepted it) - (hInstr : TrustedInstrumentation it.event.action it.observations) + (hSound : ObservationSoundness it.event.action it.observations) (hFrame : ActionEffectsInFrame it.event.action frame) (hNoWrite : Effect.write ∉ frame) : ∀ o ∈ it.observations, o.kind = Effect.write → False := by @@ -160,7 +279,7 @@ theorem accepted_transition_no_undeclared_write_observation have hsens : Effect.IsFrameSensitive o.kind := by simp [hWrite, Effect.IsFrameSensitive] have := accepted_transition_no_undeclared_sensitive_observation - it frame hAcc hInstr hFrame o hMem hsens + it frame hAcc hSound hFrame o hMem hsens simpa [hWrite] using this exact hNoWrite hin diff --git a/python/tests/test_pf_core_effect_frame_evidence.py b/python/tests/test_pf_core_effect_frame_evidence.py new file mode 100644 index 0000000..a85e45f --- /dev/null +++ b/python/tests/test_pf_core_effect_frame_evidence.py @@ -0,0 +1,289 @@ +"""PR4 effect-frame redesign: independent PFCoreEffectFrame.v0 + non-tautological proofs.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from typing import Any + +import pytest + +from pcs_core.pf_core_lean_codegen import ( + CertificateModeEvidenceMissing, + generate_proof_obligation_file, +) +from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + action_effects_in_declared_frame, + effect_frame_allowed_kinds, + effect_frame_source_digest, + resolve_pf_core_evidence, +) +from pcs_core.pf_core_semantic_projection import build_semantic_projection +from pcs_core.validate import validate_artifact + +REPO = Path(__file__).resolve().parents[2] +EFFECT_FRAME_FIXTURE = ( + REPO / "examples" / "pf-core-valid" / "certificate_mode_effectframecertificate" +) +EFFECT_FRAME_TRACE = EFFECT_FRAME_FIXTURE / "trace.json" +EFFECT_FRAME_JSON = EFFECT_FRAME_FIXTURE / "effect_frame.json" +ADVERSARIAL_FIXTURE = ( + REPO / "examples" / "pf-core-invalid" / "certificate_mode_effectframecertificate_extra_effect" +) + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, data: dict[str, Any]) -> None: + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def _prepare_case( + tmp_path: Path, + *, + effect_frame_id: str | None, + frame: dict[str, Any] | None = None, + mutate_action_effects: list[dict[str, str]] | None = None, +) -> tuple[Path, dict[str, Any]]: + work = tmp_path / "case" + work.mkdir(parents=True, exist_ok=True) + trace = dict(_load(EFFECT_FRAME_TRACE)) + if effect_frame_id is None: + trace.pop("evidence_selection", None) + else: + trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "effect_frame_id": effect_frame_id, + } + if mutate_action_effects is not None: + for event in trace.get("events") or []: + if isinstance(event, dict) and isinstance(event.get("action"), dict): + event["action"]["effects"] = deepcopy(mutate_action_effects) + from pcs_core.pf_core_runtime import compute_event_hash, compute_trace_hash + + events = trace.get("events") or [] + prev = "sha256:" + "0" * 64 + for event in events: + if not isinstance(event, dict): + continue + event["previous_event_hash"] = prev + event.pop("event_hash", None) + event.pop("signature_or_digest", None) + digest = compute_event_hash(event) + event["event_hash"] = digest + event["signature_or_digest"] = digest + prev = digest + trace.pop("trace_hash", None) + trace.pop("signature_or_digest", None) + trace["trace_hash"] = compute_trace_hash(trace) + trace["signature_or_digest"] = trace["trace_hash"] + trace_path = work / "trace.json" + _write_json(trace_path, trace) + body = dict(frame) if frame is not None else _load(EFFECT_FRAME_JSON) + _write_json(work / "effect_frame.json", body) + return trace_path, trace + + +def test_effect_frame_schema_validates() -> None: + frame = _load(EFFECT_FRAME_JSON) + validate_artifact(frame, "PFCoreEffectFrame.v0") + assert frame["frame_scope_policy"] == "global" + assert effect_frame_allowed_kinds(frame) == ["file.read"] + + +def test_effect_frame_requires_explicit_selection(tmp_path: Path) -> None: + trace_path, trace = _prepare_case(tmp_path, effect_frame_id=None) + with pytest.raises(EvidenceResolutionError, match="effect_frame_id"): + resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="EffectFrameCertificate", + ) + + +def test_resolved_evidence_binds_independent_frame(tmp_path: Path) -> None: + trace_path, trace = _prepare_case( + tmp_path, effect_frame_id="frame-file-read-global-v0" + ) + evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="EffectFrameCertificate", + ) + assert evidence.effect_frame is not None + assert evidence.effect_frame_path is not None + assert evidence.effect_frame_path.name == "effect_frame.json" + assert str(evidence.effect_frame.get("frame_id")) == "frame-file-read-global-v0" + digest = effect_frame_source_digest(evidence) + assert digest.startswith("sha256:") + # Independence: frame kinds come from the artifact, not by copying action.effects field. + action = (trace.get("events") or [{}])[0].get("action") or {} + assert evidence.effect_frame is not action + assert "effects" not in evidence.effect_frame + + +def test_codegen_uses_concrete_declared_frame_not_action_effects(tmp_path: Path) -> None: + trace_path, trace = _prepare_case( + tmp_path, effect_frame_id="frame-file-read-global-v0" + ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="EffectFrameCertificate", + ) + text = generated.path.read_text(encoding="utf-8") + assert "def concreteDeclaredFrame : List Effect :=" in text + assert "actionEffectsInFrameD" in text + assert "concreteDeclaredFrame = true" in text + assert ".effects = true" not in text + assert "import PFCore.EffectFrame" in text + projection = generated.semantic_projection or {} + assert "effect_frame" in projection + assert projection["effect_frame"]["frame_id"] == "frame-file-read-global-v0" + assert projection["effect_frame"]["frame_scope_policy"] == "global" + + +def test_projection_includes_effect_frame(tmp_path: Path) -> None: + trace_path, trace = _prepare_case( + tmp_path, effect_frame_id="frame-file-read-global-v0" + ) + evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="EffectFrameCertificate", + ) + projection = build_semantic_projection( + trace, + certificate_mode="EffectFrameCertificate", + resolved_evidence=evidence, + ) + validate_artifact(projection, "PFCoreSemanticProjection.v0") + frame = projection["effect_frame"] + assert frame["allowed_effect_kinds"] == ["file.read"] + assert frame["source_policy_ref"].startswith("policy:") + + +def test_adversarial_extra_effect_omitted_from_frame_fails(tmp_path: Path) -> None: + """Action with an extra effect not listed in the declared frame must fail.""" + frame = _load(EFFECT_FRAME_JSON) + # Frame permits only file.read. + assert frame["allowed_effect_kinds"] == ["file.read"] + trace_path, trace = _prepare_case( + tmp_path, + effect_frame_id="frame-file-read-global-v0", + frame=frame, + mutate_action_effects=[ + {"effect_kind": "file.read"}, + {"effect_kind": "file.write"}, + ], + ) + action = (trace.get("events") or [{}])[0].get("action") or {} + assert not action_effects_in_declared_frame(action, frame) + with pytest.raises(EvidenceResolutionError, match="undeclared effect"): + resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="EffectFrameCertificate", + ) + with pytest.raises(CertificateModeEvidenceMissing, match="undeclared effect|effect_frame"): + generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="EffectFrameCertificate", + ) + + +def test_adversarial_fixture_directory_fails_resolution() -> None: + trace_path = ADVERSARIAL_FIXTURE / "trace.json" + trace = _load(trace_path) + with pytest.raises(EvidenceResolutionError, match="undeclared effect"): + resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="EffectFrameCertificate", + ) + + +def test_missing_selection_still_errors_in_codegen(tmp_path: Path) -> None: + work = tmp_path / "case" + work.mkdir() + trace = dict(_load(EFFECT_FRAME_TRACE)) + trace.pop("evidence_selection", None) + trace_path = work / "trace.json" + _write_json(trace_path, trace) + shutil.copy2(EFFECT_FRAME_JSON, work / "effect_frame.json") + with pytest.raises(CertificateModeEvidenceMissing, match="effect_frame_id"): + generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="EffectFrameCertificate", + ) + + +def test_public_issuance_still_disabled_without_allow_flag(tmp_path: Path) -> None: + from pcs_core.lean_check import run_pfcore_lean_check + + case = tmp_path / "case" + shutil.copytree(EFFECT_FRAME_FIXTURE, case) + code, result = run_pfcore_lean_check( + case / "trace.json", + certificate_mode="EffectFrameCertificate", + skip_build=True, + allow_non_public_modes=False, + ) + assert code != 0 + codes = [issue.get("code") for issue in result.get("issues", [])] + assert "CertificateModeIssuanceDenied" in codes + + +def test_cli_issuance_with_allow_non_public_modes(tmp_path: Path) -> None: + if shutil.which("lake") is None: + pytest.skip("lake not available for full Lean execution path") + case = tmp_path / "case" + shutil.copytree(EFFECT_FRAME_FIXTURE, case) + out_cert = tmp_path / "PFCoreCertificate.v0.json" + result_out = tmp_path / "LeanCheckResult.v0.json" + proc = subprocess.run( + [ + sys.executable, + "-m", + "pcs_core.cli", + "pf-core", + "lean-check", + "--trace", + str(case / "trace.json"), + "--out", + str(out_cert), + "--result-out", + str(result_out), + "--certificate-mode", + "EffectFrameCertificate", + "--allow-non-public-modes", + ], + cwd=REPO / "python", + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + assert out_cert.is_file() + cert = _load(out_cert) + validate_artifact(cert, "PFCoreCertificate.v0") + assert cert["certificate_mode"] == "EffectFrameCertificate" + assert cert.get("lean_proof_checked") is True + assert cert["effect_frame_id"] == "frame-file-read-global-v0" + assert cert["effect_frame_path"] + assert str(cert["effect_frame_digest"]).startswith("sha256:") + # Certificate records the independent frame path/digest (not action.effects). + assert "effect_frame.json" in cert["effect_frame_path"].replace("\\", "/") diff --git a/schemas/PFCoreEffectFrame.v0.schema.json b/schemas/PFCoreEffectFrame.v0.schema.json new file mode 100644 index 0000000..d331a9a --- /dev/null +++ b/schemas/PFCoreEffectFrame.v0.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/PFCoreEffectFrame.v0.schema.json", + "title": "PFCoreEffectFrame.v0", + "description": "Independently declared effect frame for EffectFrameCertificate. Allowed effects are NOT derived from action.effects. v0 multi-event policy: one global frame binds every selected event (per-event/monotone frames deferred).", + "type": "object", + "required": [ + "schema_version", + "artifact_type", + "frame_id", + "allowed_effect_kinds", + "frame_scope_policy", + "source_policy_ref", + "signature_or_digest" + ], + "additionalProperties": false, + "properties": { + "schema_version": { "$ref": "common.defs.json#/$defs/schema_version" }, + "artifact_type": { "const": "PFCoreEffectFrame.v0" }, + "frame_id": { "type": "string", "minLength": 1 }, + "allowed_effect_kinds": { + "type": "array", + "minItems": 1, + "items": { "$ref": "pf_core.defs.json#/$defs/effect_kind" }, + "description": "Closed set of effect kinds permitted by this declared frame (independent of any action.effects list)" + }, + "resource_constraints": { + "type": "array", + "description": "Optional resource-pattern constraints scoped to an allowed effect kind", + "items": { + "type": "object", + "required": ["effect_kind", "resource_pattern"], + "additionalProperties": false, + "properties": { + "effect_kind": { "$ref": "pf_core.defs.json#/$defs/effect_kind" }, + "resource_pattern": { "type": "string", "minLength": 1 } + } + } + }, + "workflow_id": { + "type": "string", + "minLength": 1, + "description": "Optional workflow scope for this frame" + }, + "contract_id": { + "type": "string", + "minLength": 1, + "description": "Optional contract scope for this frame" + }, + "frame_scope_policy": { + "type": "string", + "const": "global", + "description": "v0 policy: one global frame applies to all selected events in the trace" + }, + "source_policy_ref": { + "type": "string", + "minLength": 1, + "description": "Reference to the source policy that declared this frame" + }, + "source_repo": { "type": "string", "format": "uri" }, + "source_commit": { "type": "string", "minLength": 7 }, + "created_at": { "type": "string", "format": "date-time" }, + "evidence_refs": { "$ref": "common.defs.json#/$defs/ref_list" }, + "signature_or_digest": { "$ref": "common.defs.json#/$defs/hex_digest" } + } +} From f659189403986d10342aa2bc63ce37eeafb0a7ab Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:00:47 -0700 Subject: [PATCH 07/24] Enforce FramePreserved stepState transition evidence. Pin frame-preserved traces to explicit stepState transitions and reject cross-tenant noop cases that would otherwise look vacuously safe. --- .../README.md | 5 + .../manifest.json | 5 + .../trace.json | 125 +++++++++ .../README.md | 8 +- .../manifest.json | 3 +- .../trace.json | 69 ++++- .../pf-core-valid/contract_checked/trace.json | 12 +- python/pcs_core/pf_core_runtime.py | 9 +- .../tests/test_pf_core_transition_evidence.py | 263 ++++++++++++++++++ schemas/PFCoreTrace.v0.schema.json | 22 ++ 10 files changed, 505 insertions(+), 16 deletions(-) create mode 100644 examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/README.md create mode 100644 examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/manifest.json create mode 100644 examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/trace.json create mode 100644 python/tests/test_pf_core_transition_evidence.py diff --git a/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/README.md b/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/README.md new file mode 100644 index 0000000..a3ff826 --- /dev/null +++ b/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/README.md @@ -0,0 +1,5 @@ +# Invalid FramePreservedCertificate cross-tenant no-op + +Sequential allow events on different tenants. Under `applyEvent`, the second allow +would silently leave state unchanged (`stepState` returns `none`). Remediated +`FramePreservedCertificate` rejects this path and requires `stepState = some post`. diff --git a/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/manifest.json b/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/manifest.json new file mode 100644 index 0000000..7ed8984 --- /dev/null +++ b/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/manifest.json @@ -0,0 +1,5 @@ +{ + "expected_error": "stepState failed", + "expected_error_alt": "applyEvent no-op rejected", + "certificate_mode": "FramePreservedCertificate" +} diff --git a/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/trace.json b/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/trace.json new file mode 100644 index 0000000..f489c6d --- /dev/null +++ b/examples/pf-core-invalid/certificate_mode_framepreservedcertificate_cross_tenant_noop/trace.json @@ -0,0 +1,125 @@ +{ + "schema_version": "v0", + "artifact_type": "PFCoreTrace.v0", + "trace_id": "trace-frame-preserved-cross-tenant-noop-1", + "workflow_id": "agent_tool_use.safety_v0", + "events": [ + { + "schema_version": "v0", + "artifact_type": "PFCoreEvent.v0", + "event_id": "ev-tenant-a-1", + "trace_id": "trace-frame-preserved-cross-tenant-noop-1", + "sequence": 0, + "timestamp": "2026-06-18T00:00:00Z", + "principal": { + "principal_id": "agent-1", + "principal_kind": "agent", + "tenant": "tenant-a", + "roles": [ + "agent" + ], + "capabilities": [ + "cap:file-read", + "cap:email-send", + "cap:handoff", + "cap:mcp-invoke" + ] + }, + "action": { + "action_id": "act-a-1", + "tool_name": "filesystem.read", + "capability": { + "capability_id": "cap:file-read", + "effect_kind": "file.read", + "resource_pattern": "/data/*" + }, + "effects": [ + { + "effect_kind": "file.read" + } + ], + "reads": [ + { + "resource_id": "res-1", + "uri": "/data/a.txt", + "tenant": "tenant-a" + } + ], + "writes": [], + "input_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "output_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "decision": "allow", + "decision_reason": "authorized", + "contract_refs": [], + "evidence_refs": [], + "previous_event_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "source_repo": "https://github.com/example/agent-runtime", + "source_commit": "abc1234567890abc1234567890abc1234567890", + "event_hash": "sha256:4cb030bae3a5c954a793b711b38f64ad8721e94304cbce807ef710ef29b4d60f", + "signature_or_digest": "sha256:4cb030bae3a5c954a793b711b38f64ad8721e94304cbce807ef710ef29b4d60f" + }, + { + "schema_version": "v0", + "artifact_type": "PFCoreEvent.v0", + "event_id": "ev-tenant-b-1", + "trace_id": "trace-frame-preserved-cross-tenant-noop-1", + "sequence": 1, + "timestamp": "2026-06-18T00:00:00Z", + "principal": { + "principal_id": "agent-2", + "principal_kind": "agent", + "tenant": "tenant-b", + "roles": [ + "agent" + ], + "capabilities": [ + "cap:file-read", + "cap:email-send", + "cap:handoff", + "cap:mcp-invoke" + ] + }, + "action": { + "action_id": "act-b-1", + "tool_name": "filesystem.read", + "capability": { + "capability_id": "cap:file-read", + "effect_kind": "file.read", + "resource_pattern": "/data/*" + }, + "effects": [ + { + "effect_kind": "file.read" + } + ], + "reads": [ + { + "resource_id": "res-b-1", + "uri": "/data/b.txt", + "tenant": "tenant-b" + } + ], + "writes": [], + "input_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "output_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "decision": "allow", + "decision_reason": "authorized", + "contract_refs": [], + "evidence_refs": [], + "previous_event_hash": "sha256:4cb030bae3a5c954a793b711b38f64ad8721e94304cbce807ef710ef29b4d60f", + "source_repo": "https://github.com/example/agent-runtime", + "source_commit": "abc1234567890abc1234567890abc1234567890", + "event_hash": "sha256:32dfeb25da315f658c381bc0d0f1df1b1d5a9ed88de482cb87e226e74dc9a391", + "signature_or_digest": "sha256:32dfeb25da315f658c381bc0d0f1df1b1d5a9ed88de482cb87e226e74dc9a391" + } + ], + "policy_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "contract_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "claim_class": "RuntimeChecked", + "source_repo": "https://github.com/example/agent-runtime", + "source_commit": "abc1234567890abc1234567890abc1234567890", + "trace_hash": "sha256:76a2ebd3fab23b3e7f642e7a81ea03e79115cc9aec490b9bc6ba3480b7a85a18", + "signature_or_digest": "sha256:76a2ebd3fab23b3e7f642e7a81ea03e79115cc9aec490b9bc6ba3480b7a85a18" +} diff --git a/examples/pf-core-valid/certificate_mode_framepreservedcertificate/README.md b/examples/pf-core-valid/certificate_mode_framepreservedcertificate/README.md index 362e37c..373b2a3 100644 --- a/examples/pf-core-valid/certificate_mode_framepreservedcertificate/README.md +++ b/examples/pf-core-valid/certificate_mode_framepreservedcertificate/README.md @@ -1,7 +1,9 @@ # Valid FramePreservedCertificate fixture -Valid PF-Core trace exercising **`FramePreservedCertificate`** certificate-mode codegen obligations. +Valid PF-Core trace exercising **`FramePreservedCertificate`** transition witnesses. -Effect-frame preservation obligations. +Obligations prove `stepState pre event = some post` for the allow event, deny identity +for the deny event, `frameValidD` at every post-state, and resource / active-principal / +tenant / capability-frame update equalities. Codegen does not use `applyEvent` fallbacks. -Regenerate via `python/scripts/gen_certificate_mode_fixtures.py` when certificate-mode obligations change. +Public issuance remains disabled; use `--allow-non-public-modes` for lean-check. diff --git a/examples/pf-core-valid/certificate_mode_framepreservedcertificate/manifest.json b/examples/pf-core-valid/certificate_mode_framepreservedcertificate/manifest.json index 0b0e79a..668df44 100644 --- a/examples/pf-core-valid/certificate_mode_framepreservedcertificate/manifest.json +++ b/examples/pf-core-valid/certificate_mode_framepreservedcertificate/manifest.json @@ -1,4 +1,3 @@ { - "certificate_mode": "FramePreservedCertificate", - "valid": true + "certificate_mode": "FramePreservedCertificate" } diff --git a/examples/pf-core-valid/certificate_mode_framepreservedcertificate/trace.json b/examples/pf-core-valid/certificate_mode_framepreservedcertificate/trace.json index 33859c0..5f8d554 100644 --- a/examples/pf-core-valid/certificate_mode_framepreservedcertificate/trace.json +++ b/examples/pf-core-valid/certificate_mode_framepreservedcertificate/trace.json @@ -1,14 +1,14 @@ { "schema_version": "v0", "artifact_type": "PFCoreTrace.v0", - "trace_id": "trace-file-read-1", + "trace_id": "trace-frame-preserved-allow-deny-1", "workflow_id": "agent_tool_use.safety_v0", "events": [ { "schema_version": "v0", "artifact_type": "PFCoreEvent.v0", - "event_id": "ev-file-read-1", - "trace_id": "trace-file-read-1", + "event_id": "ev-allow-1", + "trace_id": "trace-frame-preserved-allow-deny-1", "sequence": 0, "timestamp": "2026-06-18T00:00:00Z", "principal": { @@ -54,17 +54,72 @@ "contract_refs": [], "evidence_refs": [], "previous_event_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "event_hash": "sha256:4f54951a4b008bdb24f2bb88438cff876fadd84259ad6d83e8211980303a214b", "source_repo": "https://github.com/example/agent-runtime", "source_commit": "abc1234567890abc1234567890abc1234567890", - "signature_or_digest": "sha256:e6ae0e0c4c702dd1f83a6adb29a97e7d89b9741537b3ebd95bb476f754ea4960" + "event_hash": "sha256:db6074250db7c089ad99d8a0626c5ef079e9a1412d82f3dc66bae2fd55dadb10", + "signature_or_digest": "sha256:db6074250db7c089ad99d8a0626c5ef079e9a1412d82f3dc66bae2fd55dadb10" + }, + { + "schema_version": "v0", + "artifact_type": "PFCoreEvent.v0", + "event_id": "ev-deny-1", + "trace_id": "trace-frame-preserved-allow-deny-1", + "sequence": 1, + "timestamp": "2026-06-18T00:00:00Z", + "principal": { + "principal_id": "agent-1", + "principal_kind": "agent", + "tenant": "tenant-a", + "roles": [ + "agent" + ], + "capabilities": [ + "cap:file-read", + "cap:email-send", + "cap:handoff", + "cap:mcp-invoke" + ] + }, + "action": { + "action_id": "act-deny-1", + "tool_name": "filesystem.read", + "capability": { + "capability_id": "cap:file-read", + "effect_kind": "file.read", + "resource_pattern": "/data/*" + }, + "effects": [ + { + "effect_kind": "file.read" + } + ], + "reads": [ + { + "resource_id": "res-1", + "uri": "/data/report.txt", + "tenant": "tenant-a" + } + ], + "writes": [], + "input_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "output_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "decision": "deny", + "decision_reason": "policy_denied", + "contract_refs": [], + "evidence_refs": [], + "previous_event_hash": "sha256:db6074250db7c089ad99d8a0626c5ef079e9a1412d82f3dc66bae2fd55dadb10", + "source_repo": "https://github.com/example/agent-runtime", + "source_commit": "abc1234567890abc1234567890abc1234567890", + "event_hash": "sha256:503fc61f207c1d446fca17744743b2069642a3871015b74a4122fb1512c0b66c", + "signature_or_digest": "sha256:503fc61f207c1d446fca17744743b2069642a3871015b74a4122fb1512c0b66c" } ], - "trace_hash": "sha256:7c586dc277783547e76b3b78a5357cd4bb12e20627df11fa55e3f82c920ac6de", "policy_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "contract_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "claim_class": "RuntimeChecked", "source_repo": "https://github.com/example/agent-runtime", "source_commit": "abc1234567890abc1234567890abc1234567890", - "signature_or_digest": "sha256:bc26bbf4e65c1722cf2dd56723238ff13b72526ee6450d0bb9e4e54a4c3a4d30" + "trace_hash": "sha256:04d8a1b06d1f29002c63bca380197fdd4f584bf456caee01aa5bf4b47524aaf7", + "signature_or_digest": "sha256:04d8a1b06d1f29002c63bca380197fdd4f584bf456caee01aa5bf4b47524aaf7" } diff --git a/examples/pf-core-valid/contract_checked/trace.json b/examples/pf-core-valid/contract_checked/trace.json index 1063324..0aa1287 100644 --- a/examples/pf-core-valid/contract_checked/trace.json +++ b/examples/pf-core-valid/contract_checked/trace.json @@ -67,6 +67,14 @@ "claim_class": "RuntimeChecked", "source_repo": "https://github.com/example/agent-runtime", "source_commit": "abc1234567890abc1234567890abc1234567890", - "trace_hash": "sha256:fddb2a2f9ac45d4b7c7a4e8831080b8d9cfe9d3b9b2574871e45ee6616c8318f", - "signature_or_digest": "sha256:fddb2a2f9ac45d4b7c7a4e8831080b8d9cfe9d3b9b2574871e45ee6616c8318f" + "required_certificate_mode": "ContractCheckedCertificate", + "evidence_selection": { + "policy": "explicit_ids", + "policy_version": "v0", + "contract_ids": [ + "contract-file-read-v0" + ] + }, + "trace_hash": "sha256:e92959a46d789c24f5d2a29ce4cbc459635798b9be8757bf139ea35442c15fcb", + "signature_or_digest": "sha256:e92959a46d789c24f5d2a29ce4cbc459635798b9be8757bf139ea35442c15fcb" } diff --git a/python/pcs_core/pf_core_runtime.py b/python/pcs_core/pf_core_runtime.py index 4b84153..4c9ed13 100644 --- a/python/pcs_core/pf_core_runtime.py +++ b/python/pcs_core/pf_core_runtime.py @@ -645,11 +645,16 @@ def validate_observed_effects_agree( action: Mapping[str, Any], observations: list[Mapping[str, Any]], ) -> list[str]: - """Mirror ``ObservationsAgree`` / ``TrustedInstrumentation`` for one action. + """Mirror ``ObservationsAgree`` / ``ObservationSoundness`` for one action. Each observation must declare ``kind`` (or ``effect_kind``) present in the action's declared effects. Optional ``resource.uri`` must appear in reads or - writes. Callers must separately attest instrumentation faithfulness. + writes. + + This is **not** ``TrustedInstrumentation``. Lean ``TrustedInstrumentation`` is + the attested-execution relation (soundness + completeness + attribution + + authenticity). Callers must separately attest instrumentation authenticity + before claiming trust. """ declared = set(_action_effect_kinds(action)) reads = action.get("reads") if isinstance(action.get("reads"), list) else [] diff --git a/python/tests/test_pf_core_transition_evidence.py b/python/tests/test_pf_core_transition_evidence.py new file mode 100644 index 0000000..1f4c2e2 --- /dev/null +++ b/python/tests/test_pf_core_transition_evidence.py @@ -0,0 +1,263 @@ +"""PR5 transition-certificate redesign: stepState witnesses + cross-tenant reject.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from typing import Any + +import pytest + +from pcs_core.pf_core_lean_codegen import ( + CertificateModeEvidenceMissing, + generate_proof_obligation_file, +) +from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + resolve_pf_core_evidence, + simulate_frame_preserved_transitions, + step_state, + transition_chain_digest, +) +from pcs_core.pf_core_runtime import compute_event_hash, compute_trace_hash +from pcs_core.pf_core_semantic_projection import build_semantic_projection +from pcs_core.validate import validate_artifact + +REPO = Path(__file__).resolve().parents[2] +VALID_FIXTURE = ( + REPO / "examples" / "pf-core-valid" / "certificate_mode_framepreservedcertificate" +) +VALID_TRACE = VALID_FIXTURE / "trace.json" +CROSS_TENANT_NOOP = ( + REPO + / "examples" + / "pf-core-invalid" + / "certificate_mode_framepreservedcertificate_cross_tenant_noop" +) + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, data: dict[str, Any]) -> None: + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def _finalize_trace(trace: dict[str, Any]) -> dict[str, Any]: + events = trace.get("events") or [] + prev = "sha256:" + "0" * 64 + for event in events: + if not isinstance(event, dict): + continue + event["previous_event_hash"] = prev + event.pop("event_hash", None) + event.pop("signature_or_digest", None) + digest = compute_event_hash(event) + event["event_hash"] = digest + event["signature_or_digest"] = digest + prev = digest + trace.pop("trace_hash", None) + trace.pop("signature_or_digest", None) + trace["trace_hash"] = compute_trace_hash(trace) + trace["signature_or_digest"] = trace["trace_hash"] + return trace + + +def test_resolved_evidence_binds_transition_states() -> None: + trace = _load(VALID_TRACE) + evidence = resolve_pf_core_evidence( + trace, + trace_path=VALID_TRACE, + certificate_mode="FramePreservedCertificate", + ) + assert evidence.initial_state is not None + assert len(evidence.transition_states) == 2 + assert evidence.initial_state["tenant"] == "tenant-a" + assert evidence.transition_states[0]["resource_frame"] + # Deny is identity relative to the post-allow state. + assert evidence.transition_states[1] == evidence.transition_states[0] + digest = transition_chain_digest(evidence) + assert digest.startswith("sha256:") + + +def test_codegen_emits_step_state_witnesses_not_apply_event(tmp_path: Path) -> None: + generated = generate_proof_obligation_file( + _load(VALID_TRACE), + tmp_path / "out", + trace_path=VALID_TRACE, + certificate_mode="FramePreservedCertificate", + ) + text = generated.path.read_text(encoding="utf-8") + assert "import PFCore.Transition" in text + assert "stepState" in text + assert "step_state_applies_" in text + assert "resource_frame_update_" in text + assert "active_principal_update_" in text + assert "tenant_update_" in text + assert "capability_frame_update_" in text + assert "deny_identity_" in text + assert "frame_valid_after_" in text + assert "expandResourceFrame" in text + # No applyEvent expressions in obligations (docstring may mention the ban). + assert "frameValidD (applyEvent" not in text + assert ":= applyEvent" not in text + assert "frame_preserved_steps" in text + projection = generated.semantic_projection or {} + assert "initial_state" in projection + assert "transition_states" in projection + assert len(projection["transition_states"]) == 2 + + +def test_projection_includes_operational_states() -> None: + evidence = resolve_pf_core_evidence( + _load(VALID_TRACE), + trace_path=VALID_TRACE, + certificate_mode="FramePreservedCertificate", + ) + projection = build_semantic_projection( + _load(VALID_TRACE), + certificate_mode="FramePreservedCertificate", + resolved_evidence=evidence, + ) + validate_artifact(projection, "PFCoreSemanticProjection.v0") + assert projection["initial_state"]["tenant"] == "tenant-a" + assert len(projection["transition_states"]) == 2 + + +def test_sequential_cross_tenant_allow_rejected_as_noop() -> None: + """Legacy applyEvent would no-op the second allow; remediated mode must reject.""" + trace_path = CROSS_TENANT_NOOP / "trace.json" + trace = _load(trace_path) + events = [event for event in (trace.get("events") or []) if isinstance(event, dict)] + assert len(events) == 2 + assert events[0]["principal"]["tenant"] != events[1]["principal"]["tenant"] + + initial, posts = simulate_frame_preserved_transitions(events[:1]) + # After first allow, second allow returns none (the silent applyEvent fallback case). + assert step_state(posts[0], events[1]) is None + + with pytest.raises(EvidenceResolutionError, match="stepState failed|no-op"): + resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="FramePreservedCertificate", + ) + with pytest.raises(CertificateModeEvidenceMissing, match="stepState failed|no-op"): + generate_proof_obligation_file( + trace, + Path(trace_path).parent / "_out_should_fail", + trace_path=trace_path, + certificate_mode="FramePreservedCertificate", + ) + + +def test_cross_tenant_fixture_directory_fails_resolution() -> None: + trace_path = CROSS_TENANT_NOOP / "trace.json" + with pytest.raises(EvidenceResolutionError, match="stepState failed"): + resolve_pf_core_evidence( + _load(trace_path), + trace_path=trace_path, + certificate_mode="FramePreservedCertificate", + ) + + +def test_public_issuance_still_disabled_without_allow_flag(tmp_path: Path) -> None: + from pcs_core.lean_check import run_pfcore_lean_check + + case = tmp_path / "case" + shutil.copytree(VALID_FIXTURE, case) + code, result = run_pfcore_lean_check( + case / "trace.json", + certificate_mode="FramePreservedCertificate", + skip_build=True, + allow_non_public_modes=False, + ) + assert code != 0 + codes = [issue.get("code") for issue in result.get("issues", [])] + assert "CertificateModeIssuanceDenied" in codes + + +def test_cli_issuance_with_allow_non_public_modes(tmp_path: Path) -> None: + if shutil.which("lake") is None: + pytest.skip("lake not available for full Lean execution path") + case = tmp_path / "case" + shutil.copytree(VALID_FIXTURE, case) + out_cert = tmp_path / "PFCoreCertificate.v0.json" + result_out = tmp_path / "LeanCheckResult.v0.json" + proc = subprocess.run( + [ + sys.executable, + "-m", + "pcs_core.cli", + "pf-core", + "lean-check", + "--trace", + str(case / "trace.json"), + "--out", + str(out_cert), + "--result-out", + str(result_out), + "--certificate-mode", + "FramePreservedCertificate", + "--allow-non-public-modes", + ], + cwd=REPO / "python", + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + assert out_cert.is_file() + cert = _load(out_cert) + validate_artifact(cert, "PFCoreCertificate.v0") + assert cert["certificate_mode"] == "FramePreservedCertificate" + assert cert.get("lean_proof_checked") is True + assert str(cert.get("transition_chain_digest") or "").startswith("sha256:") + assert cert.get("transition_event_count") == 2 + + +def test_same_tenant_sequential_allows_succeed(tmp_path: Path) -> None: + work = tmp_path / "case" + work.mkdir() + trace = deepcopy(_load(VALID_TRACE)) + first = trace["events"][0] + second = deepcopy(first) + second["event_id"] = "ev-allow-2" + second["sequence"] = 1 + second["decision"] = "allow" + second["action"] = deepcopy(first["action"]) + second["action"]["action_id"] = "act-allow-2" + second["action"]["reads"] = [ + { + "resource_id": "res-2", + "uri": "/data/report-2.txt", + "tenant": "tenant-a", + } + ] + # Drop the deny from the fixture; two same-tenant allows. + trace["events"] = [first, second] + trace = _finalize_trace(trace) + trace_path = work / "trace.json" + _write_json(trace_path, trace) + evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode="FramePreservedCertificate", + ) + assert len(evidence.transition_states) == 2 + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="FramePreservedCertificate", + ) + text = generated.path.read_text(encoding="utf-8") + assert text.count("step_state_applies_") >= 2 + assert "deny_identity_" not in text + assert "frameValidD (applyEvent" not in text + assert ":= applyEvent" not in text diff --git a/schemas/PFCoreTrace.v0.schema.json b/schemas/PFCoreTrace.v0.schema.json index 4c1279c..87a2abd 100644 --- a/schemas/PFCoreTrace.v0.schema.json +++ b/schemas/PFCoreTrace.v0.schema.json @@ -35,6 +35,28 @@ "source_commit": { "type": "string", "minLength": 7 }, "local_dev": { "type": "boolean" }, "required_certificate_mode": { "$ref": "pf_core.defs.json#/$defs/certificate_mode" }, + "evidence_selection": { + "type": "object", + "description": "Explicit evidence binding; handoff_ids for HandoffSafeCertificate; contract_ids for ContractCheckedCertificate; effect_frame_id for EffectFrameCertificate (v0: one global frame)", + "additionalProperties": false, + "properties": { + "policy": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "handoff_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "contract_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "effect_frame_id": { + "type": "string", + "minLength": 1, + "description": "Independent PFCoreEffectFrame.v0 frame_id (v0: one global frame per trace)" + } + } + }, "signature_or_digest": { "$ref": "common.defs.json#/$defs/hex_digest" } } } From 7d84089aa3c5cdd922a10b8a3d0fb67cc45c3d31 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:00:55 -0700 Subject: [PATCH 08/24] Bind theorem manifests to proof obligations for release. Introduce PFCoreTheoremManifest so Lean theorem identity is explicit in proof binding, keeping presentation claims aligned with machine-checked obligations. --- docs/pf-core/presentation/theorem-sheet.md | 38 ++ .../Generated/PFCoreTheoremManifest.v0.json | 132 ++++ python/pcs_core/pf_core_proof_binding.py | 627 ++++++++++++++++-- python/pcs_core/pf_core_theorem_manifest.py | 254 +++++++ .../test_pf_core_theorem_manifest_binding.py | 261 ++++++++ schemas/PFCoreTheoremManifest.v0.schema.json | 136 ++++ 6 files changed, 1384 insertions(+), 64 deletions(-) create mode 100644 lean/PFCore/Generated/PFCoreTheoremManifest.v0.json create mode 100644 python/pcs_core/pf_core_theorem_manifest.py create mode 100644 python/tests/test_pf_core_theorem_manifest_binding.py create mode 100644 schemas/PFCoreTheoremManifest.v0.schema.json diff --git a/docs/pf-core/presentation/theorem-sheet.md b/docs/pf-core/presentation/theorem-sheet.md index ff3ddc2..1e4226a 100644 --- a/docs/pf-core/presentation/theorem-sheet.md +++ b/docs/pf-core/presentation/theorem-sheet.md @@ -2,6 +2,22 @@ Exact statements from `lean/PFCore/` trusted modules. +## Public certificate-mode claim surface + +Public posture is governed by [`schemas/pf_core.certificate_mode_status.json`](../../../schemas/pf_core.certificate_mode_status.json): + +| Mode | Status | +|------|--------| +| `TraceSafeRCertificate` | `release_candidate` (sole tool-use RC) | +| `TraceSafeCertificate` | `legacy` | +| `HandoffSafeCertificate`, `ContractCheckedCertificate`, `EffectFrameCertificate`, `FramePreservedCertificate` | `disabled` | +| `CompositionalExtensionCertificate` | `experimental` (A6 `CompositionalSafeExtension`; not RC) | +| External `CertificateChecked` | `preview` | + +Scaffolded only (not public issuance): `TracePrefixSafeCertificate` (prefix-only), `DenyClosedCertificate` (disabled — insufficient runtime evidence). + +Disabled modes are not public issuance claims. Theorems below remain in the kernel; specialized certificates stay disabled for public RC (evidence repaired for handoff/contract/effect-frame/transitions; enablement deferred). Issuable via `--allow-non-public-modes` for tests. + ## Trace safety (`lean/PFCore/Theorems.lean`) ### `allowed_event_has_allowed_action` @@ -126,6 +142,22 @@ Python deciders in `lean_check.py` mirror: ## Compositional trust (`lean/PFCore/Compositional.lean`) +### `CompositionalSafeExtension` (A6) + +Safe prefix + `EventSafe` + successful `Applies` + preserved `FrameValid` frames. + +```lean +def CompositionalSafeExtension (tr : Trace) (ev : Event) (s s' : State) : Prop := + TraceSafe tr ∧ EventSafe ev ∧ Applies ev s s' ∧ FrameValid s ∧ FrameValid s' + +theorem compositional_safe_extension_yields_safe_extended_trace + (tr : Trace) (ev : Event) (s s' : State) + (h : CompositionalSafeExtension tr ev s s') : + TraceSafe (Trace.cons tr ev) +``` + +Prefix-only chaining is `TracePrefixSafe` (alias of `TraceSafe`); experimental certificate alias `TracePrefixSafeCertificate`. + ### `safe_extension_preserves_trace_safe` Appending an `EventSafe` event to a `TraceSafe` trace yields `TraceSafe`. @@ -226,6 +258,12 @@ theorem safe_extension_preserves_trace_safe_strong (tr : Trace) (ev : Event) ## Effect frames (`lean/PFCore/EffectFrame.lean`) +Certificate mode `EffectFrameCertificate` (disabled for public RC; `--allow-non-public-modes` +for fixtures) binds an independently declared `PFCoreEffectFrame.v0` artifact. Generated +proofs discharge `actionEffectsInFrameD concreteAction concreteDeclaredFrame = true` where +`concreteDeclaredFrame` is emitted from the frame artifact (not `action.effects`). v0 policy: +one global frame per multi-event trace. + ### `effect_frame_prevents_undeclared_writes` Write-free effect frame prevents writes on resource `R` when write footprint requires write effect. diff --git a/lean/PFCore/Generated/PFCoreTheoremManifest.v0.json b/lean/PFCore/Generated/PFCoreTheoremManifest.v0.json new file mode 100644 index 0000000..dabc891 --- /dev/null +++ b/lean/PFCore/Generated/PFCoreTheoremManifest.v0.json @@ -0,0 +1,132 @@ +{ + "schema_version": "v0", + "artifact_type": "PFCoreTheoremManifest.v0", + "generated_module_name": "Trace_7c586dc277783547", + "proof_file_hash": "sha256:01cc51ba95448670180e3e9fd0b410f4a56b4d0d0c7e370cc8350e98ea41a55b", + "semantic_projection_hash": "sha256:5115f144cd79dc0b8037ca2b3a016f552ac5de59fd1d9ef85e0ac7c357bbbf44", + "certificate_mode": "CompositionalExtensionCertificate", + "final_witness_theorem": "concrete_certificate_mode_witness", + "final_witness_proposition": "frameValidD compositionalState_0 = true \u2227 stepState compositionalState_0 ev_file_read_1 = some compositionalState_1 \u2227 frameValidD compositionalState_1 = true \u2227 TraceSafe (Trace.cons (Trace.empty) ev_file_read_1)", + "theorems": [ + { + "theorem_name": "concrete_event_safe_ev_file_read_1", + "normalized_proposition": "eventSafeD ev_file_read_1 = true", + "theorem_category": "event_safety", + "generation_node": "codegen.event.ev-file-read-1.safe", + "evidence_artifact_ids": [ + "ev-file-read-1" + ], + "certificate_mode_role": "supporting", + "proposition_hash": "sha256:5e7661b2b93d905f2710e98fa5a1eaa155a6bfc3e78a09826eb6e56ee0727001" + }, + { + "theorem_name": "concrete_trace_safe", + "normalized_proposition": "traceSafeD trace_file_read_1 = true", + "theorem_category": "trace_safety", + "generation_node": "codegen.trace_safety.concrete_trace_safe", + "evidence_artifact_ids": [], + "certificate_mode_role": "required", + "proposition_hash": "sha256:2b265aab2dab409c87f17cbabbdd1d0374d1838b5a88eb9215fcafa8fcbcf9aa" + }, + { + "theorem_name": "concrete_trace_safe_prop", + "normalized_proposition": "TraceSafe trace_file_read_1", + "theorem_category": "trace_safety", + "generation_node": "codegen.trace_safety.concrete_trace_safe_prop", + "evidence_artifact_ids": [], + "certificate_mode_role": "required", + "proposition_hash": "sha256:3f7f9779e4e160397aff7cfd213fef0fc6138e81cd8481fb43b40490fba7355c" + }, + { + "theorem_name": "concrete_allowed_events_allowed", + "normalized_proposition": "\u2200 ev, EventIn ev trace_file_read_1 \u2192 ev.decision = Decision.allow \u2192 ActionAllowed ev.principal ev.action", + "theorem_category": "trace_safety", + "generation_node": "codegen.trace_safety.concrete_allowed_events_allowed", + "evidence_artifact_ids": [], + "certificate_mode_role": "required", + "proposition_hash": "sha256:b12dfe62ab9fc6156a376cb111bba751eff7b3688cf225f84f271d6c1fb1b624" + }, + { + "theorem_name": "compositional_frame_valid_initial", + "normalized_proposition": "frameValidD compositionalState_0 = true", + "theorem_category": "compositional", + "generation_node": "codegen.mode.CompositionalExtensionCertificate.compositional_frame_valid_initial", + "evidence_artifact_ids": [], + "certificate_mode_role": "required", + "proposition_hash": "sha256:24898eea1bc12fb1f7561ea6989e6a315791576947873b1f5ab15b541e3370ac" + }, + { + "theorem_name": "compositional_step_applies_ev_file_read_1", + "normalized_proposition": "stepState compositionalState_0 ev_file_read_1 = some compositionalState_1", + "theorem_category": "compositional", + "generation_node": "codegen.mode.CompositionalExtensionCertificate.compositional_step_applies_ev_file_read_1", + "evidence_artifact_ids": [], + "certificate_mode_role": "required", + "proposition_hash": "sha256:5969b5e1e4e260bae8ba56bf22846efd3f9c80177b20c5b53b6d80499e0caf8d" + }, + { + "theorem_name": "compositional_frame_valid_after_ev_file_read_1", + "normalized_proposition": "frameValidD compositionalState_1 = true", + "theorem_category": "compositional", + "generation_node": "codegen.mode.CompositionalExtensionCertificate.compositional_frame_valid_after_ev_file_read_1", + "evidence_artifact_ids": [], + "certificate_mode_role": "required", + "proposition_hash": "sha256:eca0b045bbed98b1d349ca2f86ab30b66661e193e4635f7345d41bbf129d4e39" + }, + { + "theorem_name": "concrete_compositional_extension_ev_file_read_1", + "normalized_proposition": "TraceSafe (Trace.cons (Trace.empty) ev_file_read_1)", + "theorem_category": "compositional", + "generation_node": "codegen.mode.CompositionalExtensionCertificate.concrete_compositional_extension_ev_file_read_1", + "evidence_artifact_ids": [], + "certificate_mode_role": "required", + "proposition_hash": "sha256:e8ccd636a966f5ddb01af83e6032c72e85b4f03a6f83bdd87a56da77af8c939e" + }, + { + "theorem_name": "concrete_compositional_extension", + "normalized_proposition": "frameValidD compositionalState_0 = true \u2227 stepState compositionalState_0 ev_file_read_1 = some compositionalState_1 \u2227 frameValidD compositionalState_1 = true \u2227 TraceSafe (Trace.cons (Trace.empty) ev_file_read_1)", + "theorem_category": "mode_aggregate", + "generation_node": "codegen.mode.CompositionalExtensionCertificate.concrete_compositional_extension", + "evidence_artifact_ids": [], + "certificate_mode_role": "aggregate", + "proposition_hash": "sha256:456e0556f4ea83a7c1a0af1739d5520f948dad92784f56771d557f47836805a2" + }, + { + "theorem_name": "concrete_tenant_isolation_prop", + "normalized_proposition": "TenantIsolation trace_file_read_1", + "theorem_category": "trust_boundary", + "generation_node": "codegen.trust_boundary.concrete_tenant_isolation_prop", + "evidence_artifact_ids": [], + "certificate_mode_role": "supporting", + "proposition_hash": "sha256:0bcc2c4a4425c73869898a9abdbc7ea0ecd08f1b07cbf47ba85fa54430980892" + }, + { + "theorem_name": "concrete_trace_cross_tenant_safe_prop", + "normalized_proposition": "TraceCrossTenantSafe trace_file_read_1", + "theorem_category": "trust_boundary", + "generation_node": "codegen.trust_boundary.concrete_trace_cross_tenant_safe_prop", + "evidence_artifact_ids": [], + "certificate_mode_role": "supporting", + "proposition_hash": "sha256:ae6ae8326fa35a7bb2e201293419fe53f1c1bc4b6cfc83940cc277ce362ade08" + }, + { + "theorem_name": "concrete_non_interference_prop", + "normalized_proposition": "TenantProjectionIsolation tenantLow tenantHigh trace_file_read_1", + "theorem_category": "trust_boundary", + "generation_node": "codegen.trust_boundary.concrete_non_interference_prop", + "evidence_artifact_ids": [], + "certificate_mode_role": "supporting", + "proposition_hash": "sha256:3caa5e86613e7e750f01e6761abdd3f8d3d490c86ba0561d3ad3da9ff57bde0d" + }, + { + "theorem_name": "concrete_certificate_mode_witness", + "normalized_proposition": "frameValidD compositionalState_0 = true \u2227 stepState compositionalState_0 ev_file_read_1 = some compositionalState_1 \u2227 frameValidD compositionalState_1 = true \u2227 TraceSafe (Trace.cons (Trace.empty) ev_file_read_1)", + "theorem_category": "mode_witness", + "generation_node": "codegen.witness.concrete_certificate_mode_witness", + "evidence_artifact_ids": [], + "certificate_mode_role": "final_witness", + "proposition_hash": "sha256:456e0556f4ea83a7c1a0af1739d5520f948dad92784f56771d557f47836805a2" + } + ], + "theorem_manifest_digest": "sha256:16c83bd58394569bfc9a74d6c7df05b4bf6b8be85f0182649e54ba6a87ecd636" +} diff --git a/python/pcs_core/pf_core_proof_binding.py b/python/pcs_core/pf_core_proof_binding.py index 7e968ac..d473f56 100644 --- a/python/pcs_core/pf_core_proof_binding.py +++ b/python/pcs_core/pf_core_proof_binding.py @@ -1,4 +1,4 @@ -"""Verify PF-Core certificate proof binding (trace, proof file, Lean environment).""" +"""Verify PF-Core certificate proof binding (digests, theorems, projection replay).""" from __future__ import annotations @@ -7,9 +7,18 @@ from pathlib import Path from typing import Any, Mapping +from pcs_core.hash import canonical_hash from pcs_core.lean_check import compute_proof_term_hash, pfcore_generated_dir from pcs_core.pf_core_lean_codegen import compute_lean_environment_hash, compute_pfcore_kernel_hash from pcs_core.pf_core_runtime import compute_trace_hash +from pcs_core.pf_core_theorem_manifest import ( + compute_theorem_manifest_digest, + load_theorem_manifest, + normalize_proposition, + proposition_hash, + propositions_by_name, + theorem_names_from_manifest, +) from pcs_core.safe_paths import UnsafePathError, resolve_contained_file, strip_repo_generated_prefix @@ -25,6 +34,8 @@ class ProofBindingResult: certificate_path: Path trace_path: Path | None = None proof_path: Path | None = None + theorem_manifest_path: Path | None = None + semantic_projection_path: Path | None = None issues: list[ProofBindingIssue] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: @@ -33,6 +44,12 @@ def to_dict(self) -> dict[str, Any]: "certificate_path": str(self.certificate_path), "trace_path": str(self.trace_path) if self.trace_path else None, "proof_path": str(self.proof_path) if self.proof_path else None, + "theorem_manifest_path": ( + str(self.theorem_manifest_path) if self.theorem_manifest_path else None + ), + "semantic_projection_path": ( + str(self.semantic_projection_path) if self.semantic_projection_path else None + ), "issues": [{"code": i.code, "message": i.message} for i in self.issues], } @@ -47,39 +64,182 @@ def _resolve_generated_proof_path(ref: str) -> Path: ) +def _issue(result: ProofBindingResult, code: str, message: str) -> None: + result.issues.append(ProofBindingIssue(code, message)) + + +def _discover_sibling(certificate_path: Path, name: str) -> Path | None: + candidate = certificate_path.parent / name + return candidate if candidate.is_file() else None + + +def _verify_certificate_schema(cert: Mapping[str, Any], result: ProofBindingResult) -> None: + """Validate certificate schema/semantics for complete release certificates.""" + required = ( + "schema_version", + "artifact_type", + "certificate_id", + "trace_hash", + "contract_hash", + "policy_hash", + "claim_class", + "checker", + "checker_version", + "assumption_refs", + "event_count", + "source_repo", + "source_commit", + "signature_or_digest", + ) + if not all(key in cert for key in required): + # Lightweight binding fixtures omit full release fields; digest checks still apply. + return + + from pcs_core.validate import ValidationError, validate_artifact + + try: + validate_artifact(dict(cert), "PFCoreCertificate.v0") + except ValidationError as exc: + for err in exc.errors or [str(exc)]: + _issue(result, "CertificateSchemaInvalid", str(err)) + except Exception as exc: # noqa: BLE001 — binding must fail closed + _issue(result, "CertificateSchemaInvalid", str(exc)) + + +def _verify_authenticated_integrity( + cert: Mapping[str, Any], + certificate_path: Path, + result: ProofBindingResult, + *, + artifact_integrity_path: Path | None, +) -> None: + integrity_path = artifact_integrity_path + if integrity_path is None: + for name in ( + "PFCoreCertificate.v0.integrity.json", + "ArtifactIntegrity.v1.json", + f"{certificate_path.stem}.integrity.json", + ): + found = _discover_sibling(certificate_path, name) + if found is not None: + integrity_path = found + break + if integrity_path is None: + embedded = cert.get("artifact_integrity") + if isinstance(embedded, Mapping): + integrity = dict(embedded) + else: + return + else: + try: + integrity = json.loads(integrity_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + _issue(result, "ArtifactIntegrityUnreadable", str(exc)) + return + if not isinstance(integrity, Mapping): + _issue(result, "ArtifactIntegrityInvalid", "integrity root must be object") + return + from pcs_core.validate import ValidationError, validate_artifact + + try: + validate_artifact(dict(integrity), "ArtifactIntegrity.v1") + except ValidationError as exc: + for err in exc.errors or [str(exc)]: + _issue(result, "ArtifactIntegritySchemaInvalid", str(err)) + return + expected = str(integrity.get("artifact_digest") or "") + actual = canonical_hash(dict(cert)) + target_digest = str(integrity.get("target_digest") or "") + if expected and expected != actual and target_digest != actual: + _issue( + result, + "ArtifactIntegrityDigestMismatch", + f"integrity artifact_digest {expected!r} / target_digest {target_digest!r} " + f"!= certificate digest {actual!r}", + ) + + # Cryptographic verify when a trusted key registry is configured. + from pcs_core.artifact_integrity import ( + resolve_trusted_key_registry, + verify_artifact_signature, + ) + + registry = resolve_trusted_key_registry() + if registry is not None: + for err in verify_artifact_signature( + integrity, + registry, + required_purpose="release_signing", + expect_digest=actual, + ): + _issue(result, "ArtifactIntegritySignatureInvalid", err) + + def verify_proof_binding( certificate_path: Path, *, trace_path: Path | None = None, + resolved_evidence: Any | None = None, + theorem_manifest_path: Path | None = None, + semantic_projection_path: Path | None = None, + artifact_integrity_path: Path | None = None, ) -> ProofBindingResult: - """Verify certificate binds trace hash, proof term hash, and Lean environment.""" + """Verify certificate binds digests, theorem manifest, projection, and evidence. + + Checks (when applicable): + 1. certificate schema and semantic validity + 2. authenticated certificate integrity when available + 3. trace digest + 4. proof-file digest + 5. kernel digest + 6. Lean-environment digest + 7. theorem-manifest digest + 8. theorem names + 9. normalized theorem propositions + 10. final mode witness + 11. semantic-projection digest + 12. projection replay from source evidence + 13. contract-evidence digest + 14. handoff-evidence digest + 15. effect-frame digest + 16. transition evidence where applicable + + Rejects certificates that add a theorem or change a proposition even when the + referenced proof file bytes remain authentic. + """ result = ProofBindingResult(ok=False, certificate_path=certificate_path) try: cert = json.loads(certificate_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - result.issues.append(ProofBindingIssue("CertificateUnreadable", str(exc))) + _issue(result, "CertificateUnreadable", str(exc)) return result if not isinstance(cert, Mapping): - result.issues.append( - ProofBindingIssue("InvalidCertificate", "certificate root must be object") - ) + _issue(result, "InvalidCertificate", "certificate root must be object") return result claim_class = str(cert.get("claim_class") or "") if claim_class != "LeanKernelChecked": - result.issues.append( - ProofBindingIssue( - "ClaimClassMismatch", - f"verify-proof-binding requires LeanKernelChecked, got {claim_class!r}", - ) + _issue( + result, + "ClaimClassMismatch", + f"verify-proof-binding requires LeanKernelChecked, got {claim_class!r}", ) return result + # 1. Certificate schema + semantic validity + _verify_certificate_schema(cert, result) + + # 2. Authenticated integrity when available + _verify_authenticated_integrity( + cert, + certificate_path, + result, + artifact_integrity_path=artifact_integrity_path, + ) + if cert.get("lean_proof_checked") is not True: - result.issues.append( - ProofBindingIssue("LeanProofNotChecked", "certificate lean_proof_checked must be true") - ) + _issue(result, "LeanProofNotChecked", "certificate lean_proof_checked must be true") cert_trace_hash = str(cert.get("trace_hash") or "") cert_proof_hash = str(cert.get("proof_term_hash") or "") @@ -88,100 +248,439 @@ def verify_proof_binding( proof_ref = str(cert.get("proof_term_ref") or cert.get("proof_ref") or "") if not cert_trace_hash.startswith("sha256:"): - result.issues.append( - ProofBindingIssue("MissingTraceHash", "certificate missing trace_hash") - ) + _issue(result, "MissingTraceHash", "certificate missing trace_hash") if not cert_proof_hash.startswith("sha256:"): - result.issues.append( - ProofBindingIssue("MissingProofTermHash", "certificate missing proof_term_hash") - ) + _issue(result, "MissingProofTermHash", "certificate missing proof_term_hash") if not cert_env_hash.startswith("sha256:"): - result.issues.append( - ProofBindingIssue( - "MissingLeanEnvironmentHash", "certificate missing lean_environment_hash" - ) - ) + _issue(result, "MissingLeanEnvironmentHash", "certificate missing lean_environment_hash") if not cert_kernel_hash.startswith("sha256:"): - result.issues.append( - ProofBindingIssue("MissingPfcoreKernelHash", "certificate missing pfcore_kernel_hash") - ) + _issue(result, "MissingPfcoreKernelHash", "certificate missing pfcore_kernel_hash") if not proof_ref: - result.issues.append( - ProofBindingIssue("MissingProofTermRef", "certificate missing proof_term_ref") - ) + _issue(result, "MissingProofTermRef", "certificate missing proof_term_ref") + # 3. Trace digest resolved_trace: Path | None = None + trace_obj: dict[str, Any] | None = None if trace_path is not None: resolved_trace = trace_path.resolve() result.trace_path = resolved_trace if not resolved_trace.is_file(): - result.issues.append( - ProofBindingIssue("TraceMissing", f"trace file not found: {resolved_trace}") - ) + _issue(result, "TraceMissing", f"trace file not found: {resolved_trace}") else: try: trace = json.loads(resolved_trace.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - result.issues.append(ProofBindingIssue("TraceUnreadable", str(exc))) + _issue(result, "TraceUnreadable", str(exc)) else: if isinstance(trace, Mapping): + trace_obj = dict(trace) actual_trace_hash = str( trace.get("trace_hash") or compute_trace_hash(dict(trace)) ) if cert_trace_hash and actual_trace_hash != cert_trace_hash: - result.issues.append( - ProofBindingIssue( - "TraceHashMismatch", - f"trace hash {actual_trace_hash!r} != certificate {cert_trace_hash!r}", - ) + _issue( + result, + "TraceHashMismatch", + f"trace hash {actual_trace_hash!r} != certificate {cert_trace_hash!r}", ) else: - result.issues.append( - ProofBindingIssue("InvalidTrace", "trace root must be object") - ) + _issue(result, "InvalidTrace", "trace root must be object") + # 4. Proof-file digest resolved_proof: Path | None = None if proof_ref: try: resolved_proof = _resolve_generated_proof_path(proof_ref) result.proof_path = resolved_proof except UnsafePathError as exc: - result.issues.append(ProofBindingIssue("ProofPathUnsafe", str(exc))) + _issue(result, "ProofPathUnsafe", str(exc)) resolved_proof = None if resolved_proof is None: if not any(issue.code == "ProofPathUnsafe" for issue in result.issues): - result.issues.append( - ProofBindingIssue("ProofFileMissing", f"generated proof not found: {proof_ref}") - ) + _issue(result, "ProofFileMissing", f"generated proof not found: {proof_ref}") elif cert_proof_hash.startswith("sha256:"): actual_proof_hash = compute_proof_term_hash(resolved_proof) if actual_proof_hash != cert_proof_hash: - result.issues.append( - ProofBindingIssue( - "ProofTermHashMismatch", - f"proof file hash {actual_proof_hash!r} != certificate {cert_proof_hash!r}", - ) + _issue( + result, + "ProofTermHashMismatch", + f"proof file hash {actual_proof_hash!r} != certificate {cert_proof_hash!r}", ) + # 5. Kernel digest + if cert_kernel_hash.startswith("sha256:"): + actual_kernel_hash = compute_pfcore_kernel_hash() + if actual_kernel_hash != cert_kernel_hash: + _issue( + result, + "PfcoreKernelHashMismatch", + f"current kernel hash {actual_kernel_hash!r} != certificate {cert_kernel_hash!r}", + ) + + # 6. Lean-environment digest if cert_env_hash.startswith("sha256:"): actual_env_hash = compute_lean_environment_hash() if actual_env_hash != cert_env_hash: - result.issues.append( - ProofBindingIssue( - "LeanEnvironmentHashMismatch", - f"current lean environment {actual_env_hash!r} != certificate {cert_env_hash!r}", - ) + _issue( + result, + "LeanEnvironmentHashMismatch", + f"current lean environment {actual_env_hash!r} != certificate {cert_env_hash!r}", ) - if cert_kernel_hash.startswith("sha256:"): - actual_kernel_hash = compute_pfcore_kernel_hash() - if actual_kernel_hash != cert_kernel_hash: - result.issues.append( - ProofBindingIssue( - "PfcoreKernelHashMismatch", - f"current kernel hash {actual_kernel_hash!r} != certificate {cert_kernel_hash!r}", + # Resolve theorem manifest + projection paths + manifest_path = theorem_manifest_path + if manifest_path is None: + if resolved_proof is not None: + sibling = resolved_proof.parent / "PFCoreTheoremManifest.v0.json" + if sibling.is_file(): + manifest_path = sibling + if manifest_path is None: + found = _discover_sibling(certificate_path, "PFCoreTheoremManifest.v0.json") + if found is not None: + manifest_path = found + result.theorem_manifest_path = manifest_path + + projection_path = semantic_projection_path + if projection_path is None: + if resolved_proof is not None: + sibling = resolved_proof.parent / "PFCoreSemanticProjection.v0.json" + if sibling.is_file(): + projection_path = sibling + if projection_path is None: + found = _discover_sibling(certificate_path, "PFCoreSemanticProjection.v0.json") + if found is not None: + projection_path = found + result.semantic_projection_path = projection_path + + cert_manifest_hash = str(cert.get("theorem_manifest_hash") or "") + inventory = cert.get("theorem_inventory") + inventory_names = ( + {str(name) for name in inventory} if isinstance(inventory, list) else set() + ) + + # 7–10. Theorem manifest digest, names, propositions, final witness + if cert_manifest_hash.startswith("sha256:") or isinstance(inventory, list): + if manifest_path is None or not manifest_path.is_file(): + if cert_manifest_hash.startswith("sha256:") or inventory_names: + _issue( + result, + "TheoremManifestMissing", + "certificate binds theorem inventory/manifest but PFCoreTheoremManifest.v0 " + "was not found", ) + else: + try: + manifest = load_theorem_manifest(manifest_path) + except (OSError, ValueError, json.JSONDecodeError) as exc: + _issue(result, "TheoremManifestUnreadable", str(exc)) + manifest = None + if manifest is not None: + from pcs_core.validate import ValidationError, validate_artifact + + try: + validate_artifact(manifest, "PFCoreTheoremManifest.v0") + except ValidationError as exc: + for err in exc.errors or [str(exc)]: + _issue(result, "TheoremManifestSchemaInvalid", str(err)) + + recomputed = compute_theorem_manifest_digest(manifest) + declared = str(manifest.get("theorem_manifest_digest") or "") + if declared and declared != recomputed: + _issue( + result, + "TheoremManifestDigestMismatch", + f"manifest digest {declared!r} != recomputed {recomputed!r}", + ) + if cert_manifest_hash.startswith("sha256:") and cert_manifest_hash != recomputed: + _issue( + result, + "CertificateTheoremManifestHashMismatch", + f"certificate theorem_manifest_hash {cert_manifest_hash!r} " + f"!= manifest digest {recomputed!r}", + ) + inventory_hash = str(cert.get("theorem_inventory_hash") or "") + if ( + cert_manifest_hash.startswith("sha256:") + and inventory_hash.startswith("sha256:") + and cert_manifest_hash == inventory_hash + ): + _issue( + result, + "TheoremManifestHashCollapsesToInventory", + "theorem_manifest_hash must not equal theorem_inventory_hash", + ) + + manifest_names = set(theorem_names_from_manifest(manifest)) + if inventory_names: + extra = inventory_names - manifest_names + missing = manifest_names - inventory_names + if extra: + _issue( + result, + "TheoremNameDrift", + "certificate theorem_inventory adds names absent from manifest: " + f"{sorted(extra)}", + ) + if missing: + _issue( + result, + "TheoremNameDrift", + "theorem manifest names missing from certificate inventory: " + f"{sorted(missing)}", + ) + + # 9. Normalized propositions — reject drift even if proof bytes match + props = propositions_by_name(manifest) + for name, prop in props.items(): + entry_hash = None + for entry in manifest.get("theorems") or []: + if isinstance(entry, Mapping) and str(entry.get("theorem_name")) == name: + entry_hash = str(entry.get("proposition_hash") or "") + break + expected_hash = proposition_hash(prop) + if entry_hash and entry_hash != expected_hash: + _issue( + result, + "PropositionHashMismatch", + f"theorem {name!r} proposition_hash does not match " + "normalized_proposition", + ) + + # 10. Final mode witness + witness = cert.get("certificate_mode_witness") + if isinstance(witness, Mapping): + final_thm = str(manifest.get("final_witness_theorem") or "") + final_prop = normalize_proposition( + str(manifest.get("final_witness_proposition") or "") + ) + cert_thm = str(witness.get("theorem") or "") + cert_prop = normalize_proposition(str(witness.get("proposition") or "")) + if final_thm and cert_thm and final_thm != cert_thm: + _issue( + result, + "FinalWitnessTheoremMismatch", + f"manifest final witness {final_thm!r} != certificate {cert_thm!r}", + ) + if final_prop and cert_prop and final_prop != cert_prop: + _issue( + result, + "FinalWitnessPropositionMismatch", + f"manifest final witness proposition differs from certificate", + ) + if cert_thm and cert_thm not in manifest_names: + _issue( + result, + "FinalWitnessMissingFromManifest", + f"certificate mode witness theorem {cert_thm!r} absent from manifest", + ) + if cert_thm and cert_thm in props and cert_prop != props[cert_thm]: + _issue( + result, + "FinalWitnessPropositionDrift", + "certificate mode witness proposition drifted from theorem manifest", + ) + + mode = str(cert.get("certificate_mode") or "") + manifest_mode = str(manifest.get("certificate_mode") or "") + if mode and manifest_mode and mode != manifest_mode: + _issue( + result, + "CertificateModeMismatch", + f"manifest mode {manifest_mode!r} != certificate {mode!r}", + ) + + if resolved_proof is not None and cert_proof_hash.startswith("sha256:"): + proof_hash_in_manifest = str(manifest.get("proof_file_hash") or "") + if proof_hash_in_manifest and proof_hash_in_manifest != cert_proof_hash: + _issue( + result, + "ManifestProofFileHashMismatch", + "theorem manifest proof_file_hash does not match certificate " + "proof_term_hash", + ) + + # 11–12. Semantic projection digest + replay + cert_projection_hash = str(cert.get("semantic_projection_hash") or "") + if cert_projection_hash.startswith("sha256:"): + if projection_path is None or not projection_path.is_file(): + _issue( + result, + "SemanticProjectionMissing", + "certificate binds semantic_projection_hash but projection file was not found", ) + else: + try: + projection = json.loads(projection_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + _issue(result, "SemanticProjectionUnreadable", str(exc)) + projection = None + if isinstance(projection, Mapping): + stored = str(projection.get("projection_hash") or "") + if stored and stored != cert_projection_hash: + _issue( + result, + "SemanticProjectionHashMismatch", + f"projection file hash {stored!r} != certificate {cert_projection_hash!r}", + ) + # Replay from source evidence when available + if trace_obj is not None: + try: + from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + resolve_pf_core_evidence, + ) + from pcs_core.pf_core_semantic_projection import ( + build_semantic_projection, + ) + + evidence = resolved_evidence + if evidence is None and resolved_trace is not None: + evidence = resolve_pf_core_evidence( + trace_obj, + trace_path=resolved_trace, + certificate_mode=cert.get("certificate_mode"), + ) + if evidence is not None: + replayed = build_semantic_projection( + trace_obj, + certificate_mode=str(cert.get("certificate_mode") or ""), + trace_path=resolved_trace, + resolved_evidence=evidence, + ) + replay_hash = str(replayed.get("projection_hash") or "") + if replay_hash and replay_hash != cert_projection_hash: + _issue( + result, + "ProjectionReplayMismatch", + f"replayed projection hash {replay_hash!r} != " + f"certificate {cert_projection_hash!r}", + ) + except EvidenceResolutionError as exc: + _issue(result, "ProjectionReplayFailed", str(exc)) + except Exception as exc: # noqa: BLE001 + _issue(result, "ProjectionReplayFailed", str(exc)) + + # 13–16. Evidence digests where applicable / present + if resolved_evidence is not None or ( + resolved_trace is not None and trace_obj is not None and cert.get("certificate_mode") + ): + evidence = resolved_evidence + if evidence is None and resolved_trace is not None and trace_obj is not None: + try: + from pcs_core.pf_core_resolved_evidence import resolve_pf_core_evidence + + evidence = resolve_pf_core_evidence( + trace_obj, + trace_path=resolved_trace, + certificate_mode=cert.get("certificate_mode"), + ) + except Exception: + evidence = None + + if evidence is not None: + mode = str(cert.get("certificate_mode") or "") + # 13. Contract evidence + cert_contract_digest = str(cert.get("contract_evidence_digest") or "") + if cert_contract_digest.startswith("sha256:") or mode == "ContractCheckedCertificate": + from pcs_core.pf_core_resolved_evidence import ( + collect_contract_theorem_names, + compute_contract_evidence_digest, + contract_source_file_digests, + ) + + try: + digests = contract_source_file_digests(evidence) + names = collect_contract_theorem_names(cert.get("theorem_inventory")) + recomputed = compute_contract_evidence_digest( + selected_contract_ids=evidence.selected_contract_ids, + contract_source_file_digests=digests, + effective_layers=evidence.effective_contract_semantic_layers, + contract_theorem_names=names, + ) + if cert_contract_digest.startswith("sha256:") and cert_contract_digest != recomputed: + _issue( + result, + "ContractEvidenceDigestMismatch", + f"contract evidence digest {cert_contract_digest!r} != " + f"recomputed {recomputed!r}", + ) + except Exception as exc: # noqa: BLE001 + _issue(result, "ContractEvidenceDigestFailed", str(exc)) + + # 14. Handoff evidence + cert_handoff_digest = str(cert.get("handoff_evidence_digest") or "") + if cert_handoff_digest.startswith("sha256:") or mode == "HandoffSafeCertificate": + from pcs_core.pf_core_resolved_evidence import ( + collect_handoff_theorem_names, + compute_handoff_evidence_digest, + handoff_source_file_digests, + ) + + try: + digests = handoff_source_file_digests(evidence) + names = collect_handoff_theorem_names(cert.get("theorem_inventory")) + recomputed = compute_handoff_evidence_digest( + selected_handoff_ids=evidence.selected_handoff_ids, + handoff_source_file_digests=digests, + handoff_theorem_names=names, + ) + if cert_handoff_digest.startswith("sha256:") and cert_handoff_digest != recomputed: + _issue( + result, + "HandoffEvidenceDigestMismatch", + f"handoff evidence digest {cert_handoff_digest!r} != " + f"recomputed {recomputed!r}", + ) + except Exception as exc: # noqa: BLE001 + _issue(result, "HandoffEvidenceDigestFailed", str(exc)) + + # 15. Effect-frame digest + cert_frame_digest = str(cert.get("effect_frame_digest") or "") + if cert_frame_digest.startswith("sha256:") or mode == "EffectFrameCertificate": + from pcs_core.pf_core_resolved_evidence import effect_frame_source_digest + + try: + if evidence.effect_frame is not None: + recomputed = effect_frame_source_digest(evidence) + if ( + cert_frame_digest.startswith("sha256:") + and cert_frame_digest != recomputed + ): + _issue( + result, + "EffectFrameDigestMismatch", + f"effect frame digest {cert_frame_digest!r} != " + f"recomputed {recomputed!r}", + ) + elif mode == "EffectFrameCertificate": + _issue( + result, + "EffectFrameMissing", + "EffectFrameCertificate requires declared effect frame evidence", + ) + except Exception as exc: # noqa: BLE001 + _issue(result, "EffectFrameDigestFailed", str(exc)) + + # 16. Transition evidence + cert_transition = str(cert.get("transition_chain_digest") or "") + if cert_transition.startswith("sha256:") or mode == "FramePreservedCertificate": + from pcs_core.pf_core_resolved_evidence import transition_chain_digest + + try: + if mode == "FramePreservedCertificate" or cert_transition.startswith("sha256:"): + recomputed = transition_chain_digest(evidence) + if ( + cert_transition.startswith("sha256:") + and cert_transition != recomputed + ): + _issue( + result, + "TransitionEvidenceDigestMismatch", + f"transition chain digest {cert_transition!r} != " + f"recomputed {recomputed!r}", + ) + except Exception as exc: # noqa: BLE001 + _issue(result, "TransitionEvidenceDigestFailed", str(exc)) result.ok = not result.issues return result diff --git a/python/pcs_core/pf_core_theorem_manifest.py b/python/pcs_core/pf_core_theorem_manifest.py new file mode 100644 index 0000000..5483d56 --- /dev/null +++ b/python/pcs_core/pf_core_theorem_manifest.py @@ -0,0 +1,254 @@ +"""PFCoreTheoremManifest.v0 — structured theorem IR shared by Lean codegen and binding.""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + +from pcs_core.hash import canonical_hash + +_THEOREM_SIGNATURE_RE = re.compile( + r"theorem\s+(\w+)(?:\s*\([^)]*\))*\s*:\s*(.+?)\s*:=", + re.DOTALL, +) + +THEOREM_CATEGORIES = frozenset( + { + "trace_safety", + "event_safety", + "trust_boundary", + "resource_scope", + "handoff_safety", + "contract", + "effect_frame", + "transition", + "compositional", + "mode_aggregate", + "mode_witness", + } +) + +CERTIFICATE_MODE_ROLES = frozenset( + { + "required", + "supporting", + "aggregate", + "final_witness", + } +) + + +def normalize_proposition(proposition: str) -> str: + """Collapse Lean proposition whitespace to a stable normalized form.""" + return " ".join(str(proposition).split()) + + +def proposition_hash(proposition: str) -> str: + normalized = normalize_proposition(proposition) + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return f"sha256:{digest}" + + +def parse_theorem_signature(lean_theorem: str) -> tuple[str, str] | None: + """Return (name, normalized_proposition) from a Lean theorem declaration.""" + match = _THEOREM_SIGNATURE_RE.search(lean_theorem) + if match is None: + return None + return match.group(1), normalize_proposition(match.group(2)) + + +@dataclass(frozen=True) +class TheoremSpec: + """Structured intermediate representation for one generated theorem.""" + + name: str + normalized_proposition: str + category: str + generation_node: str + evidence_artifact_ids: tuple[str, ...] = () + certificate_mode_role: str = "supporting" + lean_text: str = "" + + def __post_init__(self) -> None: + if self.category not in THEOREM_CATEGORIES: + raise ValueError(f"invalid theorem category: {self.category!r}") + if self.certificate_mode_role not in CERTIFICATE_MODE_ROLES: + raise ValueError(f"invalid certificate_mode_role: {self.certificate_mode_role!r}") + if not self.name or not self.name.isidentifier(): + raise ValueError(f"invalid theorem name: {self.name!r}") + + @property + def proposition_hash(self) -> str: + return proposition_hash(self.normalized_proposition) + + def to_entry(self) -> dict[str, Any]: + return { + "theorem_name": self.name, + "normalized_proposition": normalize_proposition(self.normalized_proposition), + "theorem_category": self.category, + "generation_node": self.generation_node, + "evidence_artifact_ids": list(self.evidence_artifact_ids), + "certificate_mode_role": self.certificate_mode_role, + "proposition_hash": self.proposition_hash, + } + + def emit_lean(self) -> str: + if self.lean_text.strip(): + return self.lean_text + raise ValueError(f"theorem {self.name!r} has no lean_text to emit") + + +@dataclass +class TheoremBuildContext: + """Collect theorem IR while emitting Lean; inventory is derived from the IR.""" + + inventory: set[str] = field(default_factory=set) + specs: list[TheoremSpec] = field(default_factory=list) + + def register_name(self, name: str) -> str: + from pcs_core.pf_core_lean_codegen import register_theorem_name + + return register_theorem_name(self.inventory, name) + + def emit( + self, + lean_text: str, + *, + category: str, + generation_node: str, + evidence_artifact_ids: Sequence[str] | None = None, + certificate_mode_role: str = "supporting", + ) -> str: + """Record a theorem from its Lean text into the shared IR and return the text.""" + parsed = parse_theorem_signature(lean_text) + if parsed is None: + raise ValueError(f"cannot parse theorem signature from: {lean_text[:120]!r}") + name, prop = parsed + self.register_name(name) + self.specs.append( + TheoremSpec( + name=name, + normalized_proposition=prop, + category=category, + generation_node=generation_node, + evidence_artifact_ids=tuple(evidence_artifact_ids or ()), + certificate_mode_role=certificate_mode_role, + lean_text=lean_text, + ) + ) + return lean_text + + def emit_spec(self, spec: TheoremSpec) -> str: + self.register_name(spec.name) + self.specs.append(spec) + return spec.emit_lean() + + def theorem_names(self) -> frozenset[str]: + return frozenset(self.inventory) + + +def build_theorem_manifest( + *, + specs: Sequence[TheoremSpec], + generated_module_name: str, + proof_file_hash: str, + semantic_projection_hash: str, + certificate_mode: str, + final_witness_theorem: str, + final_witness_proposition: str, +) -> dict[str, Any]: + """Build PFCoreTheoremManifest.v0 from structured theorem IR.""" + if not specs: + raise ValueError("theorem manifest requires ≥1 theorem spec") + body: dict[str, Any] = { + "schema_version": "v0", + "artifact_type": "PFCoreTheoremManifest.v0", + "generated_module_name": generated_module_name, + "proof_file_hash": proof_file_hash, + "semantic_projection_hash": semantic_projection_hash, + "certificate_mode": certificate_mode, + "final_witness_theorem": final_witness_theorem, + "final_witness_proposition": normalize_proposition(final_witness_proposition), + "theorems": [spec.to_entry() for spec in specs], + } + digest = compute_theorem_manifest_digest(body) + body["theorem_manifest_digest"] = digest + return body + + +def compute_theorem_manifest_digest(manifest: Mapping[str, Any]) -> str: + """Canonical digest over the manifest excluding theorem_manifest_digest.""" + payload = {k: v for k, v in manifest.items() if k != "theorem_manifest_digest"} + return canonical_hash(dict(payload)) + + +def write_theorem_manifest(manifest: Mapping[str, Any], path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(dict(manifest), indent=2) + "\n", encoding="utf-8") + return path + + +def load_theorem_manifest(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("theorem manifest root must be an object") + return data + + +def theorem_names_from_manifest(manifest: Mapping[str, Any]) -> list[str]: + theorems = manifest.get("theorems") + if not isinstance(theorems, list): + return [] + names: list[str] = [] + for entry in theorems: + if isinstance(entry, Mapping): + name = str(entry.get("theorem_name") or "") + if name: + names.append(name) + return names + + +def propositions_by_name(manifest: Mapping[str, Any]) -> dict[str, str]: + out: dict[str, str] = {} + theorems = manifest.get("theorems") + if not isinstance(theorems, list): + return out + for entry in theorems: + if not isinstance(entry, Mapping): + continue + name = str(entry.get("theorem_name") or "") + prop = str(entry.get("normalized_proposition") or "") + if name and prop: + out[name] = normalize_proposition(prop) + return out + + +def specs_match_inventory(specs: Iterable[TheoremSpec], inventory: Iterable[str]) -> bool: + return frozenset(spec.name for spec in specs) == frozenset(str(n) for n in inventory) + + +def reconstruct_theorem_metadata_from_proof(proof_text: str) -> dict[str, str]: + """Parse theorem name → normalized proposition from a generated Lean proof file. + + Expands ``SelectedCertificateModePredicate`` when the generated file defines + that alias (final mode witness surface form). + """ + alias_match = re.search( + r"def\s+SelectedCertificateModePredicate\s*:\s*Prop\s*:=\s*(.+?)(?=\n\s*(?:theorem|def|end)\b)", + proof_text, + re.DOTALL, + ) + alias_body = normalize_proposition(alias_match.group(1)) if alias_match else None + + reconstructed: dict[str, str] = {} + for match in _THEOREM_SIGNATURE_RE.finditer(proof_text): + name = match.group(1) + prop = normalize_proposition(match.group(2)) + if prop == "SelectedCertificateModePredicate" and alias_body: + prop = alias_body + reconstructed[name] = prop + return reconstructed diff --git a/python/tests/test_pf_core_theorem_manifest_binding.py b/python/tests/test_pf_core_theorem_manifest_binding.py new file mode 100644 index 0000000..df24d36 --- /dev/null +++ b/python/tests/test_pf_core_theorem_manifest_binding.py @@ -0,0 +1,261 @@ +"""PR6: PFCoreTheoremManifest.v0 + independent proof-binding checks.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from pcs_core.hash import canonical_hash +from pcs_core.lean_check import compute_proof_term_hash, run_pfcore_lean_check +from pcs_core.pf_core_lean_codegen import ( + generate_proof_obligation_file, + theorem_inventory_hash, +) +from pcs_core.pf_core_proof_binding import verify_proof_binding +from pcs_core.pf_core_theorem_manifest import ( + build_theorem_manifest, + compute_theorem_manifest_digest, + normalize_proposition, + proposition_hash, +) +from pcs_core.validate import validate_artifact + +REPO = Path(__file__).resolve().parents[2] +FILE_READ = REPO / "examples" / "pf-core-valid" / "file_read_allowed" / "trace.json" + + +def _load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def test_theorem_manifest_digest_differs_from_inventory_hash(tmp_path: Path) -> None: + trace = _load(FILE_READ) + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=FILE_READ, + certificate_mode="TraceSafeRCertificate", + ) + assert generated.theorem_manifest is not None + assert generated.theorem_manifest_hash is not None + inventory_hash = theorem_inventory_hash(generated.theorem_names) + assert generated.theorem_manifest_hash != inventory_hash + assert generated.theorem_manifest["theorem_manifest_digest"] == generated.theorem_manifest_hash + validate_artifact(dict(generated.theorem_manifest), "PFCoreTheoremManifest.v0") + assert generated.theorem_manifest_path is not None + assert generated.theorem_manifest_path.is_file() + entries = generated.theorem_manifest["theorems"] + assert entries + for entry in entries: + assert entry["proposition_hash"] == proposition_hash(entry["normalized_proposition"]) + assert entry["generation_node"] + assert entry["theorem_category"] + assert entry["certificate_mode_role"] + + +def test_lean_check_writes_theorem_manifest_artifact(tmp_path: Path) -> None: + out_cert = tmp_path / "PFCoreCertificate.v0.json" + result_out = tmp_path / "LeanCheckResult.v0.json" + code, result = run_pfcore_lean_check( + FILE_READ, + out_path=out_cert, + result_out_path=result_out, + certificate_mode="TraceSafeRCertificate", + ) + if code != 0: + issues = result.get("issues") if isinstance(result, dict) else None + pytest.skip(f"lean-check unavailable or failed: {issues or result}") + assert out_cert.is_file() + cert = _load(out_cert) + paths = result["artifact_paths"] + manifest_path = Path(paths["theorem_manifest"]) + assert manifest_path.is_file() + manifest = _load(manifest_path) + validate_artifact(manifest, "PFCoreTheoremManifest.v0") + assert cert["theorem_manifest_hash"] == manifest["theorem_manifest_digest"] + assert cert["theorem_manifest_hash"] != cert["theorem_inventory_hash"] + assert cert["theorem_inventory_hash"] == theorem_inventory_hash( + frozenset(cert["theorem_inventory"]) + ) + + +def test_verify_proof_binding_rejects_added_theorem_name(tmp_path: Path) -> None: + out_dir = tmp_path / "gen" + trace = _load(FILE_READ) + generated = generate_proof_obligation_file( + trace, + out_dir, + trace_path=FILE_READ, + certificate_mode="TraceSafeRCertificate", + ) + # Copy generated proof into the repo Generated/ layout expected by binding. + dest = REPO / "lean" / "PFCore" / "Generated" / generated.path.name + shutil.copy2(generated.path, dest) + shutil.copy2( + generated.theorem_manifest_path, + dest.parent / "PFCoreTheoremManifest.v0.json", + ) + try: + cert = { + "schema_version": "v0", + "artifact_type": "PFCoreCertificate.v0", + "certificate_id": "pfcore-cert-drift-name", + "trace_hash": trace.get("trace_hash") or canonical_hash(trace), + "contract_hash": "sha256:" + "0" * 64, + "policy_hash": "sha256:" + "0" * 64, + "claim_class": "LeanKernelChecked", + "lean_proof_checked": True, + "checker": "pcs-core", + "checker_version": "0.1.0", + "assumption_refs": [], + "event_count": 1, + "source_repo": "https://github.com/example/pcs-core", + "source_commit": "0000000", + "proof_term_ref": str(dest.relative_to(REPO)).replace("\\", "/"), + "proof_term_hash": compute_proof_term_hash(dest), + "lean_environment_hash": "sha256:" + "a" * 64, + "pfcore_kernel_hash": "sha256:" + "b" * 64, + "certificate_mode": generated.certificate_mode, + "theorem_inventory": sorted(generated.theorem_names | {"forged_extra_theorem"}), + "theorem_inventory_hash": theorem_inventory_hash( + generated.theorem_names | {"forged_extra_theorem"} + ), + "theorem_manifest_hash": generated.theorem_manifest_hash, + "semantic_projection_hash": generated.semantic_projection_hash, + "certificate_mode_witness": { + "theorem": generated.mode_witness_theorem, + "proposition": generated.mode_witness_proposition, + }, + "signature_or_digest": "sha256:" + "0" * 64, + } + # Intentionally wrong env/kernel so we isolate name-drift by using matching hashes. + from pcs_core.pf_core_lean_codegen import ( + compute_lean_environment_hash, + compute_pfcore_kernel_hash, + ) + + cert["lean_environment_hash"] = compute_lean_environment_hash() + cert["pfcore_kernel_hash"] = compute_pfcore_kernel_hash() + cert["signature_or_digest"] = canonical_hash(cert) + cert_path = tmp_path / "cert.json" + _write(cert_path, cert) + binding = verify_proof_binding( + cert_path, + trace_path=FILE_READ, + theorem_manifest_path=generated.theorem_manifest_path, + semantic_projection_path=out_dir / "PFCoreSemanticProjection.v0.json", + ) + assert binding.ok is False + assert any(issue.code == "TheoremNameDrift" for issue in binding.issues) + # Proof file itself remains authentic. + assert all(issue.code != "ProofTermHashMismatch" for issue in binding.issues) + finally: + if dest.is_file(): + dest.unlink() + + +def test_verify_proof_binding_rejects_proposition_drift(tmp_path: Path) -> None: + out_dir = tmp_path / "gen" + trace = _load(FILE_READ) + generated = generate_proof_obligation_file( + trace, + out_dir, + trace_path=FILE_READ, + certificate_mode="TraceSafeRCertificate", + ) + dest = REPO / "lean" / "PFCore" / "Generated" / generated.path.name + shutil.copy2(generated.path, dest) + try: + from pcs_core.pf_core_lean_codegen import ( + compute_lean_environment_hash, + compute_pfcore_kernel_hash, + ) + + manifest = dict(generated.theorem_manifest) + # Mutate a proposition while keeping proof-file bytes unchanged. + theorems = list(manifest["theorems"]) + target = dict(theorems[0]) + target["normalized_proposition"] = normalize_proposition( + target["normalized_proposition"] + " ∧ True" + ) + target["proposition_hash"] = proposition_hash(target["normalized_proposition"]) + theorems[0] = target + manifest["theorems"] = theorems + del manifest["theorem_manifest_digest"] + manifest["theorem_manifest_digest"] = compute_theorem_manifest_digest(manifest) + drifted_manifest_path = tmp_path / "PFCoreTheoremManifest.v0.json" + _write(drifted_manifest_path, manifest) + + cert = { + "schema_version": "v0", + "artifact_type": "PFCoreCertificate.v0", + "certificate_id": "pfcore-cert-prop-drift", + "trace_hash": trace.get("trace_hash") or canonical_hash(trace), + "contract_hash": "sha256:" + "0" * 64, + "policy_hash": "sha256:" + "0" * 64, + "claim_class": "LeanKernelChecked", + "lean_proof_checked": True, + "checker": "pcs-core", + "checker_version": "0.1.0", + "assumption_refs": [], + "event_count": 1, + "source_repo": "https://github.com/example/pcs-core", + "source_commit": "0000000", + "proof_term_ref": str(dest.relative_to(REPO)).replace("\\", "/"), + "proof_term_hash": compute_proof_term_hash(dest), + "lean_environment_hash": compute_lean_environment_hash(), + "pfcore_kernel_hash": compute_pfcore_kernel_hash(), + "certificate_mode": generated.certificate_mode, + "theorem_inventory": sorted(generated.theorem_names), + "theorem_inventory_hash": theorem_inventory_hash(generated.theorem_names), + "theorem_manifest_hash": manifest["theorem_manifest_digest"], + "semantic_projection_hash": generated.semantic_projection_hash, + "certificate_mode_witness": { + "theorem": generated.mode_witness_theorem, + "proposition": "FORGED_PROPOSITION_NOT_IN_MANIFEST", + }, + "signature_or_digest": "sha256:" + "0" * 64, + } + cert["signature_or_digest"] = canonical_hash(cert) + cert_path = tmp_path / "cert.json" + _write(cert_path, cert) + binding = verify_proof_binding( + cert_path, + trace_path=FILE_READ, + theorem_manifest_path=drifted_manifest_path, + semantic_projection_path=out_dir / "PFCoreSemanticProjection.v0.json", + ) + assert binding.ok is False + assert any( + issue.code + in { + "FinalWitnessPropositionMismatch", + "FinalWitnessPropositionDrift", + } + for issue in binding.issues + ) + assert all(issue.code != "ProofTermHashMismatch" for issue in binding.issues) + finally: + if dest.is_file(): + dest.unlink() + + +def test_build_theorem_manifest_requires_specs() -> None: + with pytest.raises(ValueError, match="≥1"): + build_theorem_manifest( + specs=[], + generated_module_name="Trace_deadbeef", + proof_file_hash="sha256:" + "0" * 64, + semantic_projection_hash="sha256:" + "1" * 64, + certificate_mode="TraceSafeCertificate", + final_witness_theorem="concrete_certificate_mode_witness", + final_witness_proposition="TraceSafe t", + ) diff --git a/schemas/PFCoreTheoremManifest.v0.schema.json b/schemas/PFCoreTheoremManifest.v0.schema.json new file mode 100644 index 0000000..a25b96f --- /dev/null +++ b/schemas/PFCoreTheoremManifest.v0.schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/PFCoreTheoremManifest.v0.schema.json", + "title": "PFCoreTheoremManifest.v0", + "description": "Structured theorem inventory for a generated PF-Core proof module. Digests normalized propositions and metadata; distinct from theorem_inventory_hash (name-only).", + "type": "object", + "required": [ + "schema_version", + "artifact_type", + "generated_module_name", + "proof_file_hash", + "semantic_projection_hash", + "certificate_mode", + "final_witness_theorem", + "final_witness_proposition", + "theorems", + "theorem_manifest_digest" + ], + "additionalProperties": false, + "properties": { + "schema_version": { "$ref": "common.defs.json#/$defs/schema_version" }, + "artifact_type": { "const": "PFCoreTheoremManifest.v0" }, + "generated_module_name": { + "type": "string", + "minLength": 1, + "description": "Lean module suffix, e.g. Trace_" + }, + "proof_file_hash": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "SHA-256 of the generated .lean proof file bytes" + }, + "semantic_projection_hash": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "Bound PFCoreSemanticProjection.v0 projection_hash" + }, + "certificate_mode": { + "type": "string", + "enum": [ + "TraceSafeCertificate", + "TraceSafeRCertificate", + "FramePreservedCertificate", + "EffectFrameCertificate", + "HandoffSafeCertificate", + "CompositionalExtensionCertificate", + "ContractCheckedCertificate" + ] + }, + "final_witness_theorem": { + "type": "string", + "minLength": 1, + "description": "Usually concrete_certificate_mode_witness" + }, + "final_witness_proposition": { + "type": "string", + "minLength": 1, + "description": "Normalized proposition of the final mode witness" + }, + "theorems": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/theorem_entry" } + }, + "theorem_manifest_digest": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "Canonical digest of this manifest excluding theorem_manifest_digest itself" + } + }, + "$defs": { + "theorem_entry": { + "type": "object", + "required": [ + "theorem_name", + "normalized_proposition", + "theorem_category", + "generation_node", + "evidence_artifact_ids", + "certificate_mode_role", + "proposition_hash" + ], + "additionalProperties": false, + "properties": { + "theorem_name": { "type": "string", "minLength": 1 }, + "normalized_proposition": { + "type": "string", + "minLength": 1, + "description": "Whitespace-normalized Lean proposition" + }, + "theorem_category": { + "type": "string", + "enum": [ + "trace_safety", + "event_safety", + "trust_boundary", + "resource_scope", + "handoff_safety", + "contract", + "effect_frame", + "transition", + "compositional", + "mode_aggregate", + "mode_witness" + ] + }, + "generation_node": { + "type": "string", + "minLength": 1, + "description": "Structured codegen node id (source of the theorem)" + }, + "source_span": { + "type": "object", + "additionalProperties": false, + "required": ["start_line", "end_line"], + "properties": { + "start_line": { "type": "integer", "minimum": 1 }, + "end_line": { "type": "integer", "minimum": 1 }, + "path": { "type": "string", "minLength": 1 } + }, + "description": "Optional source span when available; generation_node is authoritative" + }, + "evidence_artifact_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Evidence artifact IDs (event/handoff/contract/frame ids) bound into this theorem" + }, + "certificate_mode_role": { + "type": "string", + "enum": ["required", "supporting", "aggregate", "final_witness"] + }, + "proposition_hash": { + "$ref": "common.defs.json#/$defs/hex_digest", + "description": "SHA-256 of the normalized proposition string" + } + } + } + } +} From 0eedb22796e20bcf257f667057a8f6184fcad001 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:01:02 -0700 Subject: [PATCH 09/24] Close PF-Core release bundles with verify-bundle results. Expand bundle packaging and verification so consumers get a schema-backed BundleVerificationResult instead of ad-hoc success booleans. --- python/pcs_core/pf_core_bundle.py | 1064 ++++++++++++++++- python/tests/test_pf_core_bundle.py | 195 ++- ...oreBundleVerificationResult.v0.schema.json | 73 ++ ...PFCoreReleaseBundleManifest.v0.schema.json | 31 +- 4 files changed, 1268 insertions(+), 95 deletions(-) create mode 100644 schemas/PFCoreBundleVerificationResult.v0.schema.json diff --git a/python/pcs_core/pf_core_bundle.py b/python/pcs_core/pf_core_bundle.py index 9afd7f8..0cc6ba3 100644 --- a/python/pcs_core/pf_core_bundle.py +++ b/python/pcs_core/pf_core_bundle.py @@ -1,18 +1,26 @@ -"""PF-Core release bundle assembly and validation.""" +"""PF-Core release bundle assembly, validation, and closed-bundle verification.""" from __future__ import annotations import hashlib import json +import os import platform import shutil +import subprocess +import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Any, Mapping +from pcs_core.asset_resolver import ( + distribution_root, + pin_path, + relative_to_distribution, + require_lean_root, +) from pcs_core.hash import CANONICALIZATION_VERSION, canonical_hash from pcs_core.lean_check import compute_proof_term_hash, pfcore_generated_dir -from pcs_core.paths import package_dir, repo_root from pcs_core.pf_core_lean_codegen import ( compute_lean_environment_hash, compute_lean_environment_hash_from_bundle, @@ -23,6 +31,12 @@ from pcs_core.safe_paths import UnsafePathError, resolve_contained_file, strip_repo_generated_prefix from pcs_core.validate import ValidationError, validate_artifact, validate_schema +EVIDENCE_DIR_NAME = "evidence" +EVIDENCE_MANIFEST_NAME = "evidence_manifest.json" +SEMANTIC_PROJECTION_NAME = "PFCoreSemanticProjection.v0.json" +THEOREM_MANIFEST_NAME = "PFCoreTheoremManifest.v0.json" +BUNDLE_VERIFICATION_RESULT_NAME = "PFCoreBundleVerificationResult.v0.json" + @dataclass(frozen=True) class BundleIssue: @@ -44,6 +58,50 @@ def to_dict(self) -> dict[str, Any]: } +@dataclass +class BundleVerificationCheck: + check_id: str + status: str + detail: str = "" + + +@dataclass +class BundleVerificationResult: + ok: bool + bundle_dir: Path + issues: list[BundleIssue] = field(default_factory=list) + checks: list[BundleVerificationCheck] = field(default_factory=list) + manifest_digest: str | None = None + result_path: Path | None = None + + def to_dict(self) -> dict[str, Any]: + from pcs_core import __version__ + + payload: dict[str, Any] = { + "schema_version": "v0", + "artifact_type": "PFCoreBundleVerificationResult.v0", + "canonicalization_version": CANONICALIZATION_VERSION, + "ok": self.ok, + "bundle_dir": str(self.bundle_dir), + "verifier": "pcs-core", + "verifier_version": str(__version__), + "checks": [ + { + "check_id": c.check_id, + "status": c.status, + **({"detail": c.detail} if c.detail else {}), + } + for c in self.checks + ], + "issues": [{"code": i.code, "message": i.message} for i in self.issues], + "signature_or_digest": "sha256:" + "0" * 64, + } + if self.manifest_digest: + payload["manifest_digest"] = self.manifest_digest + payload["signature_or_digest"] = canonical_hash(payload) + return payload + + def _load_json(path: Path) -> dict[str, Any]: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): @@ -94,6 +152,17 @@ def _file_sha256(path: Path) -> str: return f"sha256:{digest}" +def _safe_bundle_dest(bundle_dir: Path, rel: str) -> Path: + normalized = rel.replace("\\", "/") + dest = bundle_dir.joinpath(*Path(normalized).parts) + dest.parent.mkdir(parents=True, exist_ok=True) + try: + dest.resolve().relative_to(bundle_dir.resolve()) + except ValueError as exc: + raise UnsafePathError(f"bundle dest escapes bundle root: {rel}") from exc + return dest + + def collect_tool_versions() -> dict[str, str]: """Deterministic tool pins recorded beside release bundles (not hashed into manifest).""" from pcs_core import __version__ @@ -106,12 +175,8 @@ def collect_tool_versions() -> dict[str, str]: toolchain = lean_dir() / "lean-toolchain" if toolchain.is_file(): versions["lean_toolchain"] = toolchain.read_text(encoding="utf-8").strip() - elan_pin = repo_root() / "pins" / "elan.json" - if not elan_pin.is_file(): - bundled_pin = package_dir() / "pins" / "elan.json" - if bundled_pin.is_file(): - elan_pin = bundled_pin - if elan_pin.is_file(): + elan_pin = pin_path("elan.json", required=False) + if elan_pin is not None and elan_pin.is_file(): try: pin = json.loads(elan_pin.read_text(encoding="utf-8")) if isinstance(pin, dict) and pin.get("version"): @@ -120,12 +185,8 @@ def collect_tool_versions() -> dict[str, str]: versions["elan_sha256"] = str(pin["sha256"]) except (OSError, json.JSONDecodeError): pass - certifyedge_pin = repo_root() / "pins" / "certifyedge.json" - if not certifyedge_pin.is_file(): - bundled_ce = package_dir() / "pins" / "certifyedge.json" - if bundled_ce.is_file(): - certifyedge_pin = bundled_ce - if certifyedge_pin.is_file(): + certifyedge_pin = pin_path("certifyedge.json", required=False) + if certifyedge_pin is not None and certifyedge_pin.is_file(): try: pin = json.loads(certifyedge_pin.read_text(encoding="utf-8")) if isinstance(pin, dict): @@ -133,6 +194,13 @@ def collect_tool_versions() -> dict[str, str]: versions["certifyedge_image_digest"] = str(pin["image_digest"]) if pin.get("version"): versions["certifyedge"] = str(pin["version"]) + if pin.get("status"): + versions["certifyedge_pin_status"] = str(pin["status"]) + if pin.get("provision_strategy"): + versions["certifyedge_provision_strategy"] = str(pin["provision_strategy"]) + from pcs_core.certifyedge_pin import pin_identity_from + + versions["certifyedge_pin_identity"] = pin_identity_from(pin) except (OSError, json.JSONDecodeError): pass return versions @@ -150,11 +218,24 @@ def write_tool_versions(out_dir: Path) -> Path: return path +def write_certifyedge_pin_record(out_dir: Path) -> Path | None: + """Copy trusted checker pin snapshot into the release bundle.""" + try: + from pcs_core.certifyedge_pin import certifyedge_pin_record_for_bundle + + record = certifyedge_pin_record_for_bundle() + except (OSError, json.JSONDecodeError, ValueError): + return None + path = out_dir / "certifyedge_pin.json" + path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return path + + def build_kernel_manifest() -> dict[str, Any]: """Per-file PF-Core kernel manifest for self-contained bundle validation.""" files: list[dict[str, str]] = [] for path in pfcore_kernel_lean_paths(): - rel = path.relative_to(repo_root()).as_posix() + rel = relative_to_distribution(path) files.append({"path": rel, "sha256": _file_sha256(path)}) manifest: dict[str, Any] = { "schema_version": "v0", @@ -194,7 +275,11 @@ def compute_pfcore_kernel_hash_from_manifest( def _copy_kernel_into_bundle(out_dir: Path, manifest: Mapping[str, Any]) -> None: kernel_root = out_dir / "kernel" - repo = repo_root() + asset_root = distribution_root() + if asset_root is None: + from pcs_core.paths import repo_root + + asset_root = repo_root() entries = manifest.get("files") if not isinstance(entries, list): return @@ -206,7 +291,7 @@ def _copy_kernel_into_bundle(out_dir: Path, manifest: Mapping[str, Any]) -> None rel = str(entry.get("path") or "") if not rel: continue - src = resolve_contained_file(repo, rel, allowed_suffixes=frozenset({".lean"})) + src = resolve_contained_file(asset_root, rel, allowed_suffixes=frozenset({".lean"})) normalized = rel.replace("\\", "/") dest_candidate = kernel_root.joinpath(*Path(normalized).parts) dest_candidate.parent.mkdir(parents=True, exist_ok=True) @@ -219,21 +304,242 @@ def _copy_kernel_into_bundle(out_dir: Path, manifest: Mapping[str, Any]) -> None def _copy_lean_environment_into_bundle(out_dir: Path) -> None: """Copy pinned Lean toolchain and lake project files into the bundle root.""" - lean_root = repo_root() / "lean" - toolchain_src = lean_root / "lean-toolchain" + lean_project = require_lean_root() + toolchain_src = lean_project / "lean-toolchain" if toolchain_src.is_file(): shutil.copy2(toolchain_src, out_dir / "lean-toolchain") dest_toolchain = out_dir / "lean" / "lean-toolchain" dest_toolchain.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(toolchain_src, dest_toolchain) - for rel in ("lakefile.lean", "lake-manifest.json"): - src = lean_root / rel + for rel in ("lakefile.lean", "lake-manifest.json", "PFCore.lean"): + src = lean_project / rel if src.is_file(): dest = out_dir / "lean" / rel dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest) +def compute_evidence_manifest_digest(manifest: Mapping[str, Any]) -> str: + payload = {k: v for k, v in manifest.items() if k != "evidence_manifest_digest"} + return canonical_hash(dict(payload)) + + +def build_evidence_manifest( + *, + files: list[dict[str, Any]], + evidence_selection_policy: str, + evidence_selection_policy_version: str, +) -> dict[str, Any]: + body: dict[str, Any] = { + "schema_version": "v0", + "artifact_type": "PFCoreEvidenceManifest.v0", + "canonicalization_version": CANONICALIZATION_VERSION, + "evidence_selection_policy": evidence_selection_policy, + "evidence_selection_policy_version": evidence_selection_policy_version, + "files": sorted(files, key=lambda item: str(item.get("path") or "")), + "evidence_manifest_digest": "sha256:" + "0" * 64, + } + body["evidence_manifest_digest"] = compute_evidence_manifest_digest(body) + return body + + +def _unique_evidence_basename(preferred: str, used: set[str]) -> str: + base = Path(preferred).name or "artifact.json" + if base not in used: + used.add(base) + return base + stem = Path(base).stem + suffix = Path(base).suffix or ".json" + index = 2 + while True: + candidate = f"{stem}_{index}{suffix}" + if candidate not in used: + used.add(candidate) + return candidate + index += 1 + + +def _copy_evidence_into_bundle( + out_dir: Path, + *, + trace: Mapping[str, Any], + trace_path: Path, + certificate: Mapping[str, Any], +) -> tuple[str | None, str | None]: + """Copy selected evidence artifacts into ``evidence/`` and write evidence_manifest. + + Returns ``(evidence_manifest_rel, evidence_manifest_hash)``. + """ + from pcs_core.pf_core_resolved_evidence import ( + EVIDENCE_SELECTION_FILENAME, + EVIDENCE_SELECTION_POLICY, + EVIDENCE_SELECTION_POLICY_VERSION, + EvidenceResolutionError, + resolve_pf_core_evidence, + ) + + mode = str( + certificate.get("certificate_mode") + or resolve_certificate_mode(trace, trace_path=trace_path) + ) + try: + evidence = resolve_pf_core_evidence( + dict(trace), + trace_path=trace_path, + certificate_mode=mode, + ) + except EvidenceResolutionError: + # Runtime-only bundles may lack selectable evidence; emit empty closed list. + evidence = None + + evidence_root = out_dir / EVIDENCE_DIR_NAME + evidence_root.mkdir(parents=True, exist_ok=True) + used_names: set[str] = set() + files: list[dict[str, Any]] = [] + + def _add_file( + src: Path | None, + *, + role: str, + artifact_id: str | None, + artifact_type: str | None, + embedded_payload: Mapping[str, Any] | None = None, + preferred_name: str | None = None, + ) -> None: + if src is not None and src.is_file(): + name = _unique_evidence_basename(preferred_name or src.name, used_names) + rel = f"{EVIDENCE_DIR_NAME}/{name}" + dest = _safe_bundle_dest(out_dir, rel) + shutil.copy2(src, dest) + entry: dict[str, Any] = { + "path": rel, + "sha256": _file_sha256(dest), + "role": role, + } + if artifact_id: + entry["artifact_id"] = artifact_id + if artifact_type: + entry["artifact_type"] = artifact_type + files.append(entry) + return + if embedded_payload is not None: + name = _unique_evidence_basename( + preferred_name or f"{role}-{artifact_id or 'embedded'}.json", + used_names, + ) + rel = f"{EVIDENCE_DIR_NAME}/{name}" + dest = _safe_bundle_dest(out_dir, rel) + dest.write_text( + json.dumps(dict(embedded_payload), indent=2) + "\n", + encoding="utf-8", + ) + entry = { + "path": rel, + "sha256": _file_sha256(dest), + "role": role, + } + if artifact_id: + entry["artifact_id"] = artifact_id + if artifact_type: + entry["artifact_type"] = artifact_type + files.append(entry) + + if evidence is not None: + for handoff in evidence.handoffs: + _add_file( + handoff.path, + role="handoff", + artifact_id=handoff.handoff_id, + artifact_type="PFCoreHandoff.v0", + embedded_payload=None if handoff.path else handoff.artifact, + preferred_name=f"handoff-{handoff.handoff_id}.json", + ) + for contract in evidence.contracts: + _add_file( + contract.path, + role="contract", + artifact_id=contract.contract_id, + artifact_type="PFCoreContract.v0", + embedded_payload=None if contract.path else contract.artifact, + preferred_name=f"contract-{contract.contract_id}.json", + ) + if evidence.effect_frame is not None: + frame_id = str(evidence.effect_frame.get("frame_id") or "effect-frame") + _add_file( + evidence.effect_frame_path, + role="effect_frame", + artifact_id=frame_id, + artifact_type="PFCoreEffectFrame.v0", + embedded_payload=( + None if evidence.effect_frame_path else evidence.effect_frame + ), + preferred_name=f"effect-frame-{frame_id}.json", + ) + selection_path = trace_path.parent / EVIDENCE_SELECTION_FILENAME + if selection_path.is_file() and not isinstance(trace.get("evidence_selection"), Mapping): + _add_file( + selection_path, + role="policy", + artifact_id="evidence_selection", + artifact_type="PFCoreEvidenceSelection.v0", + preferred_name=EVIDENCE_SELECTION_FILENAME, + ) + elif isinstance(trace.get("evidence_selection"), Mapping): + _add_file( + None, + role="policy", + artifact_id="evidence_selection", + artifact_type="PFCoreEvidenceSelection.v0", + embedded_payload=dict(trace["evidence_selection"]), + preferred_name=EVIDENCE_SELECTION_FILENAME, + ) + policy = evidence.evidence_selection_policy + policy_version = evidence.evidence_selection_policy_version + else: + policy = EVIDENCE_SELECTION_POLICY + policy_version = EVIDENCE_SELECTION_POLICY_VERSION + + manifest = build_evidence_manifest( + files=files, + evidence_selection_policy=policy, + evidence_selection_policy_version=policy_version, + ) + rel = EVIDENCE_MANIFEST_NAME + dest = _safe_bundle_dest(out_dir, rel) + dest.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return rel, str(manifest["evidence_manifest_digest"]) + + +def _discover_companion_artifact( + *, + name: str, + certificate_path: Path, + lean_check_result_path: Path | None, + proof_path: Path | None, + artifact_paths_key: str, +) -> Path | None: + if lean_check_result_path is not None and lean_check_result_path.is_file(): + try: + result = _load_json(lean_check_result_path) + except (OSError, json.JSONDecodeError, ValueError): + result = {} + paths = result.get("artifact_paths") + if isinstance(paths, Mapping): + raw = paths.get(artifact_paths_key) + if isinstance(raw, str) and raw.strip(): + candidate = Path(raw) + if candidate.is_file(): + return candidate.resolve() + sibling = certificate_path.parent / name + if sibling.is_file(): + return sibling.resolve() + if proof_path is not None: + sibling = proof_path.parent / name + if sibling.is_file(): + return sibling.resolve() + return None + + def build_release_manifest( *, trace: Mapping[str, Any], @@ -241,7 +547,14 @@ def build_release_manifest( trace_rel: str, certificate_rel: str, lean_check_result_rel: str | None = None, + lean_check_result_hash: str | None = None, proof_rel: str | None = None, + semantic_projection_rel: str | None = None, + semantic_projection_hash: str | None = None, + theorem_manifest_rel: str | None = None, + theorem_manifest_hash: str | None = None, + evidence_manifest_rel: str | None = None, + evidence_manifest_hash: str | None = None, kernel_manifest: Mapping[str, Any] | None = None, trace_path: Path | None = None, bundle_dir: Path | None = None, @@ -251,8 +564,13 @@ def build_release_manifest( trace_hash = compute_trace_hash(dict(trace)) proof_term_hash = str(certificate.get("proof_term_hash") or "") kernel_manifest = kernel_manifest or build_kernel_manifest() - kernel_root = bundle_dir if bundle_dir is not None else repo_root() - kernel_files_root = kernel_root / "kernel" if bundle_dir is not None else repo_root() + asset_root = distribution_root() + if asset_root is None: + from pcs_core.paths import repo_root + + asset_root = repo_root() + kernel_root = bundle_dir if bundle_dir is not None else asset_root + kernel_files_root = kernel_root / "kernel" if bundle_dir is not None else asset_root claim_class = str(certificate.get("claim_class") or "") manifest: dict[str, Any] = { "schema_version": "v0", @@ -284,16 +602,44 @@ def build_release_manifest( manifest["lean_environment_hash"] = compute_lean_environment_hash() if lean_check_result_rel: manifest["lean_check_result_path"] = lean_check_result_rel + if lean_check_result_hash and lean_check_result_hash.startswith("sha256:"): + manifest["lean_check_result_hash"] = lean_check_result_hash if proof_rel: manifest["proof_path"] = proof_rel - # LeanKernelChecked requires proof + lean check paths in the closed schema. + if semantic_projection_rel: + manifest["semantic_projection_path"] = semantic_projection_rel + if semantic_projection_hash and semantic_projection_hash.startswith("sha256:"): + manifest["semantic_projection_hash"] = semantic_projection_hash + if theorem_manifest_rel: + manifest["theorem_manifest_path"] = theorem_manifest_rel + if theorem_manifest_hash and theorem_manifest_hash.startswith("sha256:"): + manifest["theorem_manifest_hash"] = theorem_manifest_hash + if evidence_manifest_rel: + manifest["evidence_manifest_path"] = evidence_manifest_rel + if evidence_manifest_hash and evidence_manifest_hash.startswith("sha256:"): + manifest["evidence_manifest_hash"] = evidence_manifest_hash + # LeanKernelChecked requires closed projection/evidence/proof paths. if claim_class == "LeanKernelChecked": - if not manifest.get("proof_path"): - raise ValueError("LeanKernelChecked bundle requires proof_path") - if not manifest.get("lean_check_result_path"): - raise ValueError("LeanKernelChecked bundle requires lean_check_result_path") - if not str(manifest.get("proof_term_hash") or "").startswith("sha256:"): - raise ValueError("LeanKernelChecked bundle requires proof_term_hash") + missing: list[str] = [] + for key in ( + "proof_path", + "lean_check_result_path", + "lean_check_result_hash", + "proof_term_hash", + "semantic_projection_path", + "semantic_projection_hash", + "theorem_manifest_path", + "theorem_manifest_hash", + "evidence_manifest_path", + "evidence_manifest_hash", + ): + value = str(manifest.get(key) or "") + if not value or (key.endswith("_hash") and not value.startswith("sha256:")): + missing.append(key) + if missing: + raise ValueError( + "LeanKernelChecked bundle requires closed fields: " + ", ".join(missing) + ) manifest["signature_or_digest"] = canonical_hash(manifest) return manifest, kernel_manifest @@ -304,8 +650,10 @@ def bundle_release( out_dir: Path, *, lean_check_result_path: Path | None = None, + semantic_projection_path: Path | None = None, + theorem_manifest_path: Path | None = None, ) -> Path: - """Copy trace, certificate, optional LeanCheckResult, proof file, and manifest.""" + """Copy trace, certificate, optional LeanCheckResult, proof, projection, evidence, and manifest.""" trace = _load_json(trace_path) certificate = _load_json(cert_path) out_dir.mkdir(parents=True, exist_ok=True) @@ -316,10 +664,12 @@ def bundle_release( shutil.copy2(cert_path, cert_dest) lean_rel: str | None = None + lean_hash: str | None = None if lean_check_result_path is not None and lean_check_result_path.is_file(): lean_dest = out_dir / "LeanCheckResult.v0.json" shutil.copy2(lean_check_result_path, lean_dest) lean_rel = lean_dest.name + lean_hash = _file_sha256(lean_dest) proof_rel: str | None = None proof_path = _resolve_proof_path(certificate) @@ -328,17 +678,84 @@ def bundle_release( shutil.copy2(proof_path, proof_dest) proof_rel = proof_dest.name + projection_src = semantic_projection_path or _discover_companion_artifact( + name=SEMANTIC_PROJECTION_NAME, + certificate_path=cert_path, + lean_check_result_path=lean_check_result_path, + proof_path=proof_path, + artifact_paths_key="semantic_projection", + ) + projection_rel: str | None = None + projection_hash: str | None = None + if projection_src is not None and projection_src.is_file(): + projection_dest = out_dir / SEMANTIC_PROJECTION_NAME + shutil.copy2(projection_src, projection_dest) + projection_rel = projection_dest.name + try: + projection_obj = _load_json(projection_dest) + projection_hash = str(projection_obj.get("projection_hash") or "") + except (OSError, json.JSONDecodeError, ValueError): + projection_hash = _file_sha256(projection_dest) + if not projection_hash.startswith("sha256:"): + projection_hash = _file_sha256(projection_dest) + + theorem_src = theorem_manifest_path or _discover_companion_artifact( + name=THEOREM_MANIFEST_NAME, + certificate_path=cert_path, + lean_check_result_path=lean_check_result_path, + proof_path=proof_path, + artifact_paths_key="theorem_manifest", + ) + theorem_rel: str | None = None + theorem_hash: str | None = None + if theorem_src is not None and theorem_src.is_file(): + theorem_dest = out_dir / THEOREM_MANIFEST_NAME + shutil.copy2(theorem_src, theorem_dest) + theorem_rel = theorem_dest.name + try: + from pcs_core.pf_core_theorem_manifest import ( + compute_theorem_manifest_digest, + load_theorem_manifest, + ) + + theorem_obj = load_theorem_manifest(theorem_dest) + theorem_hash = str( + theorem_obj.get("theorem_manifest_digest") + or compute_theorem_manifest_digest(theorem_obj) + ) + except (OSError, json.JSONDecodeError, ValueError): + theorem_hash = _file_sha256(theorem_dest) + + evidence_rel, evidence_hash = _copy_evidence_into_bundle( + out_dir, + trace=trace, + trace_path=trace_path, + certificate=certificate, + ) + kernel_manifest = build_kernel_manifest() _copy_kernel_into_bundle(out_dir, kernel_manifest) _copy_lean_environment_into_bundle(out_dir) + # For LeanKernelChecked, lean-check result is required; synthesize hash if path present. + claim_class = str(certificate.get("claim_class") or "") + if claim_class == "LeanKernelChecked" and lean_rel and not lean_hash: + lean_hash = _file_sha256(out_dir / lean_rel) + manifest, _kernel_manifest = build_release_manifest( trace=trace, certificate=certificate, trace_rel=trace_dest.name, certificate_rel=cert_dest.name, lean_check_result_rel=lean_rel, + lean_check_result_hash=lean_hash, proof_rel=proof_rel, + semantic_projection_rel=projection_rel, + semantic_projection_hash=projection_hash, + theorem_manifest_rel=theorem_rel, + theorem_manifest_hash=theorem_hash, + evidence_manifest_rel=evidence_rel, + evidence_manifest_hash=evidence_hash, kernel_manifest=kernel_manifest, trace_path=trace_path, bundle_dir=out_dir, @@ -348,6 +765,7 @@ def bundle_release( kernel_manifest_path = out_dir / "kernel_manifest.json" kernel_manifest_path.write_text(json.dumps(kernel_manifest, indent=2) + "\n", encoding="utf-8") write_tool_versions(out_dir) + write_certifyedge_pin_record(out_dir) return manifest_path @@ -360,11 +778,85 @@ def _bundle_resolve( return resolve_contained_file(bundle_dir, rel, allowed_suffixes=allowed_suffixes) +def _validate_hashed_json_artifact( + result: BundleValidationResult, + *, + bundle_root: Path, + rel: str, + expected_hash: str, + artifact_type: str | None, + missing_code: str, + hash_mismatch_code: str, + hash_field: str = "projection_hash", +) -> Path | None: + if not rel: + return None + try: + path = _bundle_resolve(bundle_root, rel, allowed_suffixes=frozenset({".json"})) + except UnsafePathError as exc: + result.issues.append(BundleIssue(f"{missing_code}Unsafe", str(exc))) + return None + if not path.is_file(): + result.issues.append(BundleIssue(missing_code, f"missing: {rel}")) + return None + try: + payload = _load_json(path) + except (OSError, json.JSONDecodeError, ValueError) as exc: + result.issues.append(BundleIssue(f"{missing_code}Unreadable", str(exc))) + return path + if artifact_type: + try: + validate_artifact(payload, artifact_type, release_grade=True) + except ValidationError as exc: + for err in exc.errors or [str(exc)]: + result.issues.append(BundleIssue(f"{missing_code}Invalid", err)) + actual = "" + declared = "" + if artifact_type == "PFCoreSemanticProjection.v0": + actual = str(payload.get("projection_hash") or "") + declared = actual + elif artifact_type == "PFCoreTheoremManifest.v0": + from pcs_core.pf_core_theorem_manifest import compute_theorem_manifest_digest + + declared = str(payload.get("theorem_manifest_digest") or "") + actual = compute_theorem_manifest_digest(payload) + if declared and declared != actual: + result.issues.append( + BundleIssue( + hash_mismatch_code, + f"{rel} declared digest {declared!r} != recomputed {actual!r}", + ) + ) + elif artifact_type == "PFCoreEvidenceManifest.v0": + declared = str(payload.get("evidence_manifest_digest") or "") + actual = compute_evidence_manifest_digest(payload) + if declared and declared != actual: + result.issues.append( + BundleIssue( + hash_mismatch_code, + f"{rel} declared digest {declared!r} != recomputed {actual!r}", + ) + ) + elif hash_field in payload: + actual = str(payload.get(hash_field) or "") + if not actual.startswith("sha256:"): + actual = _file_sha256(path) + if expected_hash.startswith("sha256:") and actual != expected_hash: + result.issues.append( + BundleIssue( + hash_mismatch_code, + f"{rel} hash {actual!r} != manifest {expected_hash!r}", + ) + ) + return path + + def validate_bundle(bundle_dir: Path) -> BundleValidationResult: """Validate a PF-Core release bundle directory and manifest hashes. Schema validation of the release and kernel manifests runs **before** any - referenced path is followed. + referenced path is followed. This is the lower-cost structural command; + stable releases must also run ``verify-bundle``. """ result = BundleValidationResult(ok=False, bundle_dir=bundle_dir) try: @@ -530,12 +1022,82 @@ def validate_bundle(bundle_dir: Path) -> BundleValidationResult: ) lean_check_rel = str(manifest.get("lean_check_result_path") or "") + lean_check_hash = str(manifest.get("lean_check_result_hash") or "") if lean_check_rel: try: - _bundle_resolve(bundle_root, lean_check_rel, allowed_suffixes=frozenset({".json"})) + lean_path = _bundle_resolve( + bundle_root, lean_check_rel, allowed_suffixes=frozenset({".json"}) + ) + if lean_check_hash.startswith("sha256:"): + actual_lean = _file_sha256(lean_path) + if actual_lean != lean_check_hash: + result.issues.append( + BundleIssue( + "LeanCheckResultHashMismatch", + f"lean check result hash {actual_lean!r} != manifest " + f"{lean_check_hash!r}", + ) + ) except UnsafePathError as exc: result.issues.append(BundleIssue("LeanCheckResultPathUnsafe", str(exc))) + _validate_hashed_json_artifact( + result, + bundle_root=bundle_root, + rel=str(manifest.get("semantic_projection_path") or ""), + expected_hash=str(manifest.get("semantic_projection_hash") or ""), + artifact_type="PFCoreSemanticProjection.v0", + missing_code="SemanticProjection", + hash_mismatch_code="SemanticProjectionHashMismatch", + ) + _validate_hashed_json_artifact( + result, + bundle_root=bundle_root, + rel=str(manifest.get("theorem_manifest_path") or ""), + expected_hash=str(manifest.get("theorem_manifest_hash") or ""), + artifact_type="PFCoreTheoremManifest.v0", + missing_code="TheoremManifest", + hash_mismatch_code="TheoremManifestHashMismatch", + ) + evidence_path = _validate_hashed_json_artifact( + result, + bundle_root=bundle_root, + rel=str(manifest.get("evidence_manifest_path") or ""), + expected_hash=str(manifest.get("evidence_manifest_hash") or ""), + artifact_type="PFCoreEvidenceManifest.v0", + missing_code="EvidenceManifest", + hash_mismatch_code="EvidenceManifestHashMismatch", + ) + if evidence_path is not None and evidence_path.is_file(): + try: + evidence_manifest = _load_json(evidence_path) + entries = evidence_manifest.get("files") + if isinstance(entries, list): + for entry in entries: + if not isinstance(entry, Mapping): + continue + rel = str(entry.get("path") or "") + expected = str(entry.get("sha256") or "") + if not rel: + continue + try: + file_path = _bundle_resolve( + bundle_root, rel, allowed_suffixes=frozenset({".json"}) + ) + except UnsafePathError as exc: + result.issues.append(BundleIssue("EvidencePathUnsafe", str(exc))) + continue + actual = _file_sha256(file_path) + if expected.startswith("sha256:") and actual != expected: + result.issues.append( + BundleIssue( + "EvidenceFileHashMismatch", + f"evidence file {rel} hash {actual!r} != {expected!r}", + ) + ) + except (OSError, json.JSONDecodeError, ValueError) as exc: + result.issues.append(BundleIssue("EvidenceManifestUnreadable", str(exc))) + if manifest_kernel_hash.startswith("sha256:"): try: if kernel_manifest is None: @@ -603,8 +1165,6 @@ def validate_bundle(bundle_dir: Path) -> BundleValidationResult: attest_path = bundle_root / EXTERNAL_ATTESTATION_NAME notice_path = bundle_root / ABSENCE_NOTICE_NAME if attest_path.is_file() or notice_path.is_file(): - import os - require_live = os.environ.get("PF_CORE_CERTIFYEDGE_REQUIRE_LIVE", "").strip().lower() in { "1", "true", @@ -621,3 +1181,437 @@ def validate_bundle(bundle_dir: Path) -> BundleValidationResult: result.ok = not result.issues return result + + +def _record_check( + result: BundleVerificationResult, + check_id: str, + *, + ok: bool, + detail: str = "", + skipped: bool = False, +) -> None: + if skipped: + status = "skipped" + else: + status = "passed" if ok else "failed" + result.checks.append(BundleVerificationCheck(check_id, status, detail)) + + +def _stage_evidence_for_resolve(bundle_root: Path, evidence_manifest: Mapping[str, Any]) -> Path: + """Stage trace + evidence siblings so resolve_pf_core_evidence can rediscover them.""" + staging = Path(tempfile.mkdtemp(prefix="pfcore-verify-evidence-")) + trace_rel = "trace.json" + shutil.copy2(bundle_root / trace_rel, staging / "trace.json") + entries = evidence_manifest.get("files") + if isinstance(entries, list): + for entry in entries: + if not isinstance(entry, Mapping): + continue + rel = str(entry.get("path") or "") + if not rel: + continue + src = _bundle_resolve(bundle_root, rel, allowed_suffixes=frozenset({".json"})) + role = str(entry.get("role") or "") + dest_name = src.name + if role == "policy" and dest_name != "evidence_selection.json": + dest_name = "evidence_selection.json" + shutil.copy2(src, staging / dest_name) + return staging + + +def ensure_bundled_lean_toolchain(bundle_root: Path) -> tuple[bool, str]: + """Install or select the Lean toolchain pinned in the bundle.""" + toolchain_path = bundle_root / "lean-toolchain" + if not toolchain_path.is_file(): + toolchain_path = bundle_root / "lean" / "lean-toolchain" + if not toolchain_path.is_file(): + return False, "bundle missing lean-toolchain" + toolchain = toolchain_path.read_text(encoding="utf-8").strip() + if not toolchain: + return False, "empty lean-toolchain pin" + elan = shutil.which("elan") + if elan: + proc = subprocess.run( + [elan, "toolchain", "install", toolchain], + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip() + # Already installed is fine; elan may still return non-zero for some pins. + if "is already installed" not in detail.lower() and "already installed" not in detail.lower(): + # Fall through: lake may still resolve via PATH / lean-toolchain file. + return True, f"elan install warning: {detail or 'non-zero'}; continuing with lake" + return True, f"toolchain ready: {toolchain}" + if shutil.which("lake") or (platform.system() == "Windows" and shutil.which("wsl")): + return True, f"elan unavailable; using lake with pin {toolchain}" + return False, "neither elan nor lake available to select Lean toolchain" + + +def compile_bundled_proof(bundle_root: Path, proof_rel: str) -> tuple[bool, str]: + """Compile the bundled generated proof against the bundled PF-Core kernel.""" + from pcs_core.lean_check import _run_lake + + try: + proof_src = _bundle_resolve( + bundle_root, proof_rel, allowed_suffixes=frozenset({".lean"}) + ) + except UnsafePathError as exc: + return False, str(exc) + + work = Path(tempfile.mkdtemp(prefix="pfcore-verify-lean-")) + try: + # Lake project root + for name in ("lakefile.lean", "lake-manifest.json", "PFCore.lean"): + src = bundle_root / "lean" / name + if src.is_file(): + shutil.copy2(src, work / name) + toolchain = bundle_root / "lean-toolchain" + if not toolchain.is_file(): + toolchain = bundle_root / "lean" / "lean-toolchain" + if toolchain.is_file(): + shutil.copy2(toolchain, work / "lean-toolchain") + + kernel_root = bundle_root / "kernel" + pfcore_src = kernel_root / "lean" / "PFCore" + if not pfcore_src.is_dir(): + return False, "bundled kernel missing lean/PFCore" + shutil.copytree(pfcore_src, work / "PFCore") + generated = work / "PFCore" / "Generated" + generated.mkdir(parents=True, exist_ok=True) + proof_dest = generated / proof_src.name + shutil.copy2(proof_src, proof_dest) + + if not (work / "lakefile.lean").is_file(): + return False, "bundle missing lean/lakefile.lean" + if not (work / "PFCore.lean").is_file(): + return False, "bundle missing lean/PFCore.lean" + if not shutil.which("lake") and not ( + platform.system() == "Windows" and shutil.which("wsl") + ): + return False, "lake executable not found" + + build = _run_lake(["build", "PFCore"], cwd=work) + if build.returncode != 0: + detail = (build.stderr or build.stdout or "").strip() + return False, detail or "lake build PFCore failed against bundled kernel" + + rel = Path("PFCore") / "Generated" / proof_src.name + compile_proc = _run_lake(["env", "lean", rel.as_posix()], cwd=work) + if compile_proc.returncode != 0: + detail = (compile_proc.stderr or compile_proc.stdout or "").strip() + return False, detail or "lake env lean failed on bundled proof" + return True, "ok" + finally: + shutil.rmtree(work, ignore_errors=True) + + +def verify_bundle( + bundle_dir: Path, + *, + skip_lean_compile: bool = False, + result_out: Path | None = None, +) -> BundleVerificationResult: + """Independently verify a closed PF-Core release bundle. + + Performs structural validation, containment path resolution, digest checks, + semantic-projection replay, theorem-metadata reconstruction, certificate + comparison, Lean toolchain selection, bundled-kernel proof compile, and + external attestation checks when present. Emits a digest-bound verification + result. ``validate-bundle`` remains the cheaper structural-only command; + stable releases must require ``verify-bundle``. + """ + result = BundleVerificationResult(ok=False, bundle_dir=bundle_dir) + structural = validate_bundle(bundle_dir) + for issue in structural.issues: + result.issues.append(issue) + _record_check( + result, + "validate_closed_manifests", + ok=structural.ok, + detail="validate-bundle structural checks", + ) + if not structural.ok: + result.ok = False + _write_verification_result(result, result_out) + return result + + try: + bundle_root = bundle_dir.resolve(strict=True) + manifest = _load_json(bundle_root / "manifest.json") + except (OSError, json.JSONDecodeError, ValueError) as exc: + result.issues.append(BundleIssue("ManifestUnreadable", str(exc))) + _record_check(result, "load_manifest", ok=False, detail=str(exc)) + _write_verification_result(result, result_out) + return result + + result.manifest_digest = str(manifest.get("signature_or_digest") or "") + claim_class = str(manifest.get("claim_class") or "") + certificate = _load_json( + _bundle_resolve( + bundle_root, + str(manifest.get("certificate_path") or "certificate.json"), + allowed_suffixes=frozenset({".json"}), + ) + ) + trace = _load_json( + _bundle_resolve( + bundle_root, + str(manifest.get("trace_path") or "trace.json"), + allowed_suffixes=frozenset({".json"}), + ) + ) + + # Certificate digest binding compare + try: + validate_artifact(certificate, "PFCoreCertificate.v0", release_grade=True) + recomputed_cert_digest = canonical_hash(dict(certificate)) + declared = str(certificate.get("signature_or_digest") or "") + cert_ok = (not declared) or declared == recomputed_cert_digest + if not cert_ok: + result.issues.append( + BundleIssue( + "CertificateDigestMismatch", + f"certificate digest {declared!r} != recomputed {recomputed_cert_digest!r}", + ) + ) + # Compare certificate digests against closed manifest / bundled files + for field, manifest_key in ( + ("trace_hash", "trace_hash"), + ("proof_term_hash", "proof_term_hash"), + ("lean_environment_hash", "lean_environment_hash"), + ("pfcore_kernel_hash", "pfcore_kernel_hash"), + ("semantic_projection_hash", "semantic_projection_hash"), + ("theorem_manifest_hash", "theorem_manifest_hash"), + ): + cert_val = str(certificate.get(field) or "") + man_val = str(manifest.get(manifest_key) or "") + if cert_val.startswith("sha256:") and man_val.startswith("sha256:") and cert_val != man_val: + result.issues.append( + BundleIssue( + "CertificateManifestFieldMismatch", + f"certificate.{field} {cert_val!r} != manifest.{manifest_key} {man_val!r}", + ) + ) + cert_ok = False + _record_check(result, "compare_certificate", ok=cert_ok) + except ValidationError as exc: + for err in exc.errors or [str(exc)]: + result.issues.append(BundleIssue("CertificateInvalid", err)) + _record_check(result, "compare_certificate", ok=False, detail=str(exc)) + + # Projection replay + projection_rel = str(manifest.get("semantic_projection_path") or "") + projection_hash = str(manifest.get("semantic_projection_hash") or "") + replay_ok = True + if projection_rel: + try: + projection_path = _bundle_resolve( + bundle_root, projection_rel, allowed_suffixes=frozenset({".json"}) + ) + stored_projection = _load_json(projection_path) + evidence_rel = str(manifest.get("evidence_manifest_path") or "") + evidence_manifest = ( + _load_json( + _bundle_resolve( + bundle_root, evidence_rel, allowed_suffixes=frozenset({".json"}) + ) + ) + if evidence_rel + else {"files": []} + ) + staging = _stage_evidence_for_resolve(bundle_root, evidence_manifest) + try: + from pcs_core.pf_core_resolved_evidence import resolve_pf_core_evidence + from pcs_core.pf_core_semantic_projection import build_semantic_projection + + staged_trace = staging / "trace.json" + staged_data = _load_json(staged_trace) + mode = str( + certificate.get("certificate_mode") + or manifest.get("certificate_mode") + or "" + ) + resolved = resolve_pf_core_evidence( + staged_data, + trace_path=staged_trace, + certificate_mode=mode, + ) + replayed = build_semantic_projection( + staged_data, + certificate_mode=mode, + trace_path=staged_trace, + resolved_evidence=resolved, + ) + replay_hash = str(replayed.get("projection_hash") or "") + stored_hash = str(stored_projection.get("projection_hash") or projection_hash) + if replay_hash and stored_hash and replay_hash != stored_hash: + replay_ok = False + result.issues.append( + BundleIssue( + "ProjectionReplayMismatch", + f"replayed {replay_hash!r} != stored {stored_hash!r}", + ) + ) + finally: + shutil.rmtree(staging, ignore_errors=True) + except Exception as exc: # noqa: BLE001 + replay_ok = False + result.issues.append(BundleIssue("ProjectionReplayFailed", str(exc))) + _record_check(result, "replay_semantic_projection", ok=replay_ok) + else: + _record_check( + result, + "replay_semantic_projection", + ok=True, + skipped=claim_class != "LeanKernelChecked", + detail="no semantic projection in bundle", + ) + + # Reconstruct theorem metadata from bundled proof and compare to theorem manifest + theorem_rel = str(manifest.get("theorem_manifest_path") or "") + proof_rel = str(manifest.get("proof_path") or "") + theorem_ok = True + if theorem_rel and proof_rel: + try: + from pcs_core.pf_core_theorem_manifest import ( + load_theorem_manifest, + propositions_by_name, + reconstruct_theorem_metadata_from_proof, + ) + + theorem_manifest = load_theorem_manifest( + _bundle_resolve( + bundle_root, theorem_rel, allowed_suffixes=frozenset({".json"}) + ) + ) + proof_text = _bundle_resolve( + bundle_root, proof_rel, allowed_suffixes=frozenset({".lean"}) + ).read_text(encoding="utf-8") + reconstructed = reconstruct_theorem_metadata_from_proof(proof_text) + expected = propositions_by_name(theorem_manifest) + if set(reconstructed) != set(expected): + theorem_ok = False + result.issues.append( + BundleIssue( + "TheoremMetadataNameDrift", + f"proof theorems {sorted(reconstructed)} != " + f"manifest {sorted(expected)}", + ) + ) + for name, prop in expected.items(): + if name in reconstructed and reconstructed[name] != prop: + theorem_ok = False + result.issues.append( + BundleIssue( + "TheoremMetadataPropositionDrift", + f"theorem {name!r} proposition drifted vs reconstructed proof text", + ) + ) + except Exception as exc: # noqa: BLE001 + theorem_ok = False + result.issues.append(BundleIssue("TheoremMetadataReconstructFailed", str(exc))) + _record_check(result, "reconstruct_theorem_metadata", ok=theorem_ok) + else: + _record_check( + result, + "reconstruct_theorem_metadata", + ok=True, + skipped=claim_class != "LeanKernelChecked", + detail="theorem manifest or proof absent", + ) + + # Toolchain + compile + toolchain_ok, toolchain_detail = ensure_bundled_lean_toolchain(bundle_root) + if not toolchain_ok: + result.issues.append(BundleIssue("LeanToolchainUnavailable", toolchain_detail)) + _record_check(result, "select_lean_toolchain", ok=toolchain_ok, detail=toolchain_detail) + + if skip_lean_compile or not proof_rel: + _record_check( + result, + "compile_bundled_proof", + ok=True, + skipped=True, + detail="skipped" if skip_lean_compile else "no proof_path", + ) + else: + compile_ok, compile_detail = compile_bundled_proof(bundle_root, proof_rel) + if not compile_ok: + result.issues.append(BundleIssue("BundledProofCompileFailed", compile_detail)) + _record_check( + result, + "compile_bundled_proof", + ok=compile_ok, + detail=compile_detail[:500], + ) + + # External attestation when present / required + from pcs_core.external_attestation import ( + ABSENCE_NOTICE_NAME, + EXTERNAL_ATTESTATION_NAME, + validate_bundle_external_attestation, + ) + + attest_path = bundle_root / EXTERNAL_ATTESTATION_NAME + notice_path = bundle_root / ABSENCE_NOTICE_NAME + require_live = os.environ.get("PF_CORE_CERTIFYEDGE_REQUIRE_LIVE", "").strip().lower() in { + "1", + "true", + "yes", + } + release_mode = os.environ.get("PCS_RELEASE_MODE", "preview").strip().lower() + allow_absence = release_mode in {"preview", "dev"} and not require_live + if attest_path.is_file() or notice_path.is_file() or require_live or release_mode == "release": + attest_errors = validate_bundle_external_attestation( + bundle_root, + require_live=require_live or release_mode == "release", + allow_absence_notice=allow_absence, + ) + attest_ok = not attest_errors + for err in attest_errors: + result.issues.append(BundleIssue("ExternalAttestationInvalid", err)) + _record_check( + result, + "verify_external_attestation", + ok=attest_ok, + detail="required" if (require_live or release_mode == "release") else "present", + ) + else: + _record_check( + result, + "verify_external_attestation", + ok=True, + skipped=True, + detail="no attestation sidecar and not required", + ) + + result.ok = not result.issues + _write_verification_result(result, result_out) + return result + + +def _write_verification_result( + result: BundleVerificationResult, + result_out: Path | None, +) -> None: + payload = result.to_dict() + try: + validate_artifact(payload, "PFCoreBundleVerificationResult.v0") + except ValidationError: + # Still emit digest-bound payload for debugging even if schema drifts. + pass + dest = result_out + if dest is None: + try: + dest = result.bundle_dir / BUNDLE_VERIFICATION_RESULT_NAME + except Exception: # noqa: BLE001 + return + try: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + result.result_path = dest + except OSError: + return diff --git a/python/tests/test_pf_core_bundle.py b/python/tests/test_pf_core_bundle.py index 4d651a7..0f648fc 100644 --- a/python/tests/test_pf_core_bundle.py +++ b/python/tests/test_pf_core_bundle.py @@ -1,24 +1,30 @@ -"""Tests for PF-Core release bundle assembly and validation.""" +"""Tests for PF-Core release bundle assembly, validation, and verify-bundle.""" from __future__ import annotations import json import shutil +import subprocess +import sys from pathlib import Path -from pcs_core.pf_core_bundle import bundle_release, validate_bundle +import pytest + +from pcs_core.hash import canonical_hash +from pcs_core.pf_core_bundle import bundle_release, validate_bundle, verify_bundle from pcs_core.pf_core_lean_codegen import compute_pfcore_kernel_hash REPO = Path(__file__).resolve().parents[2] VALID_TRACE = REPO / "examples" / "pf-core-valid" / "tool_use_trace_compiled" / "pfcore_trace.json" +LAKE_AVAILABLE = shutil.which("lake") is not None -def test_bundle_release_and_validate(tmp_path: Path) -> None: +def _runtime_cert(tmp_path: Path) -> Path: cert = { "schema_version": "v0", "artifact_type": "PFCoreCertificate.v0", "certificate_id": "pfcore-cert-bundle-test", - "trace_hash": json.loads(VALID_TRACE.read_text())["trace_hash"], + "trace_hash": json.loads(VALID_TRACE.read_text(encoding="utf-8"))["trace_hash"], "contract_hash": "sha256:" + "0" * 64, "policy_hash": "sha256:" + "0" * 64, "claim_class": "RuntimeChecked", @@ -30,9 +36,14 @@ def test_bundle_release_and_validate(tmp_path: Path) -> None: "source_commit": "abc1234567890abc1234567890abc1234567890", "signature_or_digest": "sha256:" + "0" * 64, } + cert["signature_or_digest"] = canonical_hash(cert) cert_path = tmp_path / "cert.json" cert_path.write_text(json.dumps(cert, indent=2), encoding="utf-8") + return cert_path + +def test_bundle_release_and_validate(tmp_path: Path) -> None: + cert_path = _runtime_cert(tmp_path) out_dir = tmp_path / "bundle" manifest_path = bundle_release(VALID_TRACE, cert_path, out_dir) assert manifest_path.is_file() @@ -51,30 +62,16 @@ def test_bundle_release_and_validate(tmp_path: Path) -> None: assert (out_dir / "lean" / "lake-manifest.json").is_file() assert (out_dir / "trace.json").is_file() assert (out_dir / "certificate.json").is_file() + assert (out_dir / "evidence_manifest.json").is_file() + assert manifest.get("evidence_manifest_path") == "evidence_manifest.json" + assert str(manifest.get("evidence_manifest_hash") or "").startswith("sha256:") result = validate_bundle(out_dir) assert result.ok, result.issues def test_bundle_release_tool_use_defaults_trace_safe_r_mode(tmp_path: Path) -> None: - cert = { - "schema_version": "v0", - "artifact_type": "PFCoreCertificate.v0", - "certificate_id": "pfcore-cert-bundle-mode", - "trace_hash": json.loads(VALID_TRACE.read_text())["trace_hash"], - "contract_hash": "sha256:" + "0" * 64, - "policy_hash": "sha256:" + "0" * 64, - "claim_class": "RuntimeChecked", - "checker": "pcs-core", - "checker_version": "0.1.0", - "assumption_refs": ["docs/pf-core/trusted-boundary.md"], - "event_count": 1, - "source_repo": "https://github.com/example/pcs-core", - "source_commit": "abc1234567890abc1234567890abc1234567890", - "signature_or_digest": "sha256:" + "0" * 64, - } - cert_path = tmp_path / "cert.json" - cert_path.write_text(json.dumps(cert, indent=2), encoding="utf-8") + cert_path = _runtime_cert(tmp_path) out_dir = tmp_path / "bundle" bundle_release(VALID_TRACE, cert_path, out_dir) manifest = json.loads((out_dir / "manifest.json").read_text(encoding="utf-8")) @@ -82,24 +79,7 @@ def test_bundle_release_tool_use_defaults_trace_safe_r_mode(tmp_path: Path) -> N def test_validate_bundle_from_kernel_manifest_without_checkout(tmp_path: Path) -> None: - cert = { - "schema_version": "v0", - "artifact_type": "PFCoreCertificate.v0", - "certificate_id": "pfcore-cert-bundle-offline", - "trace_hash": json.loads(VALID_TRACE.read_text())["trace_hash"], - "contract_hash": "sha256:" + "0" * 64, - "policy_hash": "sha256:" + "0" * 64, - "claim_class": "RuntimeChecked", - "checker": "pcs-core", - "checker_version": "0.1.0", - "assumption_refs": ["docs/pf-core/trusted-boundary.md"], - "event_count": 1, - "source_repo": "https://github.com/example/pcs-core", - "source_commit": "abc1234567890abc1234567890abc1234567890", - "signature_or_digest": "sha256:" + "0" * 64, - } - cert_path = tmp_path / "cert.json" - cert_path.write_text(json.dumps(cert, indent=2), encoding="utf-8") + cert_path = _runtime_cert(tmp_path) bundle_dir = tmp_path / "bundle" bundle_release(VALID_TRACE, cert_path, bundle_dir) @@ -119,24 +99,7 @@ def test_validate_bundle_from_kernel_manifest_without_checkout(tmp_path: Path) - def test_validate_bundle_isolated_from_checkout(tmp_path: Path) -> None: """Bundle validation must succeed without matching repository checkout layout.""" - cert = { - "schema_version": "v0", - "artifact_type": "PFCoreCertificate.v0", - "certificate_id": "pfcore-cert-bundle-isolated", - "trace_hash": json.loads(VALID_TRACE.read_text())["trace_hash"], - "contract_hash": "sha256:" + "0" * 64, - "policy_hash": "sha256:" + "0" * 64, - "claim_class": "RuntimeChecked", - "checker": "pcs-core", - "checker_version": "0.1.0", - "assumption_refs": ["docs/pf-core/trusted-boundary.md"], - "event_count": 1, - "source_repo": "https://github.com/example/pcs-core", - "source_commit": "abc1234567890abc1234567890abc1234567890", - "signature_or_digest": "sha256:" + "0" * 64, - } - cert_path = tmp_path / "cert.json" - cert_path.write_text(json.dumps(cert, indent=2), encoding="utf-8") + cert_path = _runtime_cert(tmp_path) bundle_dir = tmp_path / "bundle" bundle_release(VALID_TRACE, cert_path, bundle_dir) @@ -149,6 +112,45 @@ def test_validate_bundle_isolated_from_checkout(tmp_path: Path) -> None: assert result.ok, result.issues +def test_verify_bundle_runtime_structural(tmp_path: Path) -> None: + """verify-bundle on RuntimeChecked succeeds without Lean compile.""" + cert_path = _runtime_cert(tmp_path) + bundle_dir = tmp_path / "bundle" + bundle_release(VALID_TRACE, cert_path, bundle_dir) + result = verify_bundle(bundle_dir, skip_lean_compile=True) + assert result.ok, result.issues + assert result.result_path is not None and result.result_path.is_file() + payload = json.loads(result.result_path.read_text(encoding="utf-8")) + assert payload["artifact_type"] == "PFCoreBundleVerificationResult.v0" + assert payload["ok"] is True + assert str(payload.get("signature_or_digest") or "").startswith("sha256:") + check_ids = {c["check_id"] for c in payload["checks"]} + assert "validate_closed_manifests" in check_ids + assert "compare_certificate" in check_ids + + +def test_verify_bundle_detects_evidence_tamper(tmp_path: Path) -> None: + cert_path = _runtime_cert(tmp_path) + bundle_dir = tmp_path / "bundle" + bundle_release(VALID_TRACE, cert_path, bundle_dir) + evidence_manifest = json.loads( + (bundle_dir / "evidence_manifest.json").read_text(encoding="utf-8") + ) + evidence_manifest["evidence_manifest_digest"] = "sha256:" + "a" * 64 + (bundle_dir / "evidence_manifest.json").write_text( + json.dumps(evidence_manifest, indent=2) + "\n", encoding="utf-8" + ) + release = json.loads((bundle_dir / "manifest.json").read_text(encoding="utf-8")) + release["evidence_manifest_hash"] = evidence_manifest["evidence_manifest_digest"] + release["signature_or_digest"] = canonical_hash(release) + (bundle_dir / "manifest.json").write_text( + json.dumps(release, indent=2) + "\n", encoding="utf-8" + ) + result = validate_bundle(bundle_dir) + assert not result.ok + assert any(issue.code == "EvidenceManifestHashMismatch" for issue in result.issues) + + def test_validate_event_sequence_order_fixture() -> None: from pcs_core.pf_core_runtime import validate_event_sequence_order @@ -156,3 +158,80 @@ def test_validate_event_sequence_order_fixture() -> None: trace = json.loads(case.read_text(encoding="utf-8")) errors = validate_event_sequence_order(trace) assert any("EventSequenceOrderMismatch" in err for err in errors) + + +@pytest.mark.skipif(not LAKE_AVAILABLE, reason="lake not available") +def test_verify_bundle_lean_kernel_checked_e2e(tmp_path: Path) -> None: + """Full closed-bundle verify: lean-check → bundle-release → verify-bundle.""" + work = tmp_path / "case" + work.mkdir() + trace_path = work / "trace.json" + shutil.copy2(VALID_TRACE, trace_path) + out_cert = work / "PFCoreCertificate.v0.json" + result_out = work / "LeanCheckResult.v0.json" + proc = subprocess.run( + [ + sys.executable, + "-m", + "pcs_core.cli", + "pf-core", + "lean-check", + "--trace", + str(trace_path), + "--out", + str(out_cert), + "--result-out", + str(result_out), + "--release-grade", + ], + cwd=REPO / "python", + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + cert = json.loads(out_cert.read_text(encoding="utf-8")) + assert cert["claim_class"] == "LeanKernelChecked" + assert (work / "PFCoreSemanticProjection.v0.json").is_file() + assert (work / "PFCoreTheoremManifest.v0.json").is_file() + + bundle_dir = tmp_path / "bundle" + bundle_release( + trace_path, + out_cert, + bundle_dir, + lean_check_result_path=result_out, + ) + manifest = json.loads((bundle_dir / "manifest.json").read_text(encoding="utf-8")) + for key in ( + "semantic_projection_path", + "semantic_projection_hash", + "theorem_manifest_path", + "theorem_manifest_hash", + "evidence_manifest_path", + "evidence_manifest_hash", + "lean_check_result_path", + "lean_check_result_hash", + ): + assert manifest.get(key), f"missing closed field {key}" + assert (bundle_dir / "PFCoreSemanticProjection.v0.json").is_file() + assert (bundle_dir / "PFCoreTheoremManifest.v0.json").is_file() + assert (bundle_dir / "evidence_manifest.json").is_file() + + structural = validate_bundle(bundle_dir) + assert structural.ok, structural.issues + + verified = verify_bundle(bundle_dir, skip_lean_compile=False) + assert verified.ok, verified.issues + check_map = {c.check_id: c.status for c in verified.checks} + assert check_map.get("replay_semantic_projection") == "passed" + assert check_map.get("reconstruct_theorem_metadata") == "passed" + assert check_map.get("compile_bundled_proof") == "passed" + + isolated = tmp_path / "isolated" / "bundle" + isolated.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(bundle_dir, isolated) + isolated_result = verify_bundle(isolated) + assert isolated_result.ok, isolated_result.issues + payload = json.loads(isolated_result.result_path.read_text(encoding="utf-8")) + assert payload["signature_or_digest"] == canonical_hash(payload) diff --git a/schemas/PFCoreBundleVerificationResult.v0.schema.json b/schemas/PFCoreBundleVerificationResult.v0.schema.json new file mode 100644 index 0000000..552e7fe --- /dev/null +++ b/schemas/PFCoreBundleVerificationResult.v0.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/PFCoreBundleVerificationResult.v0.schema.json", + "title": "PFCoreBundleVerificationResult.v0", + "description": "Digest-bound result of pcs pf-core verify-bundle against a closed PF-Core release bundle.", + "type": "object", + "required": [ + "schema_version", + "artifact_type", + "ok", + "bundle_dir", + "verifier", + "verifier_version", + "checks", + "issues", + "signature_or_digest" + ], + "additionalProperties": false, + "properties": { + "schema_version": { "$ref": "common.defs.json#/$defs/schema_version" }, + "artifact_type": { "const": "PFCoreBundleVerificationResult.v0" }, + "canonicalization_version": { + "$ref": "common.defs.json#/$defs/canonicalization_version" + }, + "ok": { "type": "boolean" }, + "bundle_dir": { + "type": "string", + "minLength": 1 + }, + "verifier": { + "type": "string", + "minLength": 1 + }, + "verifier_version": { + "type": "string", + "minLength": 1 + }, + "manifest_digest": { + "$ref": "common.defs.json#/$defs/hex_digest" + }, + "checks": { + "type": "array", + "items": { + "type": "object", + "required": ["check_id", "status"], + "additionalProperties": false, + "properties": { + "check_id": { "type": "string", "minLength": 1 }, + "status": { + "type": "string", + "enum": ["passed", "failed", "skipped"] + }, + "detail": { "type": "string" } + } + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "required": ["code", "message"], + "additionalProperties": false, + "properties": { + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + } + } + }, + "signature_or_digest": { + "$ref": "common.defs.json#/$defs/hex_digest" + } + } +} diff --git a/schemas/PFCoreReleaseBundleManifest.v0.schema.json b/schemas/PFCoreReleaseBundleManifest.v0.schema.json index ac5546c..e3d6ca0 100644 --- a/schemas/PFCoreReleaseBundleManifest.v0.schema.json +++ b/schemas/PFCoreReleaseBundleManifest.v0.schema.json @@ -30,7 +30,14 @@ "certificate_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, "kernel_manifest_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, "lean_check_result_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, + "lean_check_result_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, "proof_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, + "semantic_projection_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, + "semantic_projection_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "theorem_manifest_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, + "theorem_manifest_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "evidence_manifest_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, + "evidence_manifest_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, "trace_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, "proof_term_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, "pfcore_kernel_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, @@ -50,14 +57,34 @@ "required": [ "proof_path", "lean_check_result_path", - "proof_term_hash" + "lean_check_result_hash", + "proof_term_hash", + "semantic_projection_path", + "semantic_projection_hash", + "theorem_manifest_path", + "theorem_manifest_hash", + "evidence_manifest_path", + "evidence_manifest_hash" ], "properties": { "proof_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, "lean_check_result_path": { "$ref": "common.defs.json#/$defs/relative_posix_path" }, - "proof_term_hash": { "$ref": "common.defs.json#/$defs/hex_digest" } + "lean_check_result_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "proof_term_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "semantic_projection_path": { + "$ref": "common.defs.json#/$defs/relative_posix_path" + }, + "semantic_projection_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "theorem_manifest_path": { + "$ref": "common.defs.json#/$defs/relative_posix_path" + }, + "theorem_manifest_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "evidence_manifest_path": { + "$ref": "common.defs.json#/$defs/relative_posix_path" + }, + "evidence_manifest_hash": { "$ref": "common.defs.json#/$defs/hex_digest" } } } } From 01d16c9eb3ed54e986c06bcb54c21c05ffb95012 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:01:10 -0700 Subject: [PATCH 10/24] Align TraceSafe and TraceSafeR across Python, Rust, and TypeScript. Keep cross-language PF-Core validation on a shared TraceSafe surface so language adapters cannot diverge on fail-closed acceptance. --- python/pcs_core/conformance.py | 102 +++++++++++++++++- python/pcs_core/validate_pf_core.py | 84 ++++++++++++++- python/tests/test_pf_core_cross_language.py | 4 +- rust/crates/pcs-core/src/lib.rs | 7 +- rust/crates/pcs-core/src/pf_core.rs | 12 ++- typescript/packages/core/src/pfCore.ts | 12 ++- .../packages/core/src/tests/examples.test.ts | 91 +++++++++++++++- 7 files changed, 294 insertions(+), 18 deletions(-) diff --git a/python/pcs_core/conformance.py b/python/pcs_core/conformance.py index 74b07d2..65a77b4 100644 --- a/python/pcs_core/conformance.py +++ b/python/pcs_core/conformance.py @@ -498,8 +498,10 @@ def _suite_lean_trust() -> tuple[list[str], list[str], int]: errors.append( f"{release_name}/lean_check_result.v0.json: status must be ProofChecked", ) - lean_dir = repo_root() / "lean" - if not (lean_dir / "lakefile.lean").is_file(): + from pcs_core.asset_resolver import lean_root as resolve_lean_root + + lean_dir = resolve_lean_root() + if lean_dir is None or not (lean_dir / "lakefile.lean").is_file(): errors.append("lean/lakefile.lean missing") else: checks += 1 @@ -760,11 +762,13 @@ def _check_pf_core_generated_lean_proof(errors: list[str], checks: int) -> int: ), "TraceSafeRCertificate": (trace_path, None), "FramePreservedCertificate": ( - repo_root() / "examples/pf-core-valid/file_read_allowed/trace.json", + repo_root() + / "examples/pf-core-valid/certificate_mode_framepreservedcertificate/trace.json", None, ), "EffectFrameCertificate": ( - repo_root() / "examples/pf-core-valid/file_read_allowed/trace.json", + repo_root() + / "examples/pf-core-valid/certificate_mode_effectframecertificate/trace.json", None, ), "HandoffSafeCertificate": ( @@ -795,14 +799,87 @@ def _check_pf_core_generated_lean_proof(errors: list[str], checks: int) -> int: with tempfile.TemporaryDirectory(prefix=f"pfcore-mode-{mode}-") as mode_tmp: work = Path(mode_tmp) local_trace = work / "trace.json" - local_trace.write_text(json.dumps(mode_trace), encoding="utf-8") if handoff_path is not None and handoff_path.is_file(): shutil.copy2(handoff_path, work / "handoff.json") + try: + handoff_obj = json.loads(handoff_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + handoff_obj = {} + handoff_id = str( + handoff_obj.get("handoff_id") if isinstance(handoff_obj, dict) else "" + ) + if handoff_id: + mode_trace = dict(mode_trace) + mode_trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "handoff_ids": [handoff_id], + } + local_trace.write_text(json.dumps(mode_trace), encoding="utf-8") if mode == "ContractCheckedCertificate": for sibling in fixture_path.parent.glob("*.json"): if sibling.name == fixture_path.name: continue shutil.copy2(sibling, work / sibling.name) + mode_trace = dict(mode_trace) + contract_ids: list[str] = [] + for sibling in fixture_path.parent.glob("*.json"): + try: + sibling_obj = json.loads(sibling.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if ( + isinstance(sibling_obj, dict) + and sibling_obj.get("artifact_type") == "PFCoreContract.v0" + ): + cid = str(sibling_obj.get("contract_id") or "") + if cid: + contract_ids.append(cid) + if not contract_ids: + # Fall back to event contract_refs when sibling contracts exist + # under alternate naming in the copied workdir. + for event in mode_trace.get("events") or []: + if not isinstance(event, dict): + continue + refs = event.get("contract_refs") + if isinstance(refs, list): + contract_ids.extend(str(ref) for ref in refs if str(ref)) + if contract_ids: + mode_trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "contract_ids": sorted(set(contract_ids)), + } + if mode == "EffectFrameCertificate": + for sibling in fixture_path.parent.glob("*.json"): + if sibling.name == fixture_path.name: + continue + shutil.copy2(sibling, work / sibling.name) + mode_trace = dict(mode_trace) + frame_id = "" + for sibling in fixture_path.parent.glob("*.json"): + try: + sibling_obj = json.loads(sibling.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if ( + isinstance(sibling_obj, dict) + and sibling_obj.get("artifact_type") == "PFCoreEffectFrame.v0" + ): + frame_id = str(sibling_obj.get("frame_id") or "") + if frame_id: + break + selection = mode_trace.get("evidence_selection") + if not isinstance(selection, dict) or not selection.get( + "effect_frame_id" + ): + if frame_id: + mode_trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "effect_frame_id": frame_id, + } + local_trace.write_text(json.dumps(mode_trace), encoding="utf-8") try: generated = generate_proof_obligation_file( mode_trace, @@ -889,6 +966,21 @@ def _suite_pf_core_cross_language() -> tuple[list[str], list[str], int]: if not any("TenantIsolation" in err for err in tenant_errors): errors.append("python cross_tenant_leak vector failed") + a11_path = ( + repo_root() / "examples" / "pf-core-invalid" / "resource_scope_violation" / "trace.json" + ) + if a11_path.is_file(): + checks += 1 + from pcs_core.lean_check import trace_safe_d, trace_safe_rd + + a11_events = json.loads(a11_path.read_text(encoding="utf-8")).get("events") or [] + if not isinstance(a11_events, list) or not a11_events: + errors.append("a11 resource_scope_violation: missing events") + elif not trace_safe_d(a11_events): + errors.append("a11 resource_scope_violation: expected TraceSafe=true") + elif trace_safe_rd(a11_events): + errors.append("a11 resource_scope_violation: expected TraceSafeR=false") + rust = repo_root() / "rust" proc = subprocess.run( ["cargo", "test", "pf_core_", "--", "--nocapture"], diff --git a/python/pcs_core/validate_pf_core.py b/python/pcs_core/validate_pf_core.py index ed6b43e..693fcb5 100644 --- a/python/pcs_core/validate_pf_core.py +++ b/python/pcs_core/validate_pf_core.py @@ -195,13 +195,22 @@ def _validate_pfcore_certificate(data: dict[str, Any]) -> list[str]: errors.append("root: lean_proof_checked requires semantic_projection_hash") manifest_hash = data.get("theorem_manifest_hash") inventory_hash_field = data.get("theorem_inventory_hash") + if ( + lean_proof_checked + and isinstance(data.get("theorem_inventory"), list) + and (not isinstance(manifest_hash, str) or not manifest_hash.startswith("sha256:")) + ): + errors.append("root: lean_proof_checked requires theorem_manifest_hash") if ( lean_proof_checked and isinstance(manifest_hash, str) and isinstance(inventory_hash_field, str) - and manifest_hash != inventory_hash_field + and manifest_hash == inventory_hash_field ): - errors.append("root: theorem_manifest_hash must equal theorem_inventory_hash") + errors.append( + "root: theorem_manifest_hash must not equal theorem_inventory_hash " + "(manifest digests propositions and metadata, not name inventory alone)" + ) obligations = data.get("obligations") if isinstance(obligations, list): required = { @@ -326,6 +335,77 @@ def _validate_pfcore_certificate(data: dict[str, Any]) -> list[str]: "root: ContractCheckedCertificate cannot claim lean_proof_checked " f"with unresolved contract ref {item_str!r}" ) + selected_ids = data.get("selected_contract_ids") + if not isinstance(selected_ids, list) or not selected_ids: + errors.append( + "root: ContractCheckedCertificate requires non-empty selected_contract_ids" + ) + elif any(not isinstance(item, str) or not item.strip() for item in selected_ids): + errors.append("root: selected_contract_ids must be a non-empty string array") + digests = data.get("contract_source_file_digests") + if not isinstance(digests, dict) or not digests: + errors.append( + "root: ContractCheckedCertificate requires contract_source_file_digests" + ) + evidence_digest = str(data.get("contract_evidence_digest") or "").strip() + if not evidence_digest.startswith("sha256:"): + errors.append( + "root: ContractCheckedCertificate requires contract_evidence_digest" + ) + theorem_names = data.get("contract_theorem_names") + if not isinstance(theorem_names, list) or not theorem_names: + errors.append( + "root: ContractCheckedCertificate requires concrete contract_theorem_names" + ) + elif any(not isinstance(item, str) or not item.strip() for item in theorem_names): + errors.append("root: contract_theorem_names must be a non-empty string array") + elif inventory is not None: + missing_theorems = [ + name + for name in theorem_names + if isinstance(name, str) and name not in inventory + ] + if missing_theorems: + errors.append( + "root: contract_theorem_names missing from theorem_inventory: " + f"{missing_theorems!r}" + ) + if ( + isinstance(selected_ids, list) + and selected_ids + and isinstance(semantics_obj, dict) + ): + lean_items = semantics_obj.get("lean") + runtime_items = semantics_obj.get("runtime") + referenced_ids: set[str] = set() + for bucket in (lean_items, runtime_items): + if not isinstance(bucket, list): + continue + for item in bucket: + text = str(item) + if text.startswith("missing_contract:"): + continue + if "." in text and not text.startswith("resource_"): + referenced_ids.add(text.split(".", 1)[0]) + selected_set = {str(item) for item in selected_ids if isinstance(item, str)} + unresolved = sorted(referenced_ids - selected_set) + if unresolved: + errors.append( + "root: ContractCheckedCertificate has unresolved contract refs " + f"outside selected_contract_ids: {unresolved!r}" + ) + if cert_mode == "EffectFrameCertificate" and lean_proof_checked: + frame_id = str(data.get("effect_frame_id") or "").strip() + if not frame_id: + errors.append("root: EffectFrameCertificate requires effect_frame_id") + frame_path = str(data.get("effect_frame_path") or "").strip() + if not frame_path: + errors.append("root: EffectFrameCertificate requires effect_frame_path") + frame_digest = str(data.get("effect_frame_digest") or "").strip() + if not frame_digest.startswith("sha256:"): + errors.append( + "root: EffectFrameCertificate requires effect_frame_digest" + ) default_ref = str(data.get("default_contract_ref") or "") semantics = data.get("contract_semantics_checked") has_semantics = isinstance(semantics, dict) and ( diff --git a/python/tests/test_pf_core_cross_language.py b/python/tests/test_pf_core_cross_language.py index 12b561e..befb133 100644 --- a/python/tests/test_pf_core_cross_language.py +++ b/python/tests/test_pf_core_cross_language.py @@ -431,7 +431,7 @@ def test_contract_semantics_checked_resource_obligations_parity() -> None: def test_trace_safe_rd_decider_parity() -> None: - """Python lean_check TraceSafeR decider matches TraceSafe on catalog-valid traces.""" + """Python lean_check TraceSafeR is stricter than TraceSafe on out-of-pattern URI.""" from pcs_core.lean_check import trace_safe_d, trace_safe_rd trace = _load_json(VALID_TRACE) @@ -443,6 +443,8 @@ def test_trace_safe_rd_decider_parity() -> None: bad_path = REPO / "examples" / "pf-core-invalid" / "resource_scope_violation" / "trace.json" bad = _load_json(bad_path) bad_events = bad["events"] + # A11: base conditions pass; resource URI outside pattern → TraceSafe true, TraceSafeR false. + assert trace_safe_d(bad_events) assert not trace_safe_rd(bad_events) diff --git a/rust/crates/pcs-core/src/lib.rs b/rust/crates/pcs-core/src/lib.rs index b2943ee..952f494 100644 --- a/rust/crates/pcs-core/src/lib.rs +++ b/rust/crates/pcs-core/src/lib.rs @@ -6,8 +6,11 @@ pub mod status; pub mod validation; pub use hash::{ - canonical_hash, canonical_json_bytes, canonical_json_string, domain_separated_signing_message, - CANONICALIZATION_VERSION, + assert_canonical_number_policy, canonical_hash, canonical_hash_legacy, canonical_hash_release, + canonical_json_bytes, canonical_json_string, domain_separated_signing_message, + try_canonical_hash_release, CanonicalizationError, CANONICALIZATION_VERSION, + REJECTION_FLOAT_PROHIBITED, REJECTION_INTEGER_OUT_OF_RANGE, REJECTION_NEGATIVE_ZERO, + SAFE_INTEGER_MAX, SAFE_INTEGER_MIN, }; pub use pf_core::{ action_admissible_with_resource_pattern_d, compute_event_hash, compute_trace_hash, diff --git a/rust/crates/pcs-core/src/pf_core.rs b/rust/crates/pcs-core/src/pf_core.rs index 748bb8d..785cbc6 100644 --- a/rust/crates/pcs-core/src/pf_core.rs +++ b/rust/crates/pcs-core/src/pf_core.rs @@ -1044,6 +1044,7 @@ fn action_within_tenant_d(principal: &Value, action: &Value) -> bool { true } +/// Mirror Lean ``actionAdmissibleD`` (excludes resource-pattern scope). fn action_admissible_d(principal: &Value, action: &Value) -> bool { let Some(capability) = action.get("capability") else { return false; @@ -1059,16 +1060,19 @@ fn action_admissible_d(principal: &Value, action: &Value) -> bool { if validate_action_capabilities_known(action, PATH).is_some() || validate_action_effects_known(action, PATH).is_some() || validate_action_capability_effects(action, PATH).is_some() - || validate_resource_scope(action, PATH).is_some() { return false; } principal_has_capability(principal, cap_id) && action_within_tenant_d(principal, action) } -/// Mirror Lean ``actionAdmissibleWithResourcePatternD`` (kernel + catalog resource scope). +fn action_resources_within_capability_pattern_d(action: &Value) -> bool { + validate_resource_scope(action, "action").is_none() +} + +/// Mirror Lean ``actionAdmissibleWithResourcePatternD`` (base + resource-pattern scope). pub fn action_admissible_with_resource_pattern_d(principal: &Value, action: &Value) -> bool { - action_admissible_d(principal, action) + action_admissible_d(principal, action) && action_resources_within_capability_pattern_d(action) } /// Mirror Lean ``eventSafeD`` on allow events (deny is vacuously safe). @@ -1881,6 +1885,8 @@ mod tests { .get("events") .and_then(|v| v.as_array()) .expect("events array"); + // A11: base TraceSafe holds; refined TraceSafeR rejects out-of-pattern URI. + assert!(trace_safe_d(bad_events)); assert!(!trace_safe_rd(bad_events)); } diff --git a/typescript/packages/core/src/pfCore.ts b/typescript/packages/core/src/pfCore.ts index ac08a0c..b8382c3 100644 --- a/typescript/packages/core/src/pfCore.ts +++ b/typescript/packages/core/src/pfCore.ts @@ -922,6 +922,7 @@ function actionWithinTenantD(principal: Record, action: Record< return true; } +/** Mirror Lean `actionAdmissibleD` (excludes resource-pattern scope). */ function actionAdmissibleD(principal: Record, action: Record): boolean { const capability = action.capability; if (!capability || typeof capability !== "object" || Array.isArray(capability)) { @@ -932,20 +933,23 @@ function actionAdmissibleD(principal: Record, action: Record): boolean { + return validateResourceScope(action, "action") === null; +} + +/** Mirror Lean `actionAdmissibleWithResourcePatternD` (base + resource-pattern scope). */ export function actionAdmissibleWithResourcePatternD( principal: Record, action: Record, ): boolean { - return actionAdmissibleD(principal, action); + return actionAdmissibleD(principal, action) && actionResourcesWithinCapabilityPatternD(action); } /** Mirror Lean `eventSafeD` on allow events (deny is vacuously safe). */ diff --git a/typescript/packages/core/src/tests/examples.test.ts b/typescript/packages/core/src/tests/examples.test.ts index 8a77f28..5234ebc 100644 --- a/typescript/packages/core/src/tests/examples.test.ts +++ b/typescript/packages/core/src/tests/examples.test.ts @@ -4,7 +4,20 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; -import { canonicalHash, canonicalJsonBytes } from "../hash.js"; +import { + CANONICALIZATION_VERSION, + REJECTION_FLOAT_PROHIBITED, + REJECTION_INTEGER_OUT_OF_RANGE, + REJECTION_NEGATIVE_ZERO, + SAFE_INTEGER_MAX, + SAFE_INTEGER_MIN, + CanonicalizationError, + canonicalHash, + canonicalHashLegacy, + canonicalHashRelease, + canonicalJsonBytes, + tryCanonicalHashRelease, +} from "../hash.js"; import { canonicalEventJsonBytes, canonicalTraceJsonBytes, @@ -45,6 +58,7 @@ const sharedVectorsDir = join( dirname(fileURLToPath(import.meta.url)), "../../../../../test_vectors/hash", ); +const canonV1Dir = join(sharedVectorsDir, "canonical_json_v1"); function load(rel: string): Record { return JSON.parse(readFileSync(join(examplesDir, rel), "utf8")) as Record; @@ -342,6 +356,8 @@ test("pf-core traceSafeRD decider parity", () => { readFileSync(join(pfCoreInvalidExamplesDir, "resource_scope_violation/trace.json"), "utf8"), ) as Record; const badEvents = bad.events as Record[]; + // A11: base TraceSafe holds; refined TraceSafeR rejects out-of-pattern URI. + assert.equal(traceSafeD(badEvents), true); assert.equal(traceSafeRD(badEvents), false); }); @@ -491,3 +507,76 @@ test("property digests have sha256 shape", () => { assert.equal(digest.length, 71); } }); + +test("canonicalHashLegacy aliases canonicalHash", () => { + const payload = { schema_version: "v0", artifact_type: "CanonicalProbe.v0", n: 1 }; + assert.equal(canonicalHashLegacy(payload), canonicalHash(payload)); +}); + +test("canonicalHashRelease enforces number policy with normalized codes", () => { + const safe = { + schema_version: "v0", + artifact_type: "CanonicalProbe.v0", + lo: SAFE_INTEGER_MIN, + hi: SAFE_INTEGER_MAX, + }; + assert.equal(canonicalHashRelease(safe), canonicalHashLegacy(safe)); + + assert.throws( + () => canonicalHashRelease({ x: 1.5 }), + (err: unknown) => + err instanceof CanonicalizationError && err.code === REJECTION_FLOAT_PROHIBITED, + ); + assert.throws( + () => canonicalHashRelease({ x: SAFE_INTEGER_MAX + 1 }), + (err: unknown) => + err instanceof CanonicalizationError && err.code === REJECTION_INTEGER_OUT_OF_RANGE, + ); + assert.throws( + () => canonicalHashRelease({ x: SAFE_INTEGER_MIN - 1 }), + (err: unknown) => + err instanceof CanonicalizationError && err.code === REJECTION_INTEGER_OUT_OF_RANGE, + ); + assert.throws( + () => canonicalHashRelease({ x: -0 }), + (err: unknown) => + err instanceof CanonicalizationError && err.code === REJECTION_NEGATIVE_ZERO, + ); + assert.equal(tryCanonicalHashRelease({ x: 1.5 }).rejection, REJECTION_FLOAT_PROHIBITED); +}); + +test("canonical_json_v1 accept and release-reject vectors", () => { + const catalog = JSON.parse(readFileSync(join(canonV1Dir, "vectors.json"), "utf8")) as { + canonicalization_version: string; + cases: Array<{ case_id: string; expected_digest: string; canonical_json: string }>; + release_reject_cases: Array<{ + case_id: string; + expected_rejection: string; + legacy_digest: string; + }>; + }; + assert.equal(catalog.canonicalization_version, CANONICALIZATION_VERSION); + for (const caseRow of catalog.cases) { + const data = JSON.parse( + readFileSync(join(canonV1Dir, caseRow.case_id, "input.json"), "utf8"), + ) as Record; + assert.equal( + Buffer.from(canonicalJsonBytes(data)).toString("utf8"), + caseRow.canonical_json, + caseRow.case_id, + ); + const digest = canonicalHashLegacy(data); + assert.equal(digest, caseRow.expected_digest, caseRow.case_id); + assert.equal(canonicalHashRelease(data), digest, caseRow.case_id); + } + for (const caseRow of catalog.release_reject_cases) { + const data = JSON.parse( + readFileSync(join(canonV1Dir, caseRow.case_id, "input.json"), "utf8"), + ) as Record; + const result = tryCanonicalHashRelease(data); + assert.equal(result.rejection, caseRow.expected_rejection, caseRow.case_id); + // Legacy digests for float/-0 inputs can differ under ECMAScript JSON.stringify; + // release mode must still share the normalized rejection code. + assert.ok(canonicalHashLegacy(data).startsWith("sha256:"), caseRow.case_id); + } +}); From 76ef77891fa4fa4a069b8e0de03067b2d8624681 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:01:19 -0700 Subject: [PATCH 11/24] Require PCS projection binding for Lean release catalogs. Make mandatory PCS projection generation and Lean binding fail closed so release catalogs cannot ship without a checked projection witness. --- lean/PCS.lean | 1 + .../release_pcs_v0_1_labtrust_qc.lean | 29 +- ...lease_pcs_v0_1_scientific_computation.lean | 31 +- .../release_pcs_v0_1_tool_use_safety.lean | 29 +- lean/PCS/Projection.lean | 58 +++ lean/PCS/ReleaseChainCheck.lean | 33 ++ python/pcs_core/pcs_lean_codegen.py | 221 +++++--- python/pcs_core/pcs_projection.py | 480 +++++++++++++++++- python/tests/test_pcs_lean_codegen.py | 10 +- python/tests/test_pcs_projection_binding.py | 205 ++++++++ schemas/ProofObligation.v0.schema.json | 2 + 11 files changed, 995 insertions(+), 104 deletions(-) create mode 100644 lean/PCS/Projection.lean create mode 100644 python/tests/test_pcs_projection_binding.py diff --git a/lean/PCS.lean b/lean/PCS.lean index 9cd8603..6f6d7bb 100644 --- a/lean/PCS.lean +++ b/lean/PCS.lean @@ -8,5 +8,6 @@ import PCS.Bundle import PCS.ComputationWitness import PCS.ToolUse import PCS.ReleaseChain +import PCS.Projection import PCS.ReleaseChainCheck import PCS.Theorems diff --git a/lean/PCS/Generated/release_pcs_v0_1_labtrust_qc.lean b/lean/PCS/Generated/release_pcs_v0_1_labtrust_qc.lean index 6eedc66..3bfbc74 100644 --- a/lean/PCS/Generated/release_pcs_v0_1_labtrust_qc.lean +++ b/lean/PCS/Generated/release_pcs_v0_1_labtrust_qc.lean @@ -4,13 +4,13 @@ import PCS.ReleaseChainCheck # Generated PCS release-chain proof for `release-pcs-v0.1-labtrust-qc` Auto-generated by pcs-core pcs-envelope check --lean-proof. Do not edit by hand. -This discharges ProofObligation.v0 against `PCS.ReleaseChainAdmissible` deciders only. It does **not** imply PF-Core trace safety or `LeanKernelChecked` assurance. +This discharges ProofObligation.v0 against `PCS.EnvelopeReleaseAdmissible` (projection-bound release envelope). It does **not** imply PF-Core trace safety or `LeanKernelChecked` assurance. -/ namespace PCS.Generated.release_pcs_v0_1_labtrust_qc --- pcs_projection_manifest_hash: sha256:fc43545abd89fa0795316ec4db63cfa584221af32d422323622b7e8e1b1cf9f4 -def pcsProjectionManifestHash : String := "sha256:fc43545abd89fa0795316ec4db63cfa584221af32d422323622b7e8e1b1cf9f4" +-- pcs_projection_manifest_hash: sha256:4f0af951482773af4649775ec7a14d0e5334b99c8926c90c9125d164ba8cfc0e +def pcsProjectionManifestHash : String := "sha256:4f0af951482773af4649775ec7a14d0e5334b99c8926c90c9125d164ba8cfc0e" def concreteCertificate : Certificate := { @@ -36,6 +36,18 @@ def concreteCertifiedBundleHash : Hash := Hash.ofString "bb740698a01c4e918ca0f34 def concreteSignedInputHash : Hash := Hash.ofString "bb740698a01c4e918ca0f346e5bfaed83e6665da8df84e931c0d50e03ce82ffe" +def concreteReleaseEnvelope : ReleaseEnvelope := + { + workflowId := "labtrust.qc_release_v0.1", + releaseId := "release-pcs-v0.1-labtrust-qc", + projectionDigest := Hash.ofString "4f0af951482773af4649775ec7a14d0e5334b99c8926c90c9125d164ba8cfc0e", + certificate := concreteCertificate, + runtimeReceipt := concreteRuntimeReceipt, + verification := concreteVerification, + certifiedBundleHash := concreteCertifiedBundleHash, + signedInputHash := concreteSignedInputHash + } + theorem concrete_certificate_matches_runtime : certificateMatchesRuntimeD concreteCertificate concreteRuntimeReceipt = true := by decide @@ -71,7 +83,14 @@ theorem concrete_release_chain_admissible_prop : ReleaseChainAdmissible concreteCertificate concreteRuntimeReceipt concreteVerification concreteCertifiedBundleHash concreteSignedInputHash := (releaseChainAdmissibleD_sound _ _ _ _ _).mp concrete_release_chain_admissible -#eval releaseChainAdmissibleD concreteCertificate concreteRuntimeReceipt concreteVerification - concreteCertifiedBundleHash concreteSignedInputHash + +theorem concrete_envelope_release_admissible : + envelopeReleaseAdmissibleD concreteReleaseEnvelope = true := by + decide + +theorem concrete_envelope_release_admissible_prop : + EnvelopeReleaseAdmissible concreteReleaseEnvelope := + (envelopeReleaseAdmissibleD_sound _).mp concrete_envelope_release_admissible +#eval envelopeReleaseAdmissibleD concreteReleaseEnvelope end PCS.Generated.release_pcs_v0_1_labtrust_qc diff --git a/lean/PCS/Generated/release_pcs_v0_1_scientific_computation.lean b/lean/PCS/Generated/release_pcs_v0_1_scientific_computation.lean index e1506b4..dd9640b 100644 --- a/lean/PCS/Generated/release_pcs_v0_1_scientific_computation.lean +++ b/lean/PCS/Generated/release_pcs_v0_1_scientific_computation.lean @@ -10,8 +10,8 @@ This discharges computation witness result-hash admissibility against independen namespace PCS.Generated.release_pcs_v0_1_scientific_computation --- pcs_projection_manifest_hash: sha256:f391b04ad349f5a8727d1b86be96bd1e7c69d8c59064e4799d57ec326e93df10 -def pcsProjectionManifestHash : String := "sha256:f391b04ad349f5a8727d1b86be96bd1e7c69d8c59064e4799d57ec326e93df10" +-- pcs_projection_manifest_hash: sha256:98da562ca8c2bdefebdc61f2b87413bdb3dabb0d823b328465e8d635f9939939 +def pcsProjectionManifestHash : String := "sha256:98da562ca8c2bdefebdc61f2b87413bdb3dabb0d823b328465e8d635f9939939" def concreteComputationWitness : ComputationWitness := { @@ -39,6 +39,13 @@ def concreteCertifiedBundleHash : Hash := Hash.ofString "5a6a675d23354d219e85dae def concreteSignedInputHash : Hash := Hash.ofString "5a6a675d23354d219e85daec27a89443d8648d158249e86c48b99528b4412643" +def concreteEnvelopeProjection : EnvelopeProjectionMeta := + { + workflowId := "scientific_computation.reproducibility_v0", + releaseId := "release-pcs-v0.1-scientific-computation", + projectionDigest := Hash.ofString "98da562ca8c2bdefebdc61f2b87413bdb3dabb0d823b328465e8d635f9939939" + } + theorem concrete_witness_result_hashes_admissible : witnessResultHashesAdmissibleD concreteComputationWitness.resultHashes concreteDeclaredResultArtifactHashes = true := by @@ -76,16 +83,26 @@ theorem concrete_signed_bundle_admissible_prop : concreteVerification.verifiedInputBundleHash := (signedBundleAdmissibleD_sound _ _).mp concrete_signed_bundle_admissible +theorem concrete_envelope_projection_bound : + envelopeProjectionBoundD concreteEnvelopeProjection = true := by + decide + +theorem concrete_envelope_projection_bound_prop : + EnvelopeProjectionBound concreteEnvelopeProjection := + (envelopeProjectionBoundD_sound _).mp concrete_envelope_projection_bound + theorem concrete_computation_release_admissible_prop : - witnessResultHashesAdmissible concreteComputationWitness - concreteDeclaredResultArtifactHashes ∧ + EnvelopeProjectionBound concreteEnvelopeProjection ∧ + witnessResultHashesAdmissible concreteComputationWitness + concreteDeclaredResultArtifactHashes ∧ concreteResultArtifactHash ∈ concreteComputationWitness.resultHashes ∧ VerificationAdmitsBundle concreteVerification concreteCertifiedBundleHash ∧ SignedBundleAdmissible concreteSignedInputHash concreteVerification.verifiedInputBundleHash := - And.intro concrete_witness_result_hashes_admissible_prop - (And.intro concrete_witness_result_hash_listed_prop - (And.intro concrete_verification_admits_bundle_prop concrete_signed_bundle_admissible_prop)) + And.intro concrete_envelope_projection_bound_prop + (And.intro concrete_witness_result_hashes_admissible_prop + (And.intro concrete_witness_result_hash_listed_prop + (And.intro concrete_verification_admits_bundle_prop concrete_signed_bundle_admissible_prop))) end PCS.Generated.release_pcs_v0_1_scientific_computation diff --git a/lean/PCS/Generated/release_pcs_v0_1_tool_use_safety.lean b/lean/PCS/Generated/release_pcs_v0_1_tool_use_safety.lean index f3f3e7e..4c8628e 100644 --- a/lean/PCS/Generated/release_pcs_v0_1_tool_use_safety.lean +++ b/lean/PCS/Generated/release_pcs_v0_1_tool_use_safety.lean @@ -10,8 +10,8 @@ This discharges tool-use hash alignment plus release-chain obligations. It does namespace PCS.Generated.release_pcs_v0_1_tool_use_safety --- pcs_projection_manifest_hash: sha256:38cc472ba47be36d2e2cf6ce45761044da70b58c967d709bad5d6ffc57affc28 -def pcsProjectionManifestHash : String := "sha256:38cc472ba47be36d2e2cf6ce45761044da70b58c967d709bad5d6ffc57affc28" +-- pcs_projection_manifest_hash: sha256:ff82279a183c8783c5fc0f63a3847202b1dc8b95eaf8da816ccf62d2d8cda354 +def pcsProjectionManifestHash : String := "sha256:ff82279a183c8783c5fc0f63a3847202b1dc8b95eaf8da816ccf62d2d8cda354" def concreteToolUseTrace : ToolUseTrace := { @@ -52,6 +52,18 @@ def concreteCertifiedBundleHash : Hash := Hash.ofString "8ec0f90d0af828db78c5ada def concreteSignedInputHash : Hash := Hash.ofString "8ec0f90d0af828db78c5ada9299daea96128c4737328d40d6d6c473046d4780d" +def concreteReleaseEnvelope : ReleaseEnvelope := + { + workflowId := "agent_tool_use.safety_v0", + releaseId := "release-pcs-v0.1-tool-use-safety", + projectionDigest := Hash.ofString "ff82279a183c8783c5fc0f63a3847202b1dc8b95eaf8da816ccf62d2d8cda354", + certificate := concreteCertificate, + runtimeReceipt := concreteRuntimeReceipt, + verification := concreteVerification, + certifiedBundleHash := concreteCertifiedBundleHash, + signedInputHash := concreteSignedInputHash + } + theorem concrete_tool_trace_hash_matches : toolTraceHashMatchesCertificateD concreteToolUseTrace concreteToolUseCertificate = true := by decide @@ -96,11 +108,18 @@ theorem concrete_release_chain_admissible_prop : concreteCertifiedBundleHash concreteSignedInputHash := (releaseChainAdmissibleD_sound _ _ _ _ _).mp concrete_release_chain_admissible +theorem concrete_envelope_release_admissible : + envelopeReleaseAdmissibleD concreteReleaseEnvelope = true := by + decide + +theorem concrete_envelope_release_admissible_prop : + EnvelopeReleaseAdmissible concreteReleaseEnvelope := + (envelopeReleaseAdmissibleD_sound _).mp concrete_envelope_release_admissible + theorem concrete_tool_use_release_admissible_prop : toolTraceHashMatchesCertificate concreteToolUseTrace concreteToolUseCertificate ∧ - ReleaseChainAdmissible concreteCertificate concreteRuntimeReceipt concreteVerification - concreteCertifiedBundleHash concreteSignedInputHash := - And.intro concrete_tool_trace_hash_matches_prop concrete_release_chain_admissible_prop + EnvelopeReleaseAdmissible concreteReleaseEnvelope := + And.intro concrete_tool_trace_hash_matches_prop concrete_envelope_release_admissible_prop end PCS.Generated.release_pcs_v0_1_tool_use_safety diff --git a/lean/PCS/Projection.lean b/lean/PCS/Projection.lean new file mode 100644 index 0000000..cae7162 --- /dev/null +++ b/lean/PCS/Projection.lean @@ -0,0 +1,58 @@ +import PCS.Bundle +import PCS.Certificate +import PCS.Hash +import PCS.ReleaseChain + +/-! +# PCS release-envelope projection binding + +`EnvelopeProjectionMeta` carries workflow/release identity and the PCS projection +digest. `ReleaseEnvelope` extends that meta with concrete release-chain values so +`EnvelopeLeanChecked` witnesses cannot treat the projection as metadata-only. +-/ + +namespace PCS + +/-- Workflow/release identity bound to a PCS projection digest. -/ +structure EnvelopeProjectionMeta where + workflowId : String + releaseId : String + projectionDigest : Hash + deriving DecidableEq, Repr + +/-- Projection digest and identities must be present to participate in a witness. -/ +def EnvelopeProjectionBound (meta : EnvelopeProjectionMeta) : Prop := + meta.projectionDigest.value ≠ "" ∧ + meta.workflowId ≠ "" ∧ + meta.releaseId ≠ "" + +/-- Concrete PCS release envelope: projection meta + release-chain values. -/ +structure ReleaseEnvelope where + workflowId : String + releaseId : String + projectionDigest : Hash + certificate : Certificate + runtimeReceipt : RuntimeReceipt + verification : VerificationResult + certifiedBundleHash : Hash + signedInputHash : Hash + deriving DecidableEq, Repr + +def ReleaseEnvelope.toProjectionMeta (env : ReleaseEnvelope) : EnvelopeProjectionMeta := + { + workflowId := env.workflowId + releaseId := env.releaseId + projectionDigest := env.projectionDigest + } + +/-- Release-admissibility over a fully projected envelope (B2). -/ +def EnvelopeReleaseAdmissible (env : ReleaseEnvelope) : Prop := + EnvelopeProjectionBound env.toProjectionMeta ∧ + ReleaseChainAdmissible + env.certificate + env.runtimeReceipt + env.verification + env.certifiedBundleHash + env.signedInputHash + +end PCS diff --git a/lean/PCS/ReleaseChainCheck.lean b/lean/PCS/ReleaseChainCheck.lean index 65a7109..02f64cc 100644 --- a/lean/PCS/ReleaseChainCheck.lean +++ b/lean/PCS/ReleaseChainCheck.lean @@ -1,3 +1,4 @@ +import PCS.Projection import PCS.ReleaseChain /-! @@ -71,4 +72,36 @@ theorem releaseChainAdmissibleD_sound certificateMatchesRuntimeD_sound, verificationAdmitsBundleD_sound, signedBundleAdmissibleD_sound, and_assoc, and_left_comm, and_comm] +def projectionDigestPresentD (digest : Hash) : Bool := + decide (digest.value ≠ "") + +def envelopeProjectionBoundD (meta : EnvelopeProjectionMeta) : Bool := + projectionDigestPresentD meta.projectionDigest && + decide (meta.workflowId ≠ "") && + decide (meta.releaseId ≠ "") + +theorem envelopeProjectionBoundD_sound (meta : EnvelopeProjectionMeta) : + envelopeProjectionBoundD meta = true ↔ EnvelopeProjectionBound meta := by + cases meta with + | mk workflowId releaseId projectionDigest => + simp [envelopeProjectionBoundD, EnvelopeProjectionBound, projectionDigestPresentD, + decide_eq_true_iff, Bool.and_eq_true, and_assoc] + +def envelopeReleaseAdmissibleD (env : ReleaseEnvelope) : Bool := + envelopeProjectionBoundD env.toProjectionMeta && + releaseChainAdmissibleD + env.certificate + env.runtimeReceipt + env.verification + env.certifiedBundleHash + env.signedInputHash + +theorem envelopeReleaseAdmissibleD_sound (env : ReleaseEnvelope) : + envelopeReleaseAdmissibleD env = true ↔ EnvelopeReleaseAdmissible env := by + cases env with + | mk workflowId releaseId projectionDigest certificate runtimeReceipt + verification certifiedBundleHash signedInputHash => + simp [envelopeReleaseAdmissibleD, EnvelopeReleaseAdmissible, ReleaseEnvelope.toProjectionMeta, + envelopeProjectionBoundD_sound, releaseChainAdmissibleD_sound] + end PCS diff --git a/python/pcs_core/pcs_lean_codegen.py b/python/pcs_core/pcs_lean_codegen.py index 615f8b9..49a971e 100644 --- a/python/pcs_core/pcs_lean_codegen.py +++ b/python/pcs_core/pcs_lean_codegen.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, Mapping +from pcs_core.asset_resolver import pcs_generated_root, proof_ref_from_path, require_lean_root from pcs_core.lean_trust import extract_proof_obligations_from_release from pcs_core.obligation_extraction_errors import ( InvalidProofInputDigest, @@ -18,7 +19,6 @@ MissingWitnessId, ObligationExtractionError, ) -from pcs_core.paths import repo_root from pcs_core.pcs_projection import ( assert_no_unknown_or_empty, require_sha256_digest, @@ -430,41 +430,99 @@ def aggregate_lean_theorem_for_workflow(workflow_id: str) -> str: return "concrete_tool_use_release_admissible_prop" if workflow_id == "scientific_computation.reproducibility_v0": return "concrete_computation_release_admissible_prop" - return "concrete_release_chain_admissible_prop" + return "concrete_envelope_release_admissible_prop" -def _release_chain_definitions(values: Mapping[str, Any]) -> str: - return "\n\n".join( - [ - certificate_to_lean( - name="concreteCertificate", - certificate_id=str(values["certificate_id"]), - trace_hash=str(values["certificate_trace_hash"]), - status=str(values["certificate_status"]), - ), - runtime_receipt_to_lean( - name="concreteRuntimeReceipt", - trace_hash=str(values["runtime_trace_hash"]), - status=str(values.get("runtime_status") or "RuntimeObserved"), - ), - verification_result_to_lean( - name="concreteVerification", - status=str(values["verification_status"]), - verified_input_bundle_hash=str(values["verified_input_bundle_hash"]), - release_blocking_checks_passed=bool(values["release_blocking_checks_passed"]), - ), - bundle_hash_to_lean( - name="concreteCertifiedBundleHash", - bundle_hash=str(values["certified_bundle_hash"]), - ), - bundle_hash_to_lean( - name="concreteSignedInputHash", - bundle_hash=str(values["signed_input_bundle_hash"]), - ), - ], +def _require_projection_hash(values: Mapping[str, Any]) -> str: + raw = values.get("pcs_projection_manifest_hash") + return require_sha256_digest(raw, field="pcs_projection_manifest_hash") + + +def _envelope_projection_meta_definition( + values: Mapping[str, Any], + *, + workflow_id: str, + name: str = "concreteEnvelopeProjection", +) -> str: + projection_hash = _require_projection_hash(values) + release_id = assert_no_unknown_or_empty( + str(values.get("release_id") or ""), + field="release_id", + ) + workflow = assert_no_unknown_or_empty(workflow_id, field="workflow_id") + return ( + f"def {name} : EnvelopeProjectionMeta :=\n" + " {\n" + f" workflowId := {lean_string_literal(workflow)},\n" + f" releaseId := {lean_string_literal(release_id)},\n" + f" projectionDigest := {hash_to_lean(projection_hash)}\n" + " }" ) +def _release_envelope_definition( + values: Mapping[str, Any], + *, + workflow_id: str, +) -> str: + projection_hash = _require_projection_hash(values) + release_id = assert_no_unknown_or_empty( + str(values.get("release_id") or ""), + field="release_id", + ) + workflow = assert_no_unknown_or_empty(workflow_id, field="workflow_id") + return ( + "def concreteReleaseEnvelope : ReleaseEnvelope :=\n" + " {\n" + f" workflowId := {lean_string_literal(workflow)},\n" + f" releaseId := {lean_string_literal(release_id)},\n" + f" projectionDigest := {hash_to_lean(projection_hash)},\n" + " certificate := concreteCertificate,\n" + " runtimeReceipt := concreteRuntimeReceipt,\n" + " verification := concreteVerification,\n" + " certifiedBundleHash := concreteCertifiedBundleHash,\n" + " signedInputHash := concreteSignedInputHash\n" + " }" + ) + + +def _release_chain_definitions( + values: Mapping[str, Any], + *, + workflow_id: str | None = None, +) -> str: + parts = [ + certificate_to_lean( + name="concreteCertificate", + certificate_id=str(values["certificate_id"]), + trace_hash=str(values["certificate_trace_hash"]), + status=str(values["certificate_status"]), + ), + runtime_receipt_to_lean( + name="concreteRuntimeReceipt", + trace_hash=str(values["runtime_trace_hash"]), + status=str(values.get("runtime_status") or "RuntimeObserved"), + ), + verification_result_to_lean( + name="concreteVerification", + status=str(values["verification_status"]), + verified_input_bundle_hash=str(values["verified_input_bundle_hash"]), + release_blocking_checks_passed=bool(values["release_blocking_checks_passed"]), + ), + bundle_hash_to_lean( + name="concreteCertifiedBundleHash", + bundle_hash=str(values["certified_bundle_hash"]), + ), + bundle_hash_to_lean( + name="concreteSignedInputHash", + bundle_hash=str(values["signed_input_bundle_hash"]), + ), + ] + if workflow_id is not None: + parts.append(_release_envelope_definition(values, workflow_id=workflow_id)) + return "\n\n".join(parts) + + def _release_chain_theorems_block() -> str: return """ theorem concrete_certificate_matches_runtime : @@ -502,16 +560,26 @@ def _release_chain_theorems_block() -> str: ReleaseChainAdmissible concreteCertificate concreteRuntimeReceipt concreteVerification concreteCertifiedBundleHash concreteSignedInputHash := (releaseChainAdmissibleD_sound _ _ _ _ _).mp concrete_release_chain_admissible + +theorem concrete_envelope_release_admissible : + envelopeReleaseAdmissibleD concreteReleaseEnvelope = true := by + decide + +theorem concrete_envelope_release_admissible_prop : + EnvelopeReleaseAdmissible concreteReleaseEnvelope := + (envelopeReleaseAdmissibleD_sound _).mp concrete_envelope_release_admissible """.strip() def generate_release_chain_lean(obligations_doc: Mapping[str, Any]) -> str: values = release_chain_values_from_obligations(obligations_doc) - return _release_chain_definitions(values) + workflow_id = workflow_id_from_obligations(obligations_doc) + return _release_chain_definitions(values, workflow_id=workflow_id) def generate_tool_use_lean(obligations_doc: Mapping[str, Any]) -> str: values = tool_use_values_from_obligations(obligations_doc) + workflow_id = workflow_id_from_obligations(obligations_doc) tool_defs = "\n\n".join( [ tool_use_trace_to_lean( @@ -527,7 +595,7 @@ def generate_tool_use_lean(obligations_doc: Mapping[str, Any]) -> str: policy_hash=values["tool_certificate_policy_hash"], status=values["tool_certificate_status"], ), - _release_chain_definitions(values), + _release_chain_definitions(values, workflow_id=workflow_id), ], ) return tool_defs @@ -539,6 +607,7 @@ def generate_computation_lean( release_dir: Path | None = None, ) -> str: values = computation_values_from_obligations(obligations_doc, release_dir=release_dir) + workflow_id = workflow_id_from_obligations(obligations_doc) declared = declared_artifact_hashes_for_computation(values) witness_def = computation_witness_to_lean( name="concreteComputationWitness", @@ -574,7 +643,10 @@ def generate_computation_lean( ), ], ) - return "\n\n".join([witness_def, declared_def, result_hash_def, verification_def, bundle_defs]) + projection_meta = _envelope_projection_meta_definition(values, workflow_id=workflow_id) + return "\n\n".join( + [witness_def, declared_def, result_hash_def, verification_def, bundle_defs, projection_meta], + ) def _tool_use_theorems_block() -> str: @@ -594,9 +666,8 @@ def _tool_use_theorems_block() -> str: theorem concrete_tool_use_release_admissible_prop : toolTraceHashMatchesCertificate concreteToolUseTrace concreteToolUseCertificate ∧ - ReleaseChainAdmissible concreteCertificate concreteRuntimeReceipt concreteVerification - concreteCertifiedBundleHash concreteSignedInputHash := - And.intro concrete_tool_trace_hash_matches_prop concrete_release_chain_admissible_prop + EnvelopeReleaseAdmissible concreteReleaseEnvelope := + And.intro concrete_tool_trace_hash_matches_prop concrete_envelope_release_admissible_prop """.rstrip() ) @@ -640,16 +711,26 @@ def _computation_theorems_block() -> str: concreteVerification.verifiedInputBundleHash := (signedBundleAdmissibleD_sound _ _).mp concrete_signed_bundle_admissible +theorem concrete_envelope_projection_bound : + envelopeProjectionBoundD concreteEnvelopeProjection = true := by + decide + +theorem concrete_envelope_projection_bound_prop : + EnvelopeProjectionBound concreteEnvelopeProjection := + (envelopeProjectionBoundD_sound _).mp concrete_envelope_projection_bound + theorem concrete_computation_release_admissible_prop : - witnessResultHashesAdmissible concreteComputationWitness - concreteDeclaredResultArtifactHashes ∧ + EnvelopeProjectionBound concreteEnvelopeProjection ∧ + witnessResultHashesAdmissible concreteComputationWitness + concreteDeclaredResultArtifactHashes ∧ concreteResultArtifactHash ∈ concreteComputationWitness.resultHashes ∧ VerificationAdmitsBundle concreteVerification concreteCertifiedBundleHash ∧ SignedBundleAdmissible concreteSignedInputHash concreteVerification.verifiedInputBundleHash := - And.intro concrete_witness_result_hashes_admissible_prop - (And.intro concrete_witness_result_hash_listed_prop - (And.intro concrete_verification_admits_bundle_prop concrete_signed_bundle_admissible_prop)) + And.intro concrete_envelope_projection_bound_prop + (And.intro concrete_witness_result_hashes_admissible_prop + (And.intro concrete_witness_result_hash_listed_prop + (And.intro concrete_verification_admits_bundle_prop concrete_signed_bundle_admissible_prop))) """.strip() @@ -669,16 +750,14 @@ def generate_proof_obligation_file( release_id = assert_no_unknown_or_empty(release_id_raw.strip(), field="release_id") workflow_id = workflow_id_from_obligations(obligations_doc) projection_hash = obligations_doc.get("pcs_projection_manifest_hash") - projection_meta = "" - if projection_hash is not None: - proj = require_sha256_digest( - projection_hash, - field="pcs_projection_manifest_hash", - ) - projection_meta = ( - f"\n-- pcs_projection_manifest_hash: {proj}\n" - f"def pcsProjectionManifestHash : String := {lean_string_literal(proj)}\n" - ) + proj = require_sha256_digest( + projection_hash, + field="pcs_projection_manifest_hash", + ) + projection_meta = ( + f"\n-- pcs_projection_manifest_hash: {proj}\n" + f"def pcsProjectionManifestHash : String := {lean_string_literal(proj)}\n" + ) if workflow_id == "agent_tool_use.safety_v0": imports = "import PCS.ToolUse\nimport PCS.ReleaseChainCheck" @@ -704,15 +783,14 @@ def generate_proof_obligation_file( else: imports = "import PCS.ReleaseChainCheck" disclaimer = ( - "This discharges ProofObligation.v0 against `PCS.ReleaseChainAdmissible` " - "deciders only. " + "This discharges ProofObligation.v0 against `PCS.EnvelopeReleaseAdmissible` " + "(projection-bound release envelope). " "It does **not** imply PF-Core trace safety or `LeanKernelChecked` assurance." ) values_body = generate_release_chain_lean(obligations_doc) theorems = _release_chain_theorems_block() eval_line = """ -#eval releaseChainAdmissibleD concreteCertificate concreteRuntimeReceipt concreteVerification - concreteCertifiedBundleHash concreteSignedInputHash +#eval envelopeReleaseAdmissibleD concreteReleaseEnvelope """.strip() source = f"""{imports} @@ -758,18 +836,35 @@ def generate_from_release_dir(release_dir: Path, out_dir: Path) -> Path: def pcs_generated_dir() -> Path: - return repo_root() / "lean" / "PCS" / "Generated" + try: + return pcs_generated_root() + except FileNotFoundError: + from pcs_core.paths import repo_root + + return repo_root() / "lean" / "PCS" / "Generated" def compute_lean_environment_hash() -> str: """Hash pinned Lean toolchain + lake manifest for PCS proof metadata.""" - lean_root = repo_root() / "lean" + try: + lean_project = require_lean_root() + except FileNotFoundError: + from pcs_core.paths import repo_root + + lean_project = repo_root() / "lean" parts: list[str] = [] - toolchain = repo_root() / "lean-toolchain" + toolchain = lean_project / "lean-toolchain" + if not toolchain.is_file(): + # Historical mis-pin: lean-toolchain lived at repo root in some trees. + from pcs_core.paths import repo_root + + alt = repo_root() / "lean-toolchain" + if alt.is_file(): + toolchain = alt if toolchain.is_file(): parts.append(toolchain.read_text(encoding="utf-8")) for rel in ("lakefile.lean", "lake-manifest.json"): - path = lean_root / rel + path = lean_project / rel if path.is_file(): parts.append(path.read_text(encoding="utf-8")) digest = hashlib.sha256("\n---\n".join(parts).encode("utf-8")).hexdigest() @@ -777,8 +872,4 @@ def compute_lean_environment_hash() -> str: def proof_term_ref_from_path(path: Path) -> str: - root = repo_root() - try: - return str(path.relative_to(root)).replace("\\", "/") - except ValueError: - return str(path).replace("\\", "/") + return proof_ref_from_path(path) diff --git a/python/pcs_core/pcs_projection.py b/python/pcs_core/pcs_projection.py index 1fa39e0..186a568 100644 --- a/python/pcs_core/pcs_projection.py +++ b/python/pcs_core/pcs_projection.py @@ -2,23 +2,75 @@ from __future__ import annotations +import json import re from dataclasses import dataclass +from hashlib import sha256 from pathlib import Path -from typing import Any +from typing import Any, Callable + +from jsonpointer import JsonPointerException, resolve_pointer from pcs_core.hash import PLACEHOLDER_DIGEST, canonical_hash from pcs_core.obligation_extraction_errors import ( InvalidProofInputDigest, ObligationExtractionError, ) -from pcs_core.release_fixtures import file_digest _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") _UNKNOWN_RE = re.compile(r"(?i)(^|[^a-z0-9])unknown([^a-z0-9]|$)") _FORBIDDEN_VALUE_RE = re.compile( r"(?i)^(cert-unknown|release-unknown|proof-obligation-unknown|witness-unknown|unknown)$" ) +_LEAN_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$") + +# Versioned synthetic-resolver registry (B1). Digests bind resolver identity + resolved value. +PROJECTION_RESOLVER_REGISTRY_VERSION = "v0" +CERTIFIED_BUNDLE_RESOLVER_ID = "#resolved/certified_bundle_hash" + +# Synthetic JSON Pointer: normalized_value is the SHA-256 of the artifact file bytes (B3). +PAYLOAD_SHA256_POINTER = "/#payload_sha256" + + +@dataclass(frozen=True) +class SyntheticResolverSpec: + """Registered synthetic projection source (not a filesystem artifact).""" + + resolver_id: str + version: str + description: str + resolve: Callable[[Path], str] + + +def _resolve_certified_bundle_identity(release_dir: Path) -> str: + from pcs_core.bundle_identity import resolve_certified_bundle_identity_hash + + value = resolve_certified_bundle_identity_hash(release_dir) + if not isinstance(value, str) or not value: + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message="certified bundle identity could not be resolved", + artifact=CERTIFIED_BUNDLE_RESOLVER_ID, + field_path="/#resolved/certified_bundle_hash", + ) + return require_sha256_digest( + value, + field="/#resolved/certified_bundle_hash", + artifact=CERTIFIED_BUNDLE_RESOLVER_ID, + ) + + +SYNTHETIC_RESOLVER_REGISTRY: dict[str, SyntheticResolverSpec] = { + CERTIFIED_BUNDLE_RESOLVER_ID: SyntheticResolverSpec( + resolver_id=CERTIFIED_BUNDLE_RESOLVER_ID, + version=PROJECTION_RESOLVER_REGISTRY_VERSION, + description=( + "Certified bundle identity hash resolved from handoff invariants, " + "release-manifest chain_root, or certified bundle artifact digest" + ), + resolve=_resolve_certified_bundle_identity, + ), +} @dataclass(frozen=True) @@ -85,15 +137,107 @@ def require_nonempty_id(value: Any, *, field: str, artifact: str | None = None) return cleaned -def artifact_file_digest(release_dir: Path, relative: str) -> str: - path = release_dir / relative - if not path.is_file(): +def normalize_projected_value(raw: Any, *, field: str) -> str: + """Declared normalization for projected JSON values (string form, strip, reject unknowns).""" + if isinstance(raw, bool): + text = "true" if raw else "false" + elif isinstance(raw, int) and not isinstance(raw, bool): + text = str(raw) + elif isinstance(raw, str): + text = raw.strip() + elif raw is None: + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"{field}: JSON Pointer resolved to null", + field_path=field, + ) + else: + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"{field}: unsupported projected value type {type(raw).__name__}", + field_path=field, + ) + return assert_no_unknown_or_empty(text, field=field) + + +def synthetic_resolver_digest(*, resolver_id: str, resolver_version: str, normalized_value: str) -> str: + """Digest binding a registered resolver identity to the concrete resolved value. + + A digest of release_id + namespace alone is intentionally insufficient. + """ + require_sha256_digest(normalized_value, field=resolver_id, artifact=resolver_id) + payload = ( + f"pcs-projection-resolver:{resolver_version}:{resolver_id}:{normalized_value}" + ).encode("utf-8") + return f"sha256:{sha256(payload).hexdigest()}" + + +def is_synthetic_artifact_path(artifact_path: str) -> bool: + return artifact_path.startswith("#") + + +def lookup_synthetic_resolver(artifact_path: str) -> SyntheticResolverSpec: + spec = SYNTHETIC_RESOLVER_REGISTRY.get(artifact_path) + if spec is None: + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"unrecognized synthetic projection resolver: {artifact_path!r}", + artifact=artifact_path, + ) + return spec + + +def assert_path_contained(release_dir: Path, relative: str) -> Path: + """Ensure ``relative`` resolves to a regular file under ``release_dir`` (no traversal).""" + if not relative or relative.startswith("/") or relative.startswith("\\"): + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"artifact path must be release-relative, got {relative!r}", + artifact=relative, + ) + if ":" in relative.split("/")[0] and len(relative) >= 2 and relative[1] == ":": + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"artifact path must not be absolute, got {relative!r}", + artifact=relative, + ) + parts = Path(relative).parts + if any(part == ".." for part in parts): + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"artifact path escapes release root: {relative!r}", + artifact=relative, + ) + root = release_dir.resolve() + candidate = (root / relative).resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"artifact path escapes release root: {relative!r}", + artifact=relative, + ) from exc + if not candidate.is_file(): raise ObligationExtractionError( code="InvalidProofInputDigest", message=f"projection source artifact missing: {relative}", artifact=relative, ) - return file_digest(path.read_bytes()) + if candidate.is_symlink(): + # Symlink targets must still resolve under the release root (already checked via resolve). + pass + return candidate + + +def artifact_file_digest(release_dir: Path, relative: str) -> str: + path = assert_path_contained(release_dir, relative) + return f"sha256:{sha256(path.read_bytes()).hexdigest()}" + + +def _load_json_artifact(release_dir: Path, relative: str) -> Any: + path = assert_path_contained(release_dir, relative) + return json.loads(path.read_text(encoding="utf-8")) class ProjectionManifestBuilder: @@ -113,22 +257,27 @@ def __init__( self.projection_id = projection_id or f"pcs-projection-{self.release_id}" self._entries: list[ProjectionEntry] = [] self._digest_cache: dict[str, str] = {} + self._lean_ids: dict[str, str] = {} + self._pointer_keys: set[tuple[str, str]] = set() - def _digest_for(self, artifact_path: str) -> str: + def _file_digest_for(self, artifact_path: str) -> str: if artifact_path not in self._digest_cache: - if artifact_path.startswith("#"): - # Synthetic resolver source — hash the release_id + pointer namespace. - synthetic = f"synthetic:{self.release_id}:{artifact_path}".encode("utf-8") - from hashlib import sha256 - - self._digest_cache[artifact_path] = f"sha256:{sha256(synthetic).hexdigest()}" - else: - self._digest_cache[artifact_path] = artifact_file_digest( - self.release_dir, - artifact_path, - ) + self._digest_cache[artifact_path] = artifact_file_digest( + self.release_dir, + artifact_path, + ) return self._digest_cache[artifact_path] + def _digest_for_entry(self, artifact_path: str, normalized_value: str) -> str: + if is_synthetic_artifact_path(artifact_path): + spec = lookup_synthetic_resolver(artifact_path) + return synthetic_resolver_digest( + resolver_id=spec.resolver_id, + resolver_version=spec.version, + normalized_value=normalized_value, + ) + return self._file_digest_for(artifact_path) + def add( self, *, @@ -153,13 +302,56 @@ def add( artifact=artifact_path, ) assert_no_unknown_or_empty(lean_identifier, field="lean_identifier") + if not _LEAN_IDENT_RE.fullmatch(lean_identifier): + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"invalid lean_identifier {lean_identifier!r}", + field_path=lean_identifier, + artifact=artifact_path, + ) + if is_synthetic_artifact_path(artifact_path): + lookup_synthetic_resolver(artifact_path) + else: + assert_path_contained(self.release_dir, artifact_path) + + key = (artifact_path, json_pointer) + if key in self._pointer_keys: + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=( + f"duplicate projection entry for {artifact_path!r} pointer {json_pointer!r}" + ), + field_path=json_pointer, + artifact=artifact_path, + ) + prior = self._lean_ids.get(lean_identifier) + if prior is not None: + if prior != value: + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=( + f"conflicting Lean identifier {lean_identifier!r}: " + f"{prior!r} vs {value!r}" + ), + field_path=lean_identifier, + artifact=artifact_path, + ) + raise ObligationExtractionError( + code="InvalidProofInputDigest", + message=f"duplicate Lean identifier {lean_identifier!r}", + field_path=lean_identifier, + artifact=artifact_path, + ) + entry = ProjectionEntry( artifact_path=artifact_path, - artifact_digest=self._digest_for(artifact_path), + artifact_digest=self._digest_for_entry(artifact_path, value), json_pointer=json_pointer, normalized_value=value, lean_identifier=lean_identifier, ) + self._pointer_keys.add(key) + self._lean_ids[lean_identifier] = value self._entries.append(entry) return value @@ -187,3 +379,253 @@ def projection_manifest_hash(manifest: dict[str, Any]) -> str: if isinstance(digest, str) and _DIGEST_RE.fullmatch(digest): return digest return canonical_hash(manifest) + + +def recompute_projection_digest(manifest: dict[str, Any]) -> str: + """Recompute the projection digest from the manifest body (excluding self-hash).""" + return canonical_hash(manifest) + + +def validate_projection_manifest_structure(manifest: dict[str, Any]) -> list[str]: + """Structural projection checks that do not require a release directory.""" + errors: list[str] = [] + if not isinstance(manifest, dict): + return ["PCSProjectionManifest.v0 must be an object"] + entries = manifest.get("entries") + if not isinstance(entries, list) or not entries: + errors.append("PCSProjectionManifest.v0.entries must be a non-empty array") + return errors + + lean_ids: dict[str, str] = {} + pointer_keys: set[tuple[str, str]] = set() + for index, entry in enumerate(entries): + prefix = f"PCSProjectionManifest.v0.entries[{index}]" + if not isinstance(entry, dict): + errors.append(f"{prefix} must be an object") + continue + artifact_path = entry.get("artifact_path") + json_pointer = entry.get("json_pointer") + normalized_value = entry.get("normalized_value") + lean_identifier = entry.get("lean_identifier") + artifact_digest = entry.get("artifact_digest") + + if not isinstance(artifact_path, str) or not artifact_path: + errors.append(f"{prefix}.artifact_path must be a non-empty string") + continue + if not isinstance(json_pointer, str) or not json_pointer.startswith("/"): + errors.append(f"{prefix}.json_pointer must start with '/'") + if not isinstance(normalized_value, str) or not normalized_value.strip(): + errors.append(f"{prefix}.normalized_value must be non-empty") + elif "unknown" in normalized_value.lower(): + errors.append(f"{prefix}.normalized_value must not contain an unknown placeholder") + if not isinstance(lean_identifier, str) or not lean_identifier: + errors.append(f"{prefix}.lean_identifier must be a non-empty string") + elif not _LEAN_IDENT_RE.fullmatch(lean_identifier): + errors.append(f"{prefix}.lean_identifier has invalid form") + elif "unknown" in lean_identifier.lower(): + errors.append(f"{prefix}.lean_identifier must not contain an unknown placeholder") + if isinstance(artifact_digest, str): + if not _DIGEST_RE.fullmatch(artifact_digest): + errors.append(f"{prefix}.artifact_digest must be sha256:<64 hex>") + else: + errors.append(f"{prefix}.artifact_digest must be a sha256 digest") + + if is_synthetic_artifact_path(artifact_path): + if artifact_path not in SYNTHETIC_RESOLVER_REGISTRY: + errors.append( + f"{prefix}: unrecognized synthetic projection resolver {artifact_path!r}", + ) + elif isinstance(normalized_value, str) and _DIGEST_RE.fullmatch(normalized_value): + spec = SYNTHETIC_RESOLVER_REGISTRY[artifact_path] + expected = synthetic_resolver_digest( + resolver_id=spec.resolver_id, + resolver_version=spec.version, + normalized_value=normalized_value, + ) + if artifact_digest != expected: + errors.append( + f"{prefix}.artifact_digest does not match resolver-bound digest", + ) + else: + if ".." in Path(artifact_path).parts or artifact_path.startswith(("/", "\\")): + errors.append(f"{prefix}.artifact_path escapes or is not release-relative") + + if isinstance(artifact_path, str) and isinstance(json_pointer, str): + key = (artifact_path, json_pointer) + if key in pointer_keys: + errors.append( + f"{prefix}: duplicate entry for {artifact_path!r} pointer {json_pointer!r}", + ) + pointer_keys.add(key) + + if isinstance(lean_identifier, str) and isinstance(normalized_value, str): + prior = lean_ids.get(lean_identifier) + if prior is not None: + if prior != normalized_value: + errors.append( + f"{prefix}: conflicting Lean identifier {lean_identifier!r}", + ) + else: + errors.append( + f"{prefix}: duplicate Lean identifier {lean_identifier!r}", + ) + else: + lean_ids[lean_identifier] = normalized_value + + declared = manifest.get("signature_or_digest") + if isinstance(declared, str) and _DIGEST_RE.fullmatch(declared): + recomputed = recompute_projection_digest(manifest) + if declared != recomputed: + errors.append( + "PCSProjectionManifest.v0.signature_or_digest does not match recomputed digest", + ) + return errors + + +def validate_projection_against_release( + manifest: dict[str, Any], + release_dir: Path, + *, + expected_hash: str | None = None, +) -> list[str]: + """Full projection replay against a release root (B1 semantic validation).""" + errors = validate_projection_manifest_structure(manifest) + release_dir = release_dir.resolve() + entries = manifest.get("entries") + if not isinstance(entries, list): + return errors + + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + prefix = f"PCSProjectionManifest.v0.entries[{index}]" + artifact_path = entry.get("artifact_path") + json_pointer = entry.get("json_pointer") + declared_value = entry.get("normalized_value") + declared_digest = entry.get("artifact_digest") + lean_identifier = entry.get("lean_identifier") + if not isinstance(artifact_path, str) or not isinstance(json_pointer, str): + continue + if not isinstance(declared_value, str) or not isinstance(lean_identifier, str): + continue + + try: + if is_synthetic_artifact_path(artifact_path): + spec = lookup_synthetic_resolver(artifact_path) + resolved = normalize_projected_value( + spec.resolve(release_dir), + field=lean_identifier, + ) + expected_digest = synthetic_resolver_digest( + resolver_id=spec.resolver_id, + resolver_version=spec.version, + normalized_value=resolved, + ) + else: + recomputed_digest = artifact_file_digest(release_dir, artifact_path) + if declared_digest != recomputed_digest: + errors.append( + f"{prefix}.artifact_digest mismatch for {artifact_path!r}", + ) + if json_pointer == PAYLOAD_SHA256_POINTER: + # B3: entry binds the payload file digest itself (not a JSON field). + # Reject symlink / reparse escapes the same way as ResultArtifact verification. + from pcs_core.safe_paths import UnsafePathError, resolve_contained_file + + try: + resolve_contained_file(release_dir, artifact_path) + except UnsafePathError as exc: + errors.append( + f"{prefix}: unsafe payload path {artifact_path!r}: {exc}", + ) + continue + resolved = normalize_projected_value( + recomputed_digest, + field=lean_identifier, + ) + if declared_value != recomputed_digest: + errors.append( + f"{prefix}.normalized_value must equal payload file digest " + f"for {PAYLOAD_SHA256_POINTER}", + ) + expected_digest = recomputed_digest + else: + doc = _load_json_artifact(release_dir, artifact_path) + try: + raw = resolve_pointer(doc, json_pointer) + except (JsonPointerException, KeyError, TypeError, ValueError) as exc: + errors.append( + f"{prefix}: failed to resolve JSON Pointer {json_pointer!r}: {exc}", + ) + continue + resolved = normalize_projected_value(raw, field=lean_identifier) + expected_digest = recomputed_digest + + if resolved != declared_value: + errors.append( + f"{prefix}.normalized_value mismatch: declared {declared_value!r} " + f"!= resolved {resolved!r}", + ) + if declared_digest != expected_digest: + errors.append( + f"{prefix}.artifact_digest does not match recomputed digest", + ) + except ObligationExtractionError as exc: + errors.append(f"{prefix}: {exc}") + + if expected_hash is not None: + try: + require_sha256_digest(expected_hash, field="pcs_projection_manifest_hash") + actual = projection_manifest_hash(manifest) + if actual != expected_hash: + errors.append( + "pcs_projection_manifest_hash does not match projection digest", + ) + except ObligationExtractionError as exc: + errors.append(str(exc)) + + return errors + + +def validate_proof_obligation_projection( + data: dict[str, Any], + *, + release_dir: Path | None = None, +) -> list[str]: + """Validate mandatory PCS projection fields on ProofObligation.v0.""" + errors: list[str] = [] + manifest = data.get("pcs_projection_manifest") + proj_hash = data.get("pcs_projection_manifest_hash") + if not isinstance(manifest, dict): + errors.append("ProofObligation.v0 requires pcs_projection_manifest") + return errors + if not isinstance(proj_hash, str) or not _DIGEST_RE.fullmatch(proj_hash): + errors.append( + "ProofObligation.v0 requires pcs_projection_manifest_hash as sha256 digest", + ) + + release_id = data.get("release_id") + workflow_id = data.get("workflow_id") + if isinstance(release_id, str) and manifest.get("release_id") != release_id: + errors.append("pcs_projection_manifest.release_id must match ProofObligation.release_id") + if isinstance(workflow_id, str) and manifest.get("workflow_id") != workflow_id: + errors.append( + "pcs_projection_manifest.workflow_id must match ProofObligation.workflow_id", + ) + + if release_dir is not None: + errors.extend( + validate_projection_against_release( + manifest, + release_dir, + expected_hash=proj_hash if isinstance(proj_hash, str) else None, + ), + ) + else: + errors.extend(validate_projection_manifest_structure(manifest)) + if isinstance(proj_hash, str) and _DIGEST_RE.fullmatch(proj_hash): + if projection_manifest_hash(manifest) != proj_hash: + errors.append( + "pcs_projection_manifest_hash does not match projection digest", + ) + return errors diff --git a/python/tests/test_pcs_lean_codegen.py b/python/tests/test_pcs_lean_codegen.py index e39c40c..9349bb0 100644 --- a/python/tests/test_pcs_lean_codegen.py +++ b/python/tests/test_pcs_lean_codegen.py @@ -39,11 +39,14 @@ def test_generate_proof_obligation_file_writes_theorems(tmp_path: Path) -> None: doc = extract_proof_obligations_from_release(LABTRUST) path = generate_proof_obligation_file(doc, tmp_path) text = path.read_text(encoding="utf-8") - assert "concrete_release_chain_admissible" in text + assert "concrete_envelope_release_admissible" in text + assert "concrete_envelope_release_admissible_prop" in text + assert "concreteReleaseEnvelope" in text + assert "EnvelopeReleaseAdmissible" in text assert "concrete_certificate_matches_runtime_prop" in text assert "concrete_verification_admits_bundle_prop" in text assert "concrete_signed_bundle_admissible_prop" in text - assert "releaseChainAdmissibleD" in text + assert "envelopeReleaseAdmissibleD" in text assert "ReleaseChainAdmissible" in text assert generated_module_name(doc) in text @@ -77,7 +80,8 @@ def test_generate_from_release_dir_matches_committed_fixture() -> None: path = generate_from_release_dir(LABTRUST, generated) assert path.is_file() text = path.read_text(encoding="utf-8") - assert "concrete_release_chain_admissible_prop" in text + assert "concrete_envelope_release_admissible_prop" in text + assert "concreteReleaseEnvelope" in text assert "concrete_certificate_matches_runtime_prop" in text assert "concrete_verification_admits_bundle_prop" in text assert "concrete_signed_bundle_admissible_prop" in text diff --git a/python/tests/test_pcs_projection_binding.py b/python/tests/test_pcs_projection_binding.py new file mode 100644 index 0000000..fdf2ee4 --- /dev/null +++ b/python/tests/test_pcs_projection_binding.py @@ -0,0 +1,205 @@ +"""PR9 — mandatory PCS projection + Lean envelope binding.""" + +from __future__ import annotations + +import copy +import json +import shutil +from pathlib import Path + +import pytest + +from pcs_core.lean_trust import extract_proof_obligations_from_release, run_lean_check +from pcs_core.obligation_extraction_errors import ObligationExtractionError +from pcs_core.paths import examples_dir +from pcs_core.pcs_lean_codegen import ( + aggregate_lean_theorem_for_workflow, + generate_proof_obligation_file, +) +from pcs_core.pcs_projection import ( + CERTIFIED_BUNDLE_RESOLVER_ID, + PROJECTION_RESOLVER_REGISTRY_VERSION, + ProjectionManifestBuilder, + synthetic_resolver_digest, + validate_projection_against_release, + validate_proof_obligation_projection, +) +from pcs_core.validate import validate_artifact + +LABTRUST = examples_dir() / "labtrust-release" +TOOL_USE = examples_dir() / "tool-use-release" +COMPUTATION = examples_dir() / "computation-release" + + +def _copy_release(src: Path, dest: Path) -> Path: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + return dest + + +def test_proof_obligation_schema_requires_projection() -> None: + doc = extract_proof_obligations_from_release(LABTRUST) + validate_artifact(doc, "ProofObligation.v0", release_grade=False) + broken = copy.deepcopy(doc) + del broken["pcs_projection_manifest"] + del broken["pcs_projection_manifest_hash"] + # Recompute root digest after mutation is unnecessary; schema must reject missing fields. + from pcs_core.validate_detect import ValidationError + + with pytest.raises(ValidationError): + validate_artifact(broken, "ProofObligation.v0", release_grade=False) + + +def test_projection_replay_against_release() -> None: + doc = extract_proof_obligations_from_release(LABTRUST) + errors = validate_projection_against_release( + doc["pcs_projection_manifest"], + LABTRUST, + expected_hash=doc["pcs_projection_manifest_hash"], + ) + assert errors == [] + assert validate_proof_obligation_projection(doc, release_dir=LABTRUST) == [] + + +def test_certified_bundle_resolver_binds_value_not_namespace_alone() -> None: + doc = extract_proof_obligations_from_release(LABTRUST) + entries = doc["pcs_projection_manifest"]["entries"] + synthetic = [ + entry + for entry in entries + if entry["artifact_path"] == CERTIFIED_BUNDLE_RESOLVER_ID + ] + assert len(synthetic) == 1 + entry = synthetic[0] + expected = synthetic_resolver_digest( + resolver_id=CERTIFIED_BUNDLE_RESOLVER_ID, + resolver_version=PROJECTION_RESOLVER_REGISTRY_VERSION, + normalized_value=entry["normalized_value"], + ) + assert entry["artifact_digest"] == expected + # Legacy release-id+namespace digest must not match. + from hashlib import sha256 + + legacy_payload = f"synthetic:{doc['release_id']}:{CERTIFIED_BUNDLE_RESOLVER_ID}" + legacy = f"sha256:{sha256(legacy_payload.encode()).hexdigest()}" + assert entry["artifact_digest"] != legacy + + +def test_reject_unrecognized_synthetic_resolver(tmp_path: Path) -> None: + release = _copy_release(LABTRUST, tmp_path / "release") + builder = ProjectionManifestBuilder( + release_dir=release, + release_id="release-pcs-v0.1-labtrust-qc", + workflow_id="labtrust.qc_release_v0.1", + ) + with pytest.raises(ObligationExtractionError, match="unrecognized synthetic"): + builder.add( + artifact_path="#resolved/not_a_real_resolver", + json_pointer="/#resolved/not_a_real_resolver", + normalized_value="sha256:" + ("ab" * 32), + lean_identifier="bogusResolver", + require_digest=True, + ) + + +def test_reject_duplicate_and_conflicting_lean_identifiers(tmp_path: Path) -> None: + release = _copy_release(LABTRUST, tmp_path / "release") + builder = ProjectionManifestBuilder( + release_dir=release, + release_id="release-pcs-v0.1-labtrust-qc", + workflow_id="labtrust.qc_release_v0.1", + ) + builder.add( + artifact_path="trace_certificate.json", + json_pointer="/certificate_id", + normalized_value="cert-a", + lean_identifier="concreteCertificate.certificateId", + ) + with pytest.raises(ObligationExtractionError, match="conflicting Lean identifier"): + builder.add( + artifact_path="trace_certificate.json", + json_pointer="/status", + normalized_value="CertificateChecked", + lean_identifier="concreteCertificate.certificateId", + ) + + +def test_reject_path_traversal_in_projection(tmp_path: Path) -> None: + release = _copy_release(LABTRUST, tmp_path / "release") + builder = ProjectionManifestBuilder( + release_dir=release, + release_id="release-pcs-v0.1-labtrust-qc", + workflow_id="labtrust.qc_release_v0.1", + ) + with pytest.raises(ObligationExtractionError, match="escapes"): + builder.add( + artifact_path="../secrets.json", + json_pointer="/x", + normalized_value="nope", + lean_identifier="evil", + ) + + +def test_mutation_of_normalized_value_fails_replay(tmp_path: Path) -> None: + doc = extract_proof_obligations_from_release(LABTRUST) + mutated = copy.deepcopy(doc["pcs_projection_manifest"]) + mutated["entries"][0]["normalized_value"] = "tampered-value-not-unknown" + errors = validate_projection_against_release(mutated, LABTRUST) + assert any("normalized_value mismatch" in err for err in errors) + + +def test_codegen_binds_projection_into_release_envelope(tmp_path: Path) -> None: + doc = extract_proof_obligations_from_release(LABTRUST) + path = generate_proof_obligation_file(doc, tmp_path) + text = path.read_text(encoding="utf-8") + assert "concreteReleaseEnvelope" in text + assert "EnvelopeReleaseAdmissible" in text + assert "concrete_envelope_release_admissible_prop" in text + assert "projectionDigest" in text + assert doc["pcs_projection_manifest_hash"].removeprefix("sha256:") in text + assert aggregate_lean_theorem_for_workflow(doc["workflow_id"]) == ( + "concrete_envelope_release_admissible_prop" + ) + + +def test_tool_use_and_computation_bind_projection(tmp_path: Path) -> None: + tool_doc = extract_proof_obligations_from_release(TOOL_USE) + tool_path = generate_proof_obligation_file(tool_doc, tmp_path / "tool", release_dir=TOOL_USE) + tool_text = tool_path.read_text(encoding="utf-8") + assert "concreteReleaseEnvelope" in tool_text + assert "EnvelopeReleaseAdmissible concreteReleaseEnvelope" in tool_text + + comp_doc = extract_proof_obligations_from_release(COMPUTATION) + comp_path = generate_proof_obligation_file( + comp_doc, + tmp_path / "comp", + release_dir=COMPUTATION, + ) + comp_text = comp_path.read_text(encoding="utf-8") + assert "concreteEnvelopeProjection" in comp_text + assert "EnvelopeProjectionBound" in comp_text + + +def test_envelope_lean_checked_requires_projection_hash() -> None: + doc = extract_proof_obligations_from_release(LABTRUST) + result = run_lean_check(doc, require_lean_build=False, lean_proof=False) + assert result["pcs_projection_manifest_hash"] == doc["pcs_projection_manifest_hash"] + from pcs_core.lean_validate import validate_lean_check_result_semantics + + forged = copy.deepcopy(result) + forged["claim_class"] = "EnvelopeLeanChecked" + forged["lean_proof_checked"] = True + forged["proof_term_ref"] = "lean/PCS/Generated/x.lean" + del forged["pcs_projection_manifest_hash"] + errors = validate_lean_check_result_semantics(forged) + assert any("pcs_projection_manifest_hash" in err for err in errors) + + +def test_run_lean_check_rejects_missing_projection() -> None: + doc = extract_proof_obligations_from_release(LABTRUST) + broken = copy.deepcopy(doc) + del broken["pcs_projection_manifest"] + del broken["pcs_projection_manifest_hash"] + with pytest.raises(ObligationExtractionError): + run_lean_check(broken, require_lean_build=False, lean_proof=False) diff --git a/schemas/ProofObligation.v0.schema.json b/schemas/ProofObligation.v0.schema.json index ad3c29d..27cce47 100644 --- a/schemas/ProofObligation.v0.schema.json +++ b/schemas/ProofObligation.v0.schema.json @@ -10,6 +10,8 @@ "workflow_id", "obligations", "source_artifacts", + "pcs_projection_manifest", + "pcs_projection_manifest_hash", "lean_module", "source_repo", "source_commit", From bb3fffaa3ac7f6a57bd304799285997831e496f8 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:01:27 -0700 Subject: [PATCH 12/24] Verify ResultArtifact payload digests against on-disk bytes. Reject missing, modified, symlink, and traversal payloads with dedicated invalid fixtures so release chains cannot accept hash-only claims. --- .../computation_witness.json | 4 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 6 +- .../computation_run_receipt.json | 21 ++ .../computation_witness.json | 20 ++ .../dataset_receipt.json | 21 ++ .../environment_receipt.json | 20 ++ .../outputs/metrics.json | 1 + .../outputs/metrics_dup.json | 1 + .../result_artifact.json | 14 ++ .../result_artifact_2.json | 14 ++ .../computation_witness.json | 6 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 6 +- .../computation_witness.json | 4 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 4 +- .../computation_witness.json | 4 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 6 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 6 +- .../computation_witness.json | 4 +- .../missing_code_commit/outputs/metrics.json | 1 + .../missing_code_commit/result_artifact.json | 6 +- .../computation_witness.json | 4 +- .../missing_dataset_hash/outputs/metrics.json | 1 + .../missing_dataset_hash/result_artifact.json | 6 +- .../computation_witness.json | 4 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 6 +- .../computation_witness.json | 4 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 6 +- .../computation_witness.json | 4 +- .../nonzero_exit_code/outputs/metrics.json | 1 + .../nonzero_exit_code/result_artifact.json | 6 +- .../computation_run_receipt.json | 21 ++ .../computation_witness.json | 20 ++ .../dataset_receipt.json | 21 ++ .../environment_receipt.json | 20 ++ .../outputs/metrics.json | 1 + .../result_artifact.json | 14 ++ .../computation_run_receipt.json | 21 ++ .../payload_missing/computation_witness.json | 20 ++ .../payload_missing/dataset_receipt.json | 21 ++ .../payload_missing/environment_receipt.json | 20 ++ .../payload_missing/result_artifact.json | 14 ++ .../computation_run_receipt.json | 21 ++ .../payload_modified/computation_witness.json | 20 ++ .../payload_modified/dataset_receipt.json | 21 ++ .../payload_modified/environment_receipt.json | 20 ++ .../payload_modified/outputs/metrics.json | 1 + .../payload_modified/result_artifact.json | 14 ++ .../payload_symlink/README.md | 3 + .../computation_run_receipt.json | 21 ++ .../payload_symlink/computation_witness.json | 20 ++ .../payload_symlink/dataset_receipt.json | 21 ++ .../payload_symlink/environment_receipt.json | 20 ++ .../payload_symlink/outputs/metrics.json | 1 + .../payload_symlink/result_artifact.json | 14 ++ .../computation_run_receipt.json | 21 ++ .../computation_witness.json | 20 ++ .../payload_traversal/dataset_receipt.json | 21 ++ .../environment_receipt.json | 20 ++ .../payload_traversal/result_artifact.json | 14 ++ .../computation_run_receipt.json | 21 ++ .../computation_witness.json | 20 ++ .../payload_wrong_size/dataset_receipt.json | 21 ++ .../environment_receipt.json | 20 ++ .../payload_wrong_size/outputs/metrics.json | 1 + .../payload_wrong_size/result_artifact.json | 14 ++ .../computation_witness.json | 4 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 6 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 4 +- .../result_hash_mismatch/outputs/metrics.json | 1 + .../result_hash_mismatch/result_artifact.json | 6 +- .../computation_witness.json | 4 +- .../outputs/metrics.json | 1 + .../result_artifact.json | 6 +- .../RELEASE_FIXTURE_MANIFEST.json | 6 +- .../computation_witness.json | 4 +- .../computation-release/outputs/metrics.json | 1 + .../computation-release/result_artifact.json | 6 +- examples/computation_witness.valid.json | 4 +- examples/result_artifact.valid.json | 6 +- python/pcs_core/computation_validate.py | 213 +++++++++++++++++- python/pcs_core/safe_paths.py | 45 +++- .../materialize_computation_fixtures.py | 139 +++++++++++- python/tests/test_result_artifact_payload.py | 196 ++++++++++++++++ 92 files changed, 1357 insertions(+), 98 deletions(-) create mode 100644 examples/computation-release-invalid/dataset_hash_mismatch/outputs/metrics.json create mode 100644 examples/computation-release-invalid/duplicate_result_declaration/computation_run_receipt.json create mode 100644 examples/computation-release-invalid/duplicate_result_declaration/computation_witness.json create mode 100644 examples/computation-release-invalid/duplicate_result_declaration/dataset_receipt.json create mode 100644 examples/computation-release-invalid/duplicate_result_declaration/environment_receipt.json create mode 100644 examples/computation-release-invalid/duplicate_result_declaration/outputs/metrics.json create mode 100644 examples/computation-release-invalid/duplicate_result_declaration/outputs/metrics_dup.json create mode 100644 examples/computation-release-invalid/duplicate_result_declaration/result_artifact.json create mode 100644 examples/computation-release-invalid/duplicate_result_declaration/result_artifact_2.json create mode 100644 examples/computation-release-invalid/duplicate_result_hash/outputs/metrics.json create mode 100644 examples/computation-release-invalid/empty_declared_nonempty_witness/outputs/metrics.json create mode 100644 examples/computation-release-invalid/environment_digest_mismatch/outputs/metrics.json create mode 100644 examples/computation-release-invalid/manifest_result_absent_from_witness/outputs/metrics.json create mode 100644 examples/computation-release-invalid/missing_code_commit/outputs/metrics.json create mode 100644 examples/computation-release-invalid/missing_dataset_hash/outputs/metrics.json create mode 100644 examples/computation-release-invalid/missing_environment_hash/outputs/metrics.json create mode 100644 examples/computation-release-invalid/missing_run_receipt_hash/outputs/metrics.json create mode 100644 examples/computation-release-invalid/nonzero_exit_code/outputs/metrics.json create mode 100644 examples/computation-release-invalid/payload_digest_mismatch_envelope/computation_run_receipt.json create mode 100644 examples/computation-release-invalid/payload_digest_mismatch_envelope/computation_witness.json create mode 100644 examples/computation-release-invalid/payload_digest_mismatch_envelope/dataset_receipt.json create mode 100644 examples/computation-release-invalid/payload_digest_mismatch_envelope/environment_receipt.json create mode 100644 examples/computation-release-invalid/payload_digest_mismatch_envelope/outputs/metrics.json create mode 100644 examples/computation-release-invalid/payload_digest_mismatch_envelope/result_artifact.json create mode 100644 examples/computation-release-invalid/payload_missing/computation_run_receipt.json create mode 100644 examples/computation-release-invalid/payload_missing/computation_witness.json create mode 100644 examples/computation-release-invalid/payload_missing/dataset_receipt.json create mode 100644 examples/computation-release-invalid/payload_missing/environment_receipt.json create mode 100644 examples/computation-release-invalid/payload_missing/result_artifact.json create mode 100644 examples/computation-release-invalid/payload_modified/computation_run_receipt.json create mode 100644 examples/computation-release-invalid/payload_modified/computation_witness.json create mode 100644 examples/computation-release-invalid/payload_modified/dataset_receipt.json create mode 100644 examples/computation-release-invalid/payload_modified/environment_receipt.json create mode 100644 examples/computation-release-invalid/payload_modified/outputs/metrics.json create mode 100644 examples/computation-release-invalid/payload_modified/result_artifact.json create mode 100644 examples/computation-release-invalid/payload_symlink/README.md create mode 100644 examples/computation-release-invalid/payload_symlink/computation_run_receipt.json create mode 100644 examples/computation-release-invalid/payload_symlink/computation_witness.json create mode 100644 examples/computation-release-invalid/payload_symlink/dataset_receipt.json create mode 100644 examples/computation-release-invalid/payload_symlink/environment_receipt.json create mode 100644 examples/computation-release-invalid/payload_symlink/outputs/metrics.json create mode 100644 examples/computation-release-invalid/payload_symlink/result_artifact.json create mode 100644 examples/computation-release-invalid/payload_traversal/computation_run_receipt.json create mode 100644 examples/computation-release-invalid/payload_traversal/computation_witness.json create mode 100644 examples/computation-release-invalid/payload_traversal/dataset_receipt.json create mode 100644 examples/computation-release-invalid/payload_traversal/environment_receipt.json create mode 100644 examples/computation-release-invalid/payload_traversal/result_artifact.json create mode 100644 examples/computation-release-invalid/payload_wrong_size/computation_run_receipt.json create mode 100644 examples/computation-release-invalid/payload_wrong_size/computation_witness.json create mode 100644 examples/computation-release-invalid/payload_wrong_size/dataset_receipt.json create mode 100644 examples/computation-release-invalid/payload_wrong_size/environment_receipt.json create mode 100644 examples/computation-release-invalid/payload_wrong_size/outputs/metrics.json create mode 100644 examples/computation-release-invalid/payload_wrong_size/result_artifact.json create mode 100644 examples/computation-release-invalid/rejected_computation_witness/outputs/metrics.json create mode 100644 examples/computation-release-invalid/result_file_hash_ne_payload/outputs/metrics.json create mode 100644 examples/computation-release-invalid/result_hash_mismatch/outputs/metrics.json create mode 100644 examples/computation-release-invalid/witness_undeclared_extra_result/outputs/metrics.json create mode 100644 examples/computation-release/outputs/metrics.json create mode 100644 python/tests/test_result_artifact_payload.py diff --git a/examples/computation-release-invalid/dataset_hash_mismatch/computation_witness.json b/examples/computation-release-invalid/dataset_hash_mismatch/computation_witness.json index 10e758f..b5e26ac 100644 --- a/examples/computation-release-invalid/dataset_hash_mismatch/computation_witness.json +++ b/examples/computation-release-invalid/dataset_hash_mismatch/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:c4bfb99827e08001a308ee3d91a91b01fa4f5544375a6c089600ae91528be7af" + "signature_or_digest": "sha256:8a7bf1d5b5655f540ba2478f5a716dd61fd16dba91d349de3387e4723e117f7c" } diff --git a/examples/computation-release-invalid/dataset_hash_mismatch/outputs/metrics.json b/examples/computation-release-invalid/dataset_hash_mismatch/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/dataset_hash_mismatch/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/dataset_hash_mismatch/result_artifact.json b/examples/computation-release-invalid/dataset_hash_mismatch/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/dataset_hash_mismatch/result_artifact.json +++ b/examples/computation-release-invalid/dataset_hash_mismatch/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/duplicate_result_declaration/computation_run_receipt.json b/examples/computation-release-invalid/duplicate_result_declaration/computation_run_receipt.json new file mode 100644 index 0000000..ed227ab --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_declaration/computation_run_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "run_id": "run-sci-comp-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "command": "python -m experiment.run --seed 42", + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "dataset_receipt_ref": "dataset-input-001", + "environment_receipt_ref": "env-repro-001", + "started_at": "2026-05-18T00:00:01Z", + "completed_at": "2026-05-18T00:00:10Z", + "exit_code": 0, + "stdout_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "stderr_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "result_artifact_refs": [ + "result-metric-001" + ], + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828" +} diff --git a/examples/computation-release-invalid/duplicate_result_declaration/computation_witness.json b/examples/computation-release-invalid/duplicate_result_declaration/computation_witness.json new file mode 100644 index 0000000..af6a105 --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_declaration/computation_witness.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "witness_id": "witness-sci-comp-repro-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", + "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", + "result_hashes": [ + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" + ], + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "checker": "certifyedge", + "checker_version": "0.1.0", + "status": "CertificateChecked", + "violations": [], + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "b222222222222222222222222222222222222222", + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" +} diff --git a/examples/computation-release-invalid/duplicate_result_declaration/dataset_receipt.json b/examples/computation-release-invalid/duplicate_result_declaration/dataset_receipt.json new file mode 100644 index 0000000..bdb3061 --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_declaration/dataset_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "dataset_id": "dataset-input-001", + "dataset_name": "conformance-input", + "dataset_version": "1.0.0", + "files": [ + { + "path": "data/input.csv", + "sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size_bytes": 123456, + "media_type": "text/csv" + } + ], + "aggregate_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "source_uri": "https://example.org/datasets/conformance-input/1.0.0", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "license": "CC-BY-4.0", + "created_at": "2026-05-18T00:00:00Z", + "signature_or_digest": "sha256:4fdd6999e67eebcfec9f8f84800454614693fad9c4dbf5d887051025ce2cdeae" +} diff --git a/examples/computation-release-invalid/duplicate_result_declaration/environment_receipt.json b/examples/computation-release-invalid/duplicate_result_declaration/environment_receipt.json new file mode 100644 index 0000000..108e59c --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_declaration/environment_receipt.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "environment_id": "env-repro-001", + "environment_kind": "uv", + "os": "linux", + "architecture": "x86_64", + "language_runtimes": [ + "python==3.12.3" + ], + "packages": [ + "numpy==2.1.0", + "pandas==2.2.2" + ], + "container_image": "", + "container_digest": "", + "hardware_summary": "conformance-runner", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01" +} diff --git a/examples/computation-release-invalid/duplicate_result_declaration/outputs/metrics.json b/examples/computation-release-invalid/duplicate_result_declaration/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_declaration/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/duplicate_result_declaration/outputs/metrics_dup.json b/examples/computation-release-invalid/duplicate_result_declaration/outputs/metrics_dup.json new file mode 100644 index 0000000..19f8f32 --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_declaration/outputs/metrics_dup.json @@ -0,0 +1 @@ +{"metric":"duplicate","value":0.1} diff --git a/examples/computation-release-invalid/duplicate_result_declaration/result_artifact.json b/examples/computation-release-invalid/duplicate_result_declaration/result_artifact.json new file mode 100644 index 0000000..c7a5087 --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_declaration/result_artifact.json @@ -0,0 +1,14 @@ +{ + "schema_version": "v0", + "result_id": "result-metric-001", + "result_kind": "metric", + "path": "outputs/metrics.json", + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, + "media_type": "application/json", + "description": "Primary reproducibility metric output", + "produced_by_run": "run-sci-comp-001", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" +} diff --git a/examples/computation-release-invalid/duplicate_result_declaration/result_artifact_2.json b/examples/computation-release-invalid/duplicate_result_declaration/result_artifact_2.json new file mode 100644 index 0000000..57eee32 --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_declaration/result_artifact_2.json @@ -0,0 +1,14 @@ +{ + "schema_version": "v0", + "result_id": "result-metric-001", + "result_kind": "metric", + "path": "outputs/metrics_dup.json", + "sha256": "sha256:9c6027bd1539c6eac2fca1ffc7ed1e98a1aa82984045e7e30760cc25abee33bf", + "size_bytes": 35, + "media_type": "application/json", + "description": "Primary reproducibility metric output", + "produced_by_run": "run-sci-comp-001", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:7a10ed97e031fc0dfc1877392f35a3cfd83229581166caf3e0d1b9d0fede11ba" +} diff --git a/examples/computation-release-invalid/duplicate_result_hash/computation_witness.json b/examples/computation-release-invalid/duplicate_result_hash/computation_witness.json index 0c7c963..4747ff9 100644 --- a/examples/computation-release-invalid/duplicate_result_hash/computation_witness.json +++ b/examples/computation-release-invalid/duplicate_result_hash/computation_witness.json @@ -6,8 +6,8 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -17,5 +17,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:6d02a8fdcd969ac99c01484c36e3522bb52403eaa3f9d59857bd6996abb4dc49" + "signature_or_digest": "sha256:67edef7de78e2988fac5b55f2dd01e8d9a3a134bef005a815feab0a4eb895bcc" } diff --git a/examples/computation-release-invalid/duplicate_result_hash/outputs/metrics.json b/examples/computation-release-invalid/duplicate_result_hash/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/duplicate_result_hash/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/duplicate_result_hash/result_artifact.json b/examples/computation-release-invalid/duplicate_result_hash/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/duplicate_result_hash/result_artifact.json +++ b/examples/computation-release-invalid/duplicate_result_hash/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/empty_declared_nonempty_witness/computation_witness.json b/examples/computation-release-invalid/empty_declared_nonempty_witness/computation_witness.json index 30f7d24..af6a105 100644 --- a/examples/computation-release-invalid/empty_declared_nonempty_witness/computation_witness.json +++ b/examples/computation-release-invalid/empty_declared_nonempty_witness/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:68930f59c18ca213df059cec7b3097c5bdfc177bfbadb00ff0e14697f7fe8da8" + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" } diff --git a/examples/computation-release-invalid/empty_declared_nonempty_witness/outputs/metrics.json b/examples/computation-release-invalid/empty_declared_nonempty_witness/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/empty_declared_nonempty_witness/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/empty_declared_nonempty_witness/result_artifact.json b/examples/computation-release-invalid/empty_declared_nonempty_witness/result_artifact.json index ce8af84..8a17403 100644 --- a/examples/computation-release-invalid/empty_declared_nonempty_witness/result_artifact.json +++ b/examples/computation-release-invalid/empty_declared_nonempty_witness/result_artifact.json @@ -4,11 +4,11 @@ "result_kind": "metric", "path": "outputs/metrics.json", "sha256": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "size_bytes": 2048, + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:6c4ba9d09e7b15c0db4b2dcf24ae4da8db66a24208cb0fe6ecc99c56fd274efd" + "signature_or_digest": "sha256:0f4e5f63eeb883750d6833a90b978da2cbafb862bd848d6ab10672183b82a1fe" } diff --git a/examples/computation-release-invalid/environment_digest_mismatch/computation_witness.json b/examples/computation-release-invalid/environment_digest_mismatch/computation_witness.json index dc87ee4..ae6c1d5 100644 --- a/examples/computation-release-invalid/environment_digest_mismatch/computation_witness.json +++ b/examples/computation-release-invalid/environment_digest_mismatch/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:d783068f25856cdc9b0ebc37acf15bf4ef739587e09dc41e190571016e742d66" + "signature_or_digest": "sha256:d69a479c53ab6df4cf58614ba457ddc15b6e4f2ccb5bcd423c0e92b5ce8ffe37" } diff --git a/examples/computation-release-invalid/environment_digest_mismatch/outputs/metrics.json b/examples/computation-release-invalid/environment_digest_mismatch/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/environment_digest_mismatch/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/environment_digest_mismatch/result_artifact.json b/examples/computation-release-invalid/environment_digest_mismatch/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/environment_digest_mismatch/result_artifact.json +++ b/examples/computation-release-invalid/environment_digest_mismatch/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/manifest_result_absent_from_witness/outputs/metrics.json b/examples/computation-release-invalid/manifest_result_absent_from_witness/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/manifest_result_absent_from_witness/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/manifest_result_absent_from_witness/result_artifact.json b/examples/computation-release-invalid/manifest_result_absent_from_witness/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/manifest_result_absent_from_witness/result_artifact.json +++ b/examples/computation-release-invalid/manifest_result_absent_from_witness/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/missing_code_commit/computation_witness.json b/examples/computation-release-invalid/missing_code_commit/computation_witness.json index fd83287..2eb9cef 100644 --- a/examples/computation-release-invalid/missing_code_commit/computation_witness.json +++ b/examples/computation-release-invalid/missing_code_commit/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:2a5b46e2cd5c382f3586325b1739d1fa282704139136924fef067fb8e82fd713", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "0000000000000000000000000000000000000000", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:1bda9f4546f9c5a2b8680bd4c85150764f92cce31082b97f56a9d91b6bc51c47" + "signature_or_digest": "sha256:a99bfd1b8d572980a0937130a9ca2a1091a6f7cb6cddc8a2953ded535a1dd907" } diff --git a/examples/computation-release-invalid/missing_code_commit/outputs/metrics.json b/examples/computation-release-invalid/missing_code_commit/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/missing_code_commit/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/missing_code_commit/result_artifact.json b/examples/computation-release-invalid/missing_code_commit/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/missing_code_commit/result_artifact.json +++ b/examples/computation-release-invalid/missing_code_commit/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/missing_dataset_hash/computation_witness.json b/examples/computation-release-invalid/missing_dataset_hash/computation_witness.json index 8f97c36..2c1b8b5 100644 --- a/examples/computation-release-invalid/missing_dataset_hash/computation_witness.json +++ b/examples/computation-release-invalid/missing_dataset_hash/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:1485b9c848a4249b4c462f465756ddeee88bdfbfef63432fb588c15c3ed188c3" + "signature_or_digest": "sha256:0d19e73149afbec90270976b9d2cf7853b2fc4a5f5ac4b0b233c06ea5993e692" } diff --git a/examples/computation-release-invalid/missing_dataset_hash/outputs/metrics.json b/examples/computation-release-invalid/missing_dataset_hash/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/missing_dataset_hash/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/missing_dataset_hash/result_artifact.json b/examples/computation-release-invalid/missing_dataset_hash/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/missing_dataset_hash/result_artifact.json +++ b/examples/computation-release-invalid/missing_dataset_hash/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/missing_environment_hash/computation_witness.json b/examples/computation-release-invalid/missing_environment_hash/computation_witness.json index 0ef7d53..c272003 100644 --- a/examples/computation-release-invalid/missing_environment_hash/computation_witness.json +++ b/examples/computation-release-invalid/missing_environment_hash/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:c0ed0650ce1b7d6c6c7cd0250dce462b923b780b24336215bd81447deb10cf20" + "signature_or_digest": "sha256:344c215f836413002f3a9896941e24eedf10b7050c81ef773110d3bf58410746" } diff --git a/examples/computation-release-invalid/missing_environment_hash/outputs/metrics.json b/examples/computation-release-invalid/missing_environment_hash/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/missing_environment_hash/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/missing_environment_hash/result_artifact.json b/examples/computation-release-invalid/missing_environment_hash/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/missing_environment_hash/result_artifact.json +++ b/examples/computation-release-invalid/missing_environment_hash/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/missing_run_receipt_hash/computation_witness.json b/examples/computation-release-invalid/missing_run_receipt_hash/computation_witness.json index bdc852e..d8061b3 100644 --- a/examples/computation-release-invalid/missing_run_receipt_hash/computation_witness.json +++ b/examples/computation-release-invalid/missing_run_receipt_hash/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:fa5c9f6dfb43ec03c1677326c913d9b96647e262f07ae546e915a6ec23f0d065" + "signature_or_digest": "sha256:d219ed0e93db67a7ed1086a9432a806de5a26a94f637a3ce0ed8b86d84b46784" } diff --git a/examples/computation-release-invalid/missing_run_receipt_hash/outputs/metrics.json b/examples/computation-release-invalid/missing_run_receipt_hash/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/missing_run_receipt_hash/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/missing_run_receipt_hash/result_artifact.json b/examples/computation-release-invalid/missing_run_receipt_hash/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/missing_run_receipt_hash/result_artifact.json +++ b/examples/computation-release-invalid/missing_run_receipt_hash/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/nonzero_exit_code/computation_witness.json b/examples/computation-release-invalid/nonzero_exit_code/computation_witness.json index fc7481f..8e95d61 100644 --- a/examples/computation-release-invalid/nonzero_exit_code/computation_witness.json +++ b/examples/computation-release-invalid/nonzero_exit_code/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:36bda37867b3b414a8da08af6f3f61b8c43f7347ada1e2ca446f595410734b6e", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:822867092aebe2d4b163a0e824376d30d637da29472e107925288b0f514e1a86" + "signature_or_digest": "sha256:b914cd15d9ecfe4e0ada62dcff7b8713cf1e56156423ca28e9ef39236ad99d5a" } diff --git a/examples/computation-release-invalid/nonzero_exit_code/outputs/metrics.json b/examples/computation-release-invalid/nonzero_exit_code/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/nonzero_exit_code/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/nonzero_exit_code/result_artifact.json b/examples/computation-release-invalid/nonzero_exit_code/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/nonzero_exit_code/result_artifact.json +++ b/examples/computation-release-invalid/nonzero_exit_code/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/payload_digest_mismatch_envelope/computation_run_receipt.json b/examples/computation-release-invalid/payload_digest_mismatch_envelope/computation_run_receipt.json new file mode 100644 index 0000000..ed227ab --- /dev/null +++ b/examples/computation-release-invalid/payload_digest_mismatch_envelope/computation_run_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "run_id": "run-sci-comp-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "command": "python -m experiment.run --seed 42", + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "dataset_receipt_ref": "dataset-input-001", + "environment_receipt_ref": "env-repro-001", + "started_at": "2026-05-18T00:00:01Z", + "completed_at": "2026-05-18T00:00:10Z", + "exit_code": 0, + "stdout_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "stderr_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "result_artifact_refs": [ + "result-metric-001" + ], + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828" +} diff --git a/examples/computation-release-invalid/payload_digest_mismatch_envelope/computation_witness.json b/examples/computation-release-invalid/payload_digest_mismatch_envelope/computation_witness.json new file mode 100644 index 0000000..538606b --- /dev/null +++ b/examples/computation-release-invalid/payload_digest_mismatch_envelope/computation_witness.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "witness_id": "witness-sci-comp-repro-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", + "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", + "result_hashes": [ + "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ], + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "checker": "certifyedge", + "checker_version": "0.1.0", + "status": "CertificateChecked", + "violations": [], + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "b222222222222222222222222222222222222222", + "signature_or_digest": "sha256:7ee7fd7c5c6e8a400d0a49d8045a516df3b31c732ba5cab5d3f57b72ae95922f" +} diff --git a/examples/computation-release-invalid/payload_digest_mismatch_envelope/dataset_receipt.json b/examples/computation-release-invalid/payload_digest_mismatch_envelope/dataset_receipt.json new file mode 100644 index 0000000..bdb3061 --- /dev/null +++ b/examples/computation-release-invalid/payload_digest_mismatch_envelope/dataset_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "dataset_id": "dataset-input-001", + "dataset_name": "conformance-input", + "dataset_version": "1.0.0", + "files": [ + { + "path": "data/input.csv", + "sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size_bytes": 123456, + "media_type": "text/csv" + } + ], + "aggregate_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "source_uri": "https://example.org/datasets/conformance-input/1.0.0", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "license": "CC-BY-4.0", + "created_at": "2026-05-18T00:00:00Z", + "signature_or_digest": "sha256:4fdd6999e67eebcfec9f8f84800454614693fad9c4dbf5d887051025ce2cdeae" +} diff --git a/examples/computation-release-invalid/payload_digest_mismatch_envelope/environment_receipt.json b/examples/computation-release-invalid/payload_digest_mismatch_envelope/environment_receipt.json new file mode 100644 index 0000000..108e59c --- /dev/null +++ b/examples/computation-release-invalid/payload_digest_mismatch_envelope/environment_receipt.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "environment_id": "env-repro-001", + "environment_kind": "uv", + "os": "linux", + "architecture": "x86_64", + "language_runtimes": [ + "python==3.12.3" + ], + "packages": [ + "numpy==2.1.0", + "pandas==2.2.2" + ], + "container_image": "", + "container_digest": "", + "hardware_summary": "conformance-runner", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01" +} diff --git a/examples/computation-release-invalid/payload_digest_mismatch_envelope/outputs/metrics.json b/examples/computation-release-invalid/payload_digest_mismatch_envelope/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/payload_digest_mismatch_envelope/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/payload_digest_mismatch_envelope/result_artifact.json b/examples/computation-release-invalid/payload_digest_mismatch_envelope/result_artifact.json new file mode 100644 index 0000000..7eafb53 --- /dev/null +++ b/examples/computation-release-invalid/payload_digest_mismatch_envelope/result_artifact.json @@ -0,0 +1,14 @@ +{ + "schema_version": "v0", + "result_id": "result-metric-001", + "result_kind": "metric", + "path": "outputs/metrics.json", + "sha256": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "size_bytes": 46, + "media_type": "application/json", + "description": "Primary reproducibility metric output", + "produced_by_run": "run-sci-comp-001", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:dec49c39746bdbd3080c9e36aba94dfe0185ed415a9f0400a06403a07f92c4b1" +} diff --git a/examples/computation-release-invalid/payload_missing/computation_run_receipt.json b/examples/computation-release-invalid/payload_missing/computation_run_receipt.json new file mode 100644 index 0000000..ed227ab --- /dev/null +++ b/examples/computation-release-invalid/payload_missing/computation_run_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "run_id": "run-sci-comp-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "command": "python -m experiment.run --seed 42", + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "dataset_receipt_ref": "dataset-input-001", + "environment_receipt_ref": "env-repro-001", + "started_at": "2026-05-18T00:00:01Z", + "completed_at": "2026-05-18T00:00:10Z", + "exit_code": 0, + "stdout_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "stderr_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "result_artifact_refs": [ + "result-metric-001" + ], + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828" +} diff --git a/examples/computation-release-invalid/payload_missing/computation_witness.json b/examples/computation-release-invalid/payload_missing/computation_witness.json new file mode 100644 index 0000000..af6a105 --- /dev/null +++ b/examples/computation-release-invalid/payload_missing/computation_witness.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "witness_id": "witness-sci-comp-repro-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", + "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", + "result_hashes": [ + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" + ], + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "checker": "certifyedge", + "checker_version": "0.1.0", + "status": "CertificateChecked", + "violations": [], + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "b222222222222222222222222222222222222222", + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" +} diff --git a/examples/computation-release-invalid/payload_missing/dataset_receipt.json b/examples/computation-release-invalid/payload_missing/dataset_receipt.json new file mode 100644 index 0000000..bdb3061 --- /dev/null +++ b/examples/computation-release-invalid/payload_missing/dataset_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "dataset_id": "dataset-input-001", + "dataset_name": "conformance-input", + "dataset_version": "1.0.0", + "files": [ + { + "path": "data/input.csv", + "sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size_bytes": 123456, + "media_type": "text/csv" + } + ], + "aggregate_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "source_uri": "https://example.org/datasets/conformance-input/1.0.0", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "license": "CC-BY-4.0", + "created_at": "2026-05-18T00:00:00Z", + "signature_or_digest": "sha256:4fdd6999e67eebcfec9f8f84800454614693fad9c4dbf5d887051025ce2cdeae" +} diff --git a/examples/computation-release-invalid/payload_missing/environment_receipt.json b/examples/computation-release-invalid/payload_missing/environment_receipt.json new file mode 100644 index 0000000..108e59c --- /dev/null +++ b/examples/computation-release-invalid/payload_missing/environment_receipt.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "environment_id": "env-repro-001", + "environment_kind": "uv", + "os": "linux", + "architecture": "x86_64", + "language_runtimes": [ + "python==3.12.3" + ], + "packages": [ + "numpy==2.1.0", + "pandas==2.2.2" + ], + "container_image": "", + "container_digest": "", + "hardware_summary": "conformance-runner", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01" +} diff --git a/examples/computation-release-invalid/payload_missing/result_artifact.json b/examples/computation-release-invalid/payload_missing/result_artifact.json new file mode 100644 index 0000000..c7a5087 --- /dev/null +++ b/examples/computation-release-invalid/payload_missing/result_artifact.json @@ -0,0 +1,14 @@ +{ + "schema_version": "v0", + "result_id": "result-metric-001", + "result_kind": "metric", + "path": "outputs/metrics.json", + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, + "media_type": "application/json", + "description": "Primary reproducibility metric output", + "produced_by_run": "run-sci-comp-001", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" +} diff --git a/examples/computation-release-invalid/payload_modified/computation_run_receipt.json b/examples/computation-release-invalid/payload_modified/computation_run_receipt.json new file mode 100644 index 0000000..ed227ab --- /dev/null +++ b/examples/computation-release-invalid/payload_modified/computation_run_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "run_id": "run-sci-comp-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "command": "python -m experiment.run --seed 42", + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "dataset_receipt_ref": "dataset-input-001", + "environment_receipt_ref": "env-repro-001", + "started_at": "2026-05-18T00:00:01Z", + "completed_at": "2026-05-18T00:00:10Z", + "exit_code": 0, + "stdout_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "stderr_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "result_artifact_refs": [ + "result-metric-001" + ], + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828" +} diff --git a/examples/computation-release-invalid/payload_modified/computation_witness.json b/examples/computation-release-invalid/payload_modified/computation_witness.json new file mode 100644 index 0000000..af6a105 --- /dev/null +++ b/examples/computation-release-invalid/payload_modified/computation_witness.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "witness_id": "witness-sci-comp-repro-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", + "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", + "result_hashes": [ + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" + ], + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "checker": "certifyedge", + "checker_version": "0.1.0", + "status": "CertificateChecked", + "violations": [], + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "b222222222222222222222222222222222222222", + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" +} diff --git a/examples/computation-release-invalid/payload_modified/dataset_receipt.json b/examples/computation-release-invalid/payload_modified/dataset_receipt.json new file mode 100644 index 0000000..bdb3061 --- /dev/null +++ b/examples/computation-release-invalid/payload_modified/dataset_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "dataset_id": "dataset-input-001", + "dataset_name": "conformance-input", + "dataset_version": "1.0.0", + "files": [ + { + "path": "data/input.csv", + "sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size_bytes": 123456, + "media_type": "text/csv" + } + ], + "aggregate_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "source_uri": "https://example.org/datasets/conformance-input/1.0.0", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "license": "CC-BY-4.0", + "created_at": "2026-05-18T00:00:00Z", + "signature_or_digest": "sha256:4fdd6999e67eebcfec9f8f84800454614693fad9c4dbf5d887051025ce2cdeae" +} diff --git a/examples/computation-release-invalid/payload_modified/environment_receipt.json b/examples/computation-release-invalid/payload_modified/environment_receipt.json new file mode 100644 index 0000000..108e59c --- /dev/null +++ b/examples/computation-release-invalid/payload_modified/environment_receipt.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "environment_id": "env-repro-001", + "environment_kind": "uv", + "os": "linux", + "architecture": "x86_64", + "language_runtimes": [ + "python==3.12.3" + ], + "packages": [ + "numpy==2.1.0", + "pandas==2.2.2" + ], + "container_image": "", + "container_digest": "", + "hardware_summary": "conformance-runner", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01" +} diff --git a/examples/computation-release-invalid/payload_modified/outputs/metrics.json b/examples/computation-release-invalid/payload_modified/outputs/metrics.json new file mode 100644 index 0000000..81b0b64 --- /dev/null +++ b/examples/computation-release-invalid/payload_modified/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"tampered","value":0.0} diff --git a/examples/computation-release-invalid/payload_modified/result_artifact.json b/examples/computation-release-invalid/payload_modified/result_artifact.json new file mode 100644 index 0000000..c7a5087 --- /dev/null +++ b/examples/computation-release-invalid/payload_modified/result_artifact.json @@ -0,0 +1,14 @@ +{ + "schema_version": "v0", + "result_id": "result-metric-001", + "result_kind": "metric", + "path": "outputs/metrics.json", + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, + "media_type": "application/json", + "description": "Primary reproducibility metric output", + "produced_by_run": "run-sci-comp-001", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" +} diff --git a/examples/computation-release-invalid/payload_symlink/README.md b/examples/computation-release-invalid/payload_symlink/README.md new file mode 100644 index 0000000..7b27b12 --- /dev/null +++ b/examples/computation-release-invalid/payload_symlink/README.md @@ -0,0 +1,3 @@ +Invalid symlink escape fixture. + +The declared payload path is outputs/metrics.json. Tests replace that path with a symlink (or reparse point) that points outside the release root; verify_result_artifact_payload must reject it with payload_path_unsafe. diff --git a/examples/computation-release-invalid/payload_symlink/computation_run_receipt.json b/examples/computation-release-invalid/payload_symlink/computation_run_receipt.json new file mode 100644 index 0000000..ed227ab --- /dev/null +++ b/examples/computation-release-invalid/payload_symlink/computation_run_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "run_id": "run-sci-comp-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "command": "python -m experiment.run --seed 42", + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "dataset_receipt_ref": "dataset-input-001", + "environment_receipt_ref": "env-repro-001", + "started_at": "2026-05-18T00:00:01Z", + "completed_at": "2026-05-18T00:00:10Z", + "exit_code": 0, + "stdout_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "stderr_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "result_artifact_refs": [ + "result-metric-001" + ], + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828" +} diff --git a/examples/computation-release-invalid/payload_symlink/computation_witness.json b/examples/computation-release-invalid/payload_symlink/computation_witness.json new file mode 100644 index 0000000..af6a105 --- /dev/null +++ b/examples/computation-release-invalid/payload_symlink/computation_witness.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "witness_id": "witness-sci-comp-repro-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", + "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", + "result_hashes": [ + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" + ], + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "checker": "certifyedge", + "checker_version": "0.1.0", + "status": "CertificateChecked", + "violations": [], + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "b222222222222222222222222222222222222222", + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" +} diff --git a/examples/computation-release-invalid/payload_symlink/dataset_receipt.json b/examples/computation-release-invalid/payload_symlink/dataset_receipt.json new file mode 100644 index 0000000..bdb3061 --- /dev/null +++ b/examples/computation-release-invalid/payload_symlink/dataset_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "dataset_id": "dataset-input-001", + "dataset_name": "conformance-input", + "dataset_version": "1.0.0", + "files": [ + { + "path": "data/input.csv", + "sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size_bytes": 123456, + "media_type": "text/csv" + } + ], + "aggregate_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "source_uri": "https://example.org/datasets/conformance-input/1.0.0", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "license": "CC-BY-4.0", + "created_at": "2026-05-18T00:00:00Z", + "signature_or_digest": "sha256:4fdd6999e67eebcfec9f8f84800454614693fad9c4dbf5d887051025ce2cdeae" +} diff --git a/examples/computation-release-invalid/payload_symlink/environment_receipt.json b/examples/computation-release-invalid/payload_symlink/environment_receipt.json new file mode 100644 index 0000000..108e59c --- /dev/null +++ b/examples/computation-release-invalid/payload_symlink/environment_receipt.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "environment_id": "env-repro-001", + "environment_kind": "uv", + "os": "linux", + "architecture": "x86_64", + "language_runtimes": [ + "python==3.12.3" + ], + "packages": [ + "numpy==2.1.0", + "pandas==2.2.2" + ], + "container_image": "", + "container_digest": "", + "hardware_summary": "conformance-runner", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01" +} diff --git a/examples/computation-release-invalid/payload_symlink/outputs/metrics.json b/examples/computation-release-invalid/payload_symlink/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/payload_symlink/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/payload_symlink/result_artifact.json b/examples/computation-release-invalid/payload_symlink/result_artifact.json new file mode 100644 index 0000000..c7a5087 --- /dev/null +++ b/examples/computation-release-invalid/payload_symlink/result_artifact.json @@ -0,0 +1,14 @@ +{ + "schema_version": "v0", + "result_id": "result-metric-001", + "result_kind": "metric", + "path": "outputs/metrics.json", + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, + "media_type": "application/json", + "description": "Primary reproducibility metric output", + "produced_by_run": "run-sci-comp-001", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" +} diff --git a/examples/computation-release-invalid/payload_traversal/computation_run_receipt.json b/examples/computation-release-invalid/payload_traversal/computation_run_receipt.json new file mode 100644 index 0000000..ed227ab --- /dev/null +++ b/examples/computation-release-invalid/payload_traversal/computation_run_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "run_id": "run-sci-comp-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "command": "python -m experiment.run --seed 42", + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "dataset_receipt_ref": "dataset-input-001", + "environment_receipt_ref": "env-repro-001", + "started_at": "2026-05-18T00:00:01Z", + "completed_at": "2026-05-18T00:00:10Z", + "exit_code": 0, + "stdout_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "stderr_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "result_artifact_refs": [ + "result-metric-001" + ], + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828" +} diff --git a/examples/computation-release-invalid/payload_traversal/computation_witness.json b/examples/computation-release-invalid/payload_traversal/computation_witness.json new file mode 100644 index 0000000..af6a105 --- /dev/null +++ b/examples/computation-release-invalid/payload_traversal/computation_witness.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "witness_id": "witness-sci-comp-repro-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", + "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", + "result_hashes": [ + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" + ], + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "checker": "certifyedge", + "checker_version": "0.1.0", + "status": "CertificateChecked", + "violations": [], + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "b222222222222222222222222222222222222222", + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" +} diff --git a/examples/computation-release-invalid/payload_traversal/dataset_receipt.json b/examples/computation-release-invalid/payload_traversal/dataset_receipt.json new file mode 100644 index 0000000..bdb3061 --- /dev/null +++ b/examples/computation-release-invalid/payload_traversal/dataset_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "dataset_id": "dataset-input-001", + "dataset_name": "conformance-input", + "dataset_version": "1.0.0", + "files": [ + { + "path": "data/input.csv", + "sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size_bytes": 123456, + "media_type": "text/csv" + } + ], + "aggregate_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "source_uri": "https://example.org/datasets/conformance-input/1.0.0", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "license": "CC-BY-4.0", + "created_at": "2026-05-18T00:00:00Z", + "signature_or_digest": "sha256:4fdd6999e67eebcfec9f8f84800454614693fad9c4dbf5d887051025ce2cdeae" +} diff --git a/examples/computation-release-invalid/payload_traversal/environment_receipt.json b/examples/computation-release-invalid/payload_traversal/environment_receipt.json new file mode 100644 index 0000000..108e59c --- /dev/null +++ b/examples/computation-release-invalid/payload_traversal/environment_receipt.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "environment_id": "env-repro-001", + "environment_kind": "uv", + "os": "linux", + "architecture": "x86_64", + "language_runtimes": [ + "python==3.12.3" + ], + "packages": [ + "numpy==2.1.0", + "pandas==2.2.2" + ], + "container_image": "", + "container_digest": "", + "hardware_summary": "conformance-runner", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01" +} diff --git a/examples/computation-release-invalid/payload_traversal/result_artifact.json b/examples/computation-release-invalid/payload_traversal/result_artifact.json new file mode 100644 index 0000000..2c9e0dd --- /dev/null +++ b/examples/computation-release-invalid/payload_traversal/result_artifact.json @@ -0,0 +1,14 @@ +{ + "schema_version": "v0", + "result_id": "result-metric-001", + "result_kind": "metric", + "path": "../outside_metrics.json", + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, + "media_type": "application/json", + "description": "Primary reproducibility metric output", + "produced_by_run": "run-sci-comp-001", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:31a667621aaad59266d4fc856835964f2b7af586be38d53ebe62fe2eb8889b0c" +} diff --git a/examples/computation-release-invalid/payload_wrong_size/computation_run_receipt.json b/examples/computation-release-invalid/payload_wrong_size/computation_run_receipt.json new file mode 100644 index 0000000..ed227ab --- /dev/null +++ b/examples/computation-release-invalid/payload_wrong_size/computation_run_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "run_id": "run-sci-comp-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "command": "python -m experiment.run --seed 42", + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "dataset_receipt_ref": "dataset-input-001", + "environment_receipt_ref": "env-repro-001", + "started_at": "2026-05-18T00:00:01Z", + "completed_at": "2026-05-18T00:00:10Z", + "exit_code": 0, + "stdout_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "stderr_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "result_artifact_refs": [ + "result-metric-001" + ], + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828" +} diff --git a/examples/computation-release-invalid/payload_wrong_size/computation_witness.json b/examples/computation-release-invalid/payload_wrong_size/computation_witness.json new file mode 100644 index 0000000..af6a105 --- /dev/null +++ b/examples/computation-release-invalid/payload_wrong_size/computation_witness.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "witness_id": "witness-sci-comp-repro-001", + "workflow_id": "scientific_computation.reproducibility_v0", + "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", + "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", + "result_hashes": [ + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" + ], + "code_repo": "https://github.com/example/scientific-computation-runner", + "code_commit": "e555555555555555555555555555555555555555", + "checker": "certifyedge", + "checker_version": "0.1.0", + "status": "CertificateChecked", + "violations": [], + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "b222222222222222222222222222222222222222", + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" +} diff --git a/examples/computation-release-invalid/payload_wrong_size/dataset_receipt.json b/examples/computation-release-invalid/payload_wrong_size/dataset_receipt.json new file mode 100644 index 0000000..bdb3061 --- /dev/null +++ b/examples/computation-release-invalid/payload_wrong_size/dataset_receipt.json @@ -0,0 +1,21 @@ +{ + "schema_version": "v0", + "dataset_id": "dataset-input-001", + "dataset_name": "conformance-input", + "dataset_version": "1.0.0", + "files": [ + { + "path": "data/input.csv", + "sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size_bytes": 123456, + "media_type": "text/csv" + } + ], + "aggregate_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "source_uri": "https://example.org/datasets/conformance-input/1.0.0", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "license": "CC-BY-4.0", + "created_at": "2026-05-18T00:00:00Z", + "signature_or_digest": "sha256:4fdd6999e67eebcfec9f8f84800454614693fad9c4dbf5d887051025ce2cdeae" +} diff --git a/examples/computation-release-invalid/payload_wrong_size/environment_receipt.json b/examples/computation-release-invalid/payload_wrong_size/environment_receipt.json new file mode 100644 index 0000000..108e59c --- /dev/null +++ b/examples/computation-release-invalid/payload_wrong_size/environment_receipt.json @@ -0,0 +1,20 @@ +{ + "schema_version": "v0", + "environment_id": "env-repro-001", + "environment_kind": "uv", + "os": "linux", + "architecture": "x86_64", + "language_runtimes": [ + "python==3.12.3" + ], + "packages": [ + "numpy==2.1.0", + "pandas==2.2.2" + ], + "container_image": "", + "container_digest": "", + "hardware_summary": "conformance-runner", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01" +} diff --git a/examples/computation-release-invalid/payload_wrong_size/outputs/metrics.json b/examples/computation-release-invalid/payload_wrong_size/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/payload_wrong_size/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/payload_wrong_size/result_artifact.json b/examples/computation-release-invalid/payload_wrong_size/result_artifact.json new file mode 100644 index 0000000..71c3f59 --- /dev/null +++ b/examples/computation-release-invalid/payload_wrong_size/result_artifact.json @@ -0,0 +1,14 @@ +{ + "schema_version": "v0", + "result_id": "result-metric-001", + "result_kind": "metric", + "path": "outputs/metrics.json", + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 145, + "media_type": "application/json", + "description": "Primary reproducibility metric output", + "produced_by_run": "run-sci-comp-001", + "source_repo": "https://github.com/example/scientific-computation-runner", + "source_commit": "e555555555555555555555555555555555555555", + "signature_or_digest": "sha256:ff76349ec89ec514bc13cc708f60b01ff42cc8faa521504f8dff6842e49d16da" +} diff --git a/examples/computation-release-invalid/rejected_computation_witness/computation_witness.json b/examples/computation-release-invalid/rejected_computation_witness/computation_witness.json index b1b1343..20aa634 100644 --- a/examples/computation-release-invalid/rejected_computation_witness/computation_witness.json +++ b/examples/computation-release-invalid/rejected_computation_witness/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -22,5 +22,5 @@ ], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:7de09bd7e783f6407ee402dbbe2824e2f91e04293372e0e5d74183f0bf3ca18f" + "signature_or_digest": "sha256:17fa65a28d9caa596b01150ec26b57853a130c5f01d3ab658b139ee76c14b1a1" } diff --git a/examples/computation-release-invalid/rejected_computation_witness/outputs/metrics.json b/examples/computation-release-invalid/rejected_computation_witness/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/rejected_computation_witness/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/rejected_computation_witness/result_artifact.json b/examples/computation-release-invalid/rejected_computation_witness/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/rejected_computation_witness/result_artifact.json +++ b/examples/computation-release-invalid/rejected_computation_witness/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/result_file_hash_ne_payload/outputs/metrics.json b/examples/computation-release-invalid/result_file_hash_ne_payload/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/result_file_hash_ne_payload/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/result_file_hash_ne_payload/result_artifact.json b/examples/computation-release-invalid/result_file_hash_ne_payload/result_artifact.json index e53bf65..0034efa 100644 --- a/examples/computation-release-invalid/result_file_hash_ne_payload/result_artifact.json +++ b/examples/computation-release-invalid/result_file_hash_ne_payload/result_artifact.json @@ -4,11 +4,11 @@ "result_kind": "metric", "path": "outputs/metrics.json", "sha256": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "size_bytes": 2048, + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/result_hash_mismatch/outputs/metrics.json b/examples/computation-release-invalid/result_hash_mismatch/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/result_hash_mismatch/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/result_hash_mismatch/result_artifact.json b/examples/computation-release-invalid/result_hash_mismatch/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/result_hash_mismatch/result_artifact.json +++ b/examples/computation-release-invalid/result_hash_mismatch/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release-invalid/witness_undeclared_extra_result/computation_witness.json b/examples/computation-release-invalid/witness_undeclared_extra_result/computation_witness.json index 01c9a15..78a7d70 100644 --- a/examples/computation-release-invalid/witness_undeclared_extra_result/computation_witness.json +++ b/examples/computation-release-invalid/witness_undeclared_extra_result/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ], "code_repo": "https://github.com/example/scientific-computation-runner", @@ -17,5 +17,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:157e886757a57a7bdabdddddfe1d7ed093d2e17e0769dbcaffc54356a09cfc8d" + "signature_or_digest": "sha256:41fbe2f19c92b72c61b87efbdc620679fa3fe8a0f85ee032d2ccd9297f594512" } diff --git a/examples/computation-release-invalid/witness_undeclared_extra_result/outputs/metrics.json b/examples/computation-release-invalid/witness_undeclared_extra_result/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release-invalid/witness_undeclared_extra_result/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release-invalid/witness_undeclared_extra_result/result_artifact.json b/examples/computation-release-invalid/witness_undeclared_extra_result/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release-invalid/witness_undeclared_extra_result/result_artifact.json +++ b/examples/computation-release-invalid/witness_undeclared_extra_result/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation-release/RELEASE_FIXTURE_MANIFEST.json b/examples/computation-release/RELEASE_FIXTURE_MANIFEST.json index e9dd001..62e2800 100644 --- a/examples/computation-release/RELEASE_FIXTURE_MANIFEST.json +++ b/examples/computation-release/RELEASE_FIXTURE_MANIFEST.json @@ -12,11 +12,11 @@ "dataset_receipt.json": "sha256:f94a4a839cea893cd0abeea758326e0e28f01a293b6ac87f8436ca5cca753e79", "environment_receipt.json": "sha256:c01a8f055da8965e01c1172eb7ff9f58e702619261a6d6159e24ee861e134598", "computation_run_receipt.json": "sha256:567e0adeec5bc61786efa529dcb777f5ac2ddda1f8cb1160d67e5638405cbd4a", - "result_artifact.json": "sha256:a2b8d26f9d0e056e7fd963156021a88b43c764c84357e2ff8ae70cd2c2d99acc", - "computation_witness.json": "sha256:b89def93118f055abb45b8b0187e2aaeb452ec6eae502c9ba9bbf7ded83377cb", + "result_artifact.json": "sha256:b3f437010792f1f1f70ade9912374a1795c1458bf35309d6e1f888d875d09f3c", + "computation_witness.json": "sha256:944ab3a7bcc6ecd3b7b702c122fc3aa4e1048f2d885d14350894bb18618e3bf2", "science_claim_bundle.certified.json": "sha256:5a6a675d23354d219e85daec27a89443d8648d158249e86c48b99528b4412643", "verification_result.json": "sha256:f78c35d74928bb139e2d507424a022f2dfa78fcc2e1a67ccd4adeb0f51e0b43c", "signed_science_claim_bundle.json": "sha256:e6419afb62cf88f2ae12f5f8bf58fc7ebde8cf7f2f28b61c9aea1a2aba889c4a", - "scientific_memory_import_report.json": "sha256:ba324c85c2aee78e1893c7b667e8580cbeede842b7027d42b9474b8c9dafbe70" + "scientific_memory_import_report.json": "sha256:35b2bba0b7d1f8d50047453d6426e066c37250b693c2437aac3630dcc145eab7" } } diff --git a/examples/computation-release/computation_witness.json b/examples/computation-release/computation_witness.json index 30f7d24..af6a105 100644 --- a/examples/computation-release/computation_witness.json +++ b/examples/computation-release/computation_witness.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:68930f59c18ca213df059cec7b3097c5bdfc177bfbadb00ff0e14697f7fe8da8" + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" } diff --git a/examples/computation-release/outputs/metrics.json b/examples/computation-release/outputs/metrics.json new file mode 100644 index 0000000..1446ba9 --- /dev/null +++ b/examples/computation-release/outputs/metrics.json @@ -0,0 +1 @@ +{"metric":"accuracy","value":0.987,"seed":42} diff --git a/examples/computation-release/result_artifact.json b/examples/computation-release/result_artifact.json index 2e7b7c2..c7a5087 100644 --- a/examples/computation-release/result_artifact.json +++ b/examples/computation-release/result_artifact.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/examples/computation_witness.valid.json b/examples/computation_witness.valid.json index 30f7d24..af6a105 100644 --- a/examples/computation_witness.valid.json +++ b/examples/computation_witness.valid.json @@ -6,7 +6,7 @@ "environment_hash": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "code_repo": "https://github.com/example/scientific-computation-runner", "code_commit": "e555555555555555555555555555555555555555", @@ -16,5 +16,5 @@ "violations": [], "source_repo": "https://github.com/fraware/CertifyEdge", "source_commit": "b222222222222222222222222222222222222222", - "signature_or_digest": "sha256:68930f59c18ca213df059cec7b3097c5bdfc177bfbadb00ff0e14697f7fe8da8" + "signature_or_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e" } diff --git a/examples/result_artifact.valid.json b/examples/result_artifact.valid.json index 2e7b7c2..c7a5087 100644 --- a/examples/result_artifact.valid.json +++ b/examples/result_artifact.valid.json @@ -3,12 +3,12 @@ "result_id": "result-metric-001", "result_kind": "metric", "path": "outputs/metrics.json", - "sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "size_bytes": 2048, + "sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "size_bytes": 46, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": "run-sci-comp-001", "source_repo": "https://github.com/example/scientific-computation-runner", "source_commit": "e555555555555555555555555555555555555555", - "signature_or_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887" + "signature_or_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c" } diff --git a/python/pcs_core/computation_validate.py b/python/pcs_core/computation_validate.py index 18a54fd..40df769 100644 --- a/python/pcs_core/computation_validate.py +++ b/python/pcs_core/computation_validate.py @@ -2,10 +2,15 @@ from __future__ import annotations +import json +from dataclasses import dataclass +from hashlib import sha256 from pathlib import Path -from typing import Any +from typing import Any, Mapping from pcs_core.hash import PLACEHOLDER_DIGEST, SIGNATURE_FIELD, canonical_hash +from pcs_core.safe_paths import UnsafePathError, resolve_contained_file + RELEASE_WITNESS_STATUS = "CertificateChecked" @@ -17,6 +22,24 @@ RESULT_ARTIFACT_FILE = "result_artifact.json" COMPUTATION_WITNESS_FILE = "computation_witness.json" +# Issue-code tokens embedded in semantic error strings (release-chain mappers parse these). +PAYLOAD_DIGEST_MISMATCH = "payload_digest_mismatch" +PAYLOAD_SIZE_MISMATCH = "payload_size_mismatch" +PAYLOAD_MISSING = "payload_missing" +PAYLOAD_PATH_UNSAFE = "payload_path_unsafe" +DUPLICATE_RESULT_DECLARATION = "duplicate_result_declaration" + + +@dataclass(frozen=True) +class VerifiedResultPayload: + """Byte-verified ResultArtifact.v0 payload under a release root.""" + + result_id: str + result_artifact_relpath: str + payload_relpath: str + digest: str + size_bytes: int + def _is_zero_commit(commit: str) -> bool: return commit == "0" * 40 @@ -55,6 +78,186 @@ def _signature_or_digest_valid(data: dict[str, Any]) -> list[str]: return [] +def _payload_digest(content: bytes) -> str: + return f"sha256:{sha256(content).hexdigest()}" + + +def verify_result_artifact_payload( + release_dir: Path, + result: Mapping[str, Any], + *, + result_artifact_relpath: str = RESULT_ARTIFACT_FILE, +) -> VerifiedResultPayload: + """Resolve, read, and bind ResultArtifact.v0 payload bytes under ``release_dir``. + + Rejects absolute paths, ``..`` traversal, symlinks, and Windows reparse-point + escapes. Compares SHA-256 and ``size_bytes`` against the declared fields. + """ + result_id = result.get("result_id") + if not isinstance(result_id, str) or not result_id.strip(): + raise ValueError( + f"{result_artifact_relpath}: ResultArtifact.v0 result_id is required " + f"({DUPLICATE_RESULT_DECLARATION})", + ) + payload_ref = result.get("path") + if not isinstance(payload_ref, str) or not payload_ref.strip(): + raise ValueError( + f"{result_artifact_relpath}: ResultArtifact.v0 path is required ({PAYLOAD_MISSING})", + ) + declared_digest = result.get("sha256") + if not isinstance(declared_digest, str) or not declared_digest.startswith("sha256:"): + raise ValueError( + f"{result_artifact_relpath}: ResultArtifact.v0 sha256 is required " + f"({PAYLOAD_DIGEST_MISMATCH})", + ) + declared_size = result.get("size_bytes") + if not isinstance(declared_size, int) or isinstance(declared_size, bool) or declared_size < 0: + raise ValueError( + f"{result_artifact_relpath}: ResultArtifact.v0 size_bytes is required " + f"({PAYLOAD_SIZE_MISMATCH})", + ) + + root = release_dir.resolve() + try: + payload_path = resolve_contained_file(root, payload_ref) + except UnsafePathError as exc: + message = str(exc).lower() + if "does not resolve" in message or "not a regular file" in message: + code = PAYLOAD_MISSING + else: + code = PAYLOAD_PATH_UNSAFE + raise ValueError( + f"{result_artifact_relpath}: unsafe or missing payload path {payload_ref!r} " + f"({code}): {exc}", + ) from exc + + payload_bytes = payload_path.read_bytes() + actual_digest = _payload_digest(payload_bytes) + actual_size = len(payload_bytes) + if actual_digest != declared_digest: + raise ValueError( + f"{result_artifact_relpath}: payload digest mismatch for {payload_ref!r}: " + f"declared {declared_digest}, got {actual_digest} ({PAYLOAD_DIGEST_MISMATCH})", + ) + if actual_size != declared_size: + raise ValueError( + f"{result_artifact_relpath}: payload size mismatch for {payload_ref!r}: " + f"declared {declared_size}, got {actual_size} ({PAYLOAD_SIZE_MISMATCH})", + ) + + # Normalize to forward-slash release-relative path for projection / obligations. + rel = payload_path.relative_to(root).as_posix() + return VerifiedResultPayload( + result_id=result_id.strip(), + result_artifact_relpath=result_artifact_relpath.replace("\\", "/"), + payload_relpath=rel, + digest=actual_digest, + size_bytes=actual_size, + ) + + +def _iter_result_artifact_files(release_dir: Path) -> list[tuple[str, dict[str, Any]]]: + """Return ``(relpath, doc)`` for every ResultArtifact.v0 under the release root.""" + root = release_dir.resolve() + found: list[tuple[str, dict[str, Any]]] = [] + seen_paths: set[str] = set() + + def _consider(path: Path) -> None: + if not path.is_file(): + return + try: + rel = path.relative_to(root).as_posix() + except ValueError: + return + if rel in seen_paths: + return + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return + if not isinstance(doc, dict): + return + # Primary harness file is always treated as ResultArtifact.v0. + if path.name == RESULT_ARTIFACT_FILE or path.name.startswith("result_artifact"): + seen_paths.add(rel) + found.append((rel, doc)) + return + artifact_type = str(doc.get("artifact_type") or "") + if artifact_type == "ResultArtifact.v0": + seen_paths.add(rel) + found.append((rel, doc)) + + _consider(root / RESULT_ARTIFACT_FILE) + for path in sorted(root.glob("result_artifact*.json")): + _consider(path) + + manifest_path = root / "release_manifest.v0.json" + if manifest_path.is_file(): + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + manifest = None + if isinstance(manifest, dict): + artifacts = manifest.get("artifacts") + if isinstance(artifacts, dict): + for name, meta in artifacts.items(): + if not isinstance(meta, dict): + continue + artifact_type = str(meta.get("artifact_type") or "") + if artifact_type != "ResultArtifact.v0" and not str(name).startswith( + "result_artifact", + ): + continue + _consider(root / str(name)) + + return found + + +def verify_all_result_artifact_payloads(release_dir: Path) -> list[VerifiedResultPayload]: + """Verify every ResultArtifact.v0 payload; reject duplicate declarations.""" + entries = _iter_result_artifact_files(release_dir) + if not entries: + raise ValueError( + f"{release_dir}: no ResultArtifact.v0 files found ({PAYLOAD_MISSING})", + ) + + verified: list[VerifiedResultPayload] = [] + seen_ids: dict[str, str] = {} + seen_payload_paths: dict[str, str] = {} + + for relpath, doc in entries: + item = verify_result_artifact_payload( + release_dir, + doc, + result_artifact_relpath=relpath, + ) + prior_id = seen_ids.get(item.result_id) + if prior_id is not None: + raise ValueError( + f"duplicate ResultArtifact result_id {item.result_id!r} in " + f"{prior_id} and {relpath} ({DUPLICATE_RESULT_DECLARATION})", + ) + prior_path = seen_payload_paths.get(item.payload_relpath) + if prior_path is not None: + raise ValueError( + f"duplicate ResultArtifact payload path {item.payload_relpath!r} in " + f"{prior_path} and {relpath} ({DUPLICATE_RESULT_DECLARATION})", + ) + seen_ids[item.result_id] = relpath + seen_payload_paths[item.payload_relpath] = relpath + verified.append(item) + return verified + + +def validate_result_payloads_in_release(directory: Path) -> list[str]: + """Return semantic errors for ResultArtifact payload binding under ``directory``.""" + try: + verify_all_result_artifact_payloads(directory) + except ValueError as exc: + return [str(exc)] + return [] + + def validate_dataset_receipt_semantics(data: dict[str, Any]) -> list[str]: errors: list[str] = [] files = data.get("files") @@ -237,16 +440,12 @@ def _load_release_json(directory: Path, name: str) -> dict[str, Any] | None: path = directory / name if not path.is_file(): return None - import json - data = json.loads(path.read_text(encoding="utf-8")) return data if isinstance(data, dict) else None def validate_computation_release_directory(directory: Path) -> list[str]: """Validate a computation release fixture directory (valid train).""" - import json - from pcs_core.validate import ValidationError, validate_artifact, validate_file errors: list[str] = [] @@ -291,6 +490,7 @@ def validate_computation_release_directory(directory: Path) -> list[str]: witness=witness, ), ) + errors.extend(validate_result_payloads_in_release(directory)) if run_receipt.get("workflow_id") != profile.get("workflow_id"): errors.append("computation_run_receipt.workflow_id does not match workflow_profile") for name in ( @@ -345,8 +545,6 @@ def validate_computation_release_directory(directory: Path) -> list[str]: def validate_computation_invalid_case(directory: Path) -> list[str]: """Return errors if an invalid-case directory incorrectly passes validation.""" - import json - from pcs_core.validate import ValidationError, validate_artifact paths = { @@ -385,6 +583,7 @@ def validate_computation_invalid_case(directory: Path) -> list[str]: witness=witness, ), ) + failures.extend(validate_result_payloads_in_release(directory)) if not failures: return [f"{directory.name}: invalid fixture must fail semantic validation"] return [] diff --git a/python/pcs_core/safe_paths.py b/python/pcs_core/safe_paths.py index 259f0f2..b83ccc0 100644 --- a/python/pcs_core/safe_paths.py +++ b/python/pcs_core/safe_paths.py @@ -2,7 +2,9 @@ from __future__ import annotations +import os import re +import stat from pathlib import Path, PurePosixPath, PureWindowsPath # Conservative limit: reject absurdly long refs before filesystem work. @@ -10,11 +12,34 @@ _CONTROL_OR_NUL_RE = re.compile(r"[\x00-\x1f\x7f]") +# Windows FILE_ATTRIBUTE_REPARSE_POINT — covers symlinks, junctions, and mounts. +_FILE_ATTRIBUTE_REPARSE_POINT = 0x400 + class UnsafePathError(ValueError): """Raised when a path ref fails containment or safety checks.""" +def is_symlink_or_reparse_point(path: Path) -> bool: + """True if ``path`` is a symlink or Windows reparse point (junction/mount).""" + try: + if path.is_symlink(): + return True + except OSError: + return True + if os.name != "nt": + return False + try: + st = path.lstat() + except OSError: + return True + attrs = getattr(st, "st_file_attributes", None) + if isinstance(attrs, int) and (attrs & _FILE_ATTRIBUTE_REPARSE_POINT): + return True + # Fallback: some Python builds omit st_file_attributes; treat S_IFLNK as link. + return stat.S_ISLNK(st.st_mode) + + def _is_windows_drive_or_unc(ref: str) -> bool: """Detect Windows drive paths and UNC shares in the raw ref string.""" if re.match(r"^[A-Za-z]:[\\/]", ref): @@ -44,7 +69,7 @@ def _has_parent_segment(ref: str) -> bool: def _reject_symlink_components(path: Path) -> None: - """Reject if any path component (including the final) is a symlink.""" + """Reject if any path component (including the final) is a symlink/reparse point.""" # Walk from root toward the leaf so intermediate link escapes are caught. parts = path.parts if not parts: @@ -52,14 +77,14 @@ def _reject_symlink_components(path: Path) -> None: # Absolute paths: rebuild incrementally from the anchor. current = Path(parts[0]) if len(parts) == 1: - if current.is_symlink(): - raise UnsafePathError(f"symlink rejected: {current}") + if is_symlink_or_reparse_point(current): + raise UnsafePathError(f"symlink or reparse point rejected: {current}") return for part in parts[1:]: current = current / part try: - if current.is_symlink(): - raise UnsafePathError(f"symlink rejected: {current}") + if is_symlink_or_reparse_point(current): + raise UnsafePathError(f"symlink or reparse point rejected: {current}") except OSError as exc: raise UnsafePathError(f"cannot inspect path component {current}: {exc}") from exc @@ -110,14 +135,14 @@ def resolve_contained_file( if not root_resolved.is_dir(): raise UnsafePathError(f"root is not a directory: {root_resolved}") - # Walk lexically under root before resolve so intermediate symlinks are visible. + # Walk lexically under root before resolve so intermediate symlinks/reparses are visible. lexical = root_resolved for part in pure.parts: lexical = lexical / part if reject_symlinks: try: - if lexical.is_symlink(): - raise UnsafePathError(f"symlink rejected: {lexical}") + if is_symlink_or_reparse_point(lexical): + raise UnsafePathError(f"symlink or reparse point rejected: {lexical}") except OSError as exc: raise UnsafePathError(f"cannot inspect path component {lexical}: {exc}") from exc @@ -136,9 +161,9 @@ def resolve_contained_file( if not resolved.is_file(): raise UnsafePathError(f"path is not a regular file: {ref!r}") - if resolved.is_symlink(): + if is_symlink_or_reparse_point(resolved): # Belt-and-suspenders if is_file() followed a link on some platforms. - raise UnsafePathError(f"symlink rejected: {resolved}") + raise UnsafePathError(f"symlink or reparse point rejected: {resolved}") if allowed_suffixes: suffix = resolved.suffix.lower() diff --git a/python/scripts/materialize_computation_fixtures.py b/python/scripts/materialize_computation_fixtures.py index f57b89e..5fb00ad 100644 --- a/python/scripts/materialize_computation_fixtures.py +++ b/python/scripts/materialize_computation_fixtures.py @@ -28,6 +28,7 @@ SM_REPO, ) from pcs_core.registry import build_artifact_registry # noqa: E402 +from pcs_core.release_fixtures import file_digest # noqa: E402 from pcs_core.validate import validate_file # noqa: E402 RUNNER_REPO = "https://github.com/example/scientific-computation-runner" @@ -41,7 +42,10 @@ ENV_ID = "env-repro-001" RUN_ID = "run-sci-comp-001" RESULT_ID = "result-metric-001" -RESULT_SHA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +RESULT_PAYLOAD_RELPATH = "outputs/metrics.json" +RESULT_PAYLOAD_BYTES = b'{"metric":"accuracy","value":0.987,"seed":42}\n' +RESULT_SHA = file_digest(RESULT_PAYLOAD_BYTES) +RESULT_SIZE = len(RESULT_PAYLOAD_BYTES) _PLACEHOLDER_COMMITS = { "a" * 40: RUNNER_COMMIT, @@ -149,9 +153,9 @@ def _result_body() -> dict[str, Any]: "schema_version": "v0", "result_id": RESULT_ID, "result_kind": "metric", - "path": "outputs/metrics.json", + "path": RESULT_PAYLOAD_RELPATH, "sha256": RESULT_SHA, - "size_bytes": 2048, + "size_bytes": RESULT_SIZE, "media_type": "application/json", "description": "Primary reproducibility metric output", "produced_by_run": RUN_ID, @@ -337,8 +341,6 @@ def _write_json(path: Path, data: dict[str, Any]) -> None: def main() -> int: - from pcs_core.release_fixtures import file_digest - profiles = examples_dir() / "workflow_profiles" release = examples_dir() / "computation-release" invalid_root = examples_dir() / "computation-release-invalid" @@ -354,6 +356,9 @@ def main() -> int: _write_json(release / "computation_run_receipt.json", run_receipt) _write_json(release / "result_artifact.json", result) _write_json(release / "computation_witness.json", witness) + payload_path = release / RESULT_PAYLOAD_RELPATH + payload_path.parent.mkdir(parents=True, exist_ok=True) + payload_path.write_bytes(RESULT_PAYLOAD_BYTES) _write_json( release / "science_claim_bundle.certified.json", _adapt_science_bundle( @@ -505,6 +510,16 @@ def main() -> int: ], "responsible_component": "CertifyEdge", }, + { + "check_id": "computation_result_payload_bytes", + "description": "ResultArtifact payload path resolves and SHA-256/size match bytes", + "status": "passed", + "details": {}, + "registry_check_refs": [ + "ResultArtifact.v0.payload_bytes_match_digest", + ], + "responsible_component": "pcs-core", + }, { "check_id": "computation_code_commit_present", "description": "ComputationWitness and run receipt carry non-zero code commits", @@ -650,17 +665,37 @@ def main() -> int: def _write_invalid_case( case_name: str, builder: Callable[[], tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]], + *, + payload_bytes: bytes | None = RESULT_PAYLOAD_BYTES, + extra_files: dict[str, dict[str, Any] | bytes] | None = None, ) -> None: ds, env, run_doc, res, wit = builder() case_dir = invalid_root / case_name case_dir.mkdir(parents=True, exist_ok=True) for stale in case_dir.glob("*.json"): stale.unlink() + outputs = case_dir / "outputs" + if outputs.is_dir(): + for stale_payload in outputs.rglob("*"): + if stale_payload.is_file(): + stale_payload.unlink() _write_json(case_dir / "dataset_receipt.json", ds) _write_json(case_dir / "environment_receipt.json", env) _write_json(case_dir / "computation_run_receipt.json", run_doc) _write_json(case_dir / "result_artifact.json", res) _write_json(case_dir / "computation_witness.json", wit) + if payload_bytes is not None: + out = case_dir / RESULT_PAYLOAD_RELPATH + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(payload_bytes) + if extra_files: + for rel, content in extra_files.items(): + dest = case_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + dest.write_bytes(content) + else: + _write_json(dest, content) def _invalid_dataset_hash_mismatch() -> tuple[dict[str, Any], ...]: ds, env, run_doc, res, _ = _valid_train() @@ -836,6 +871,56 @@ def _invalid_missing_run_receipt_hash() -> tuple[dict[str, Any], ...]: ) return ds, env, run_doc, res, _with_digest(wit) + def _invalid_payload_modified() -> tuple[dict[str, Any], ...]: + return _valid_train() + + def _invalid_payload_wrong_size() -> tuple[dict[str, Any], ...]: + ds, env, run_doc, res, _ = _valid_train() + wrong = dict(res) + wrong["size_bytes"] = RESULT_SIZE + 99 + wrong = _with_digest(wrong) + wit = _witness_body( + dataset=ds, + environment=env, + run_receipt=run_doc, + result=wrong, + ) + return ds, env, run_doc, wrong, _with_digest(wit) + + def _invalid_payload_missing() -> tuple[dict[str, Any], ...]: + return _valid_train() + + def _invalid_payload_traversal() -> tuple[dict[str, Any], ...]: + ds, env, run_doc, res, _ = _valid_train() + traversed = dict(res) + traversed["path"] = "../outside_metrics.json" + traversed = _with_digest(traversed) + wit = _witness_body( + dataset=ds, + environment=env, + run_receipt=run_doc, + result=traversed, + ) + return ds, env, run_doc, traversed, _with_digest(wit) + + def _invalid_payload_digest_mismatch_envelope() -> tuple[dict[str, Any], ...]: + """Valid sealed ResultArtifact JSON whose sha256 does not match on-disk bytes.""" + ds, env, run_doc, res, _ = _valid_train() + wrong = dict(res) + wrong["sha256"] = "sha256:" + "d" * 64 + wrong = _with_digest(wrong) + wit = _witness_body( + dataset=ds, + environment=env, + run_receipt=run_doc, + result=wrong, + result_hashes=[str(wrong["sha256"])], + ) + return ds, env, run_doc, wrong, _with_digest(wit) + + def _invalid_duplicate_result_declaration() -> tuple[dict[str, Any], ...]: + return _valid_train() + for case_name, builder in { "dataset_hash_mismatch": _invalid_dataset_hash_mismatch, "result_hash_mismatch": _invalid_result_hash_mismatch, @@ -854,6 +939,50 @@ def _invalid_missing_run_receipt_hash() -> tuple[dict[str, Any], ...]: }.items(): _write_invalid_case(case_name, builder) + # Payload mutation fixtures (B3). + _write_invalid_case( + "payload_modified", + _invalid_payload_modified, + payload_bytes=b'{"metric":"tampered","value":0.0}\n', + ) + _write_invalid_case("payload_wrong_size", _invalid_payload_wrong_size) + _write_invalid_case("payload_missing", _invalid_payload_missing, payload_bytes=None) + _write_invalid_case( + "payload_traversal", + _invalid_payload_traversal, + payload_bytes=None, + ) + # Symlink fixture: payload path is declared; tests replace the file with a symlink. + _write_invalid_case("payload_symlink", _invalid_payload_modified) + (invalid_root / "payload_symlink" / "README.md").write_text( + "Invalid symlink escape fixture.\n\n" + "The declared payload path is outputs/metrics.json. Tests replace that path with a " + "symlink (or reparse point) that points outside the release root; " + "verify_result_artifact_payload must reject it with payload_path_unsafe.\n", + encoding="utf-8", + newline="\n", + ) + _write_invalid_case( + "payload_digest_mismatch_envelope", + _invalid_payload_digest_mismatch_envelope, + ) + + dup_second_payload = b'{"metric":"duplicate","value":0.1}\n' + dup_second = dict(_result_body()) + dup_second["result_id"] = RESULT_ID # intentional duplicate declaration + dup_second["path"] = "outputs/metrics_dup.json" + dup_second["sha256"] = file_digest(dup_second_payload) + dup_second["size_bytes"] = len(dup_second_payload) + dup_second = _with_digest(dup_second) + _write_invalid_case( + "duplicate_result_declaration", + _invalid_duplicate_result_declaration, + extra_files={ + "result_artifact_2.json": dup_second, + "outputs/metrics_dup.json": dup_second_payload, + }, + ) + sm_report = { "allow_legacy": False, "bundle_shape": "pcs_core", diff --git a/python/tests/test_result_artifact_payload.py b/python/tests/test_result_artifact_payload.py new file mode 100644 index 0000000..10ec806 --- /dev/null +++ b/python/tests/test_result_artifact_payload.py @@ -0,0 +1,196 @@ +"""ResultArtifact.v0 payload byte verification (PR10 / B3).""" + +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path + +import pytest + +from pcs_core.computation_validate import ( + DUPLICATE_RESULT_DECLARATION, + PAYLOAD_DIGEST_MISMATCH, + PAYLOAD_MISSING, + PAYLOAD_PATH_UNSAFE, + PAYLOAD_SIZE_MISMATCH, + validate_computation_invalid_case, + validate_result_payloads_in_release, + verify_all_result_artifact_payloads, + verify_result_artifact_payload, +) +from pcs_core.lean_trust import extract_proof_obligations_from_release +from pcs_core.paths import examples_dir +from pcs_core.pcs_projection import PAYLOAD_SHA256_POINTER +from pcs_core.release_chain import validate_release_chain +from pcs_core.release_fixtures import file_digest + +COMPUTATION_RELEASE = examples_dir() / "computation-release" +INVALID_ROOT = examples_dir() / "computation-release-invalid" + + +def _load_result(release: Path) -> dict: + path = release / "result_artifact.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def test_valid_release_verifies_payload_bytes() -> None: + if not (COMPUTATION_RELEASE / "result_artifact.json").is_file(): + pytest.skip("run python/scripts/materialize_computation_fixtures.py") + verified = verify_all_result_artifact_payloads(COMPUTATION_RELEASE) + assert len(verified) == 1 + item = verified[0] + result = _load_result(COMPUTATION_RELEASE) + assert item.digest == result["sha256"] + assert item.size_bytes == result["size_bytes"] + assert item.payload_relpath == result["path"] + assert validate_result_payloads_in_release(COMPUTATION_RELEASE) == [] + assert validate_release_chain(COMPUTATION_RELEASE) == [] + + +def test_computation_obligations_record_verified_payload_digest() -> None: + if not (COMPUTATION_RELEASE / "proof_obligation.v0.json").is_file(): + pytest.skip("run python/scripts/materialize_computation_fixtures.py") + doc = extract_proof_obligations_from_release(COMPUTATION_RELEASE) + result = _load_result(COMPUTATION_RELEASE) + payload_path = Path(result["path"]) + entries = doc["pcs_projection_manifest"]["entries"] + payload_entries = [ + entry + for entry in entries + if entry.get("json_pointer") == PAYLOAD_SHA256_POINTER + ] + assert payload_entries, "expected verified payload projection entry" + assert payload_entries[0]["artifact_path"] == payload_path.as_posix() + assert payload_entries[0]["normalized_value"] == result["sha256"] + lean_id = payload_entries[0]["lean_identifier"] + assert lean_id == "concreteVerifiedResultPayloadHash" + + alignment = next( + obligation + for obligation in doc["obligations"] + if obligation["obligation_id"] == "computation_witness_hash_alignment" + ) + assert alignment["inputs"]["result_artifact_sha256"] == result["sha256"] + declared = alignment["inputs"]["declared_result_artifact_hashes"] + assert declared == [result["sha256"]] + + +@pytest.mark.parametrize( + ("case_name", "expected_token"), + [ + ("payload_modified", PAYLOAD_DIGEST_MISMATCH), + ("payload_wrong_size", PAYLOAD_SIZE_MISMATCH), + ("payload_missing", PAYLOAD_MISSING), + ("payload_traversal", PAYLOAD_PATH_UNSAFE), + ("payload_digest_mismatch_envelope", PAYLOAD_DIGEST_MISMATCH), + ("duplicate_result_declaration", DUPLICATE_RESULT_DECLARATION), + ], +) +def test_invalid_payload_fixtures(case_name: str, expected_token: str) -> None: + case_dir = INVALID_ROOT / case_name + if not case_dir.is_dir(): + pytest.skip(f"missing invalid fixture {case_name}") + harness_errors = validate_computation_invalid_case(case_dir) + assert harness_errors == [], harness_errors + errors = validate_result_payloads_in_release(case_dir) + assert errors, f"{case_name} must fail payload verification" + joined = " ".join(errors) + assert expected_token in joined, joined + + +@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unavailable") +def test_payload_symlink_escape_rejected(tmp_path: Path) -> None: + src = INVALID_ROOT / "payload_symlink" + if not src.is_dir(): + pytest.skip("missing payload_symlink fixture") + case_dir = tmp_path / "payload_symlink" + shutil.copytree(src, case_dir) + payload = case_dir / "outputs" / "metrics.json" + outside = tmp_path / "outside_metrics.json" + outside.write_bytes(b'{"metric":"escaped"}\n') + if payload.is_file(): + payload.unlink() + try: + payload.symlink_to(outside) + except OSError as exc: + pytest.skip(f"symlink creation failed: {exc}") + if not payload.is_symlink(): + pytest.skip("platform did not create a detectable symlink") + errors = validate_result_payloads_in_release(case_dir) + assert errors + assert PAYLOAD_PATH_UNSAFE in " ".join(errors) + + +def test_absolute_payload_path_rejected(tmp_path: Path) -> None: + if not (COMPUTATION_RELEASE / "result_artifact.json").is_file(): + pytest.skip("run python/scripts/materialize_computation_fixtures.py") + case_dir = tmp_path / "abs" + shutil.copytree( + COMPUTATION_RELEASE, + case_dir, + ignore=shutil.ignore_patterns( + "handoff_*", + "science_*", + "signed_*", + "verification_*", + "release_*", + "RELEASE_*", + "lean_*", + "proof_*", + "Artifact*", + "scientific_*", + "workflow_*", + ), + ) + # Minimal copy: ensure payload + result exist. + result_src = COMPUTATION_RELEASE / "result_artifact.json" + if not (case_dir / "result_artifact.json").is_file(): + shutil.copy2(result_src, case_dir / "result_artifact.json") + (case_dir / "outputs").mkdir(parents=True, exist_ok=True) + shutil.copy2( + COMPUTATION_RELEASE / "outputs" / "metrics.json", + case_dir / "outputs" / "metrics.json", + ) + result = _load_result(case_dir) + metrics = (case_dir / "outputs" / "metrics.json").resolve() + result["path"] = str(metrics) + from pcs_core.hash import PLACEHOLDER_DIGEST, canonical_hash + + result["signature_or_digest"] = PLACEHOLDER_DIGEST + result["signature_or_digest"] = canonical_hash(result) + (case_dir / "result_artifact.json").write_text( + json.dumps(result, indent=2) + "\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match=PAYLOAD_PATH_UNSAFE): + verify_result_artifact_payload(case_dir, result) + + +def test_tmp_mutation_digest_and_size(tmp_path: Path) -> None: + if not (COMPUTATION_RELEASE / "outputs" / "metrics.json").is_file(): + pytest.skip("run python/scripts/materialize_computation_fixtures.py") + case_dir = tmp_path / "mut" + case_dir.mkdir() + (case_dir / "outputs").mkdir() + payload = b'{"ok":true}\n' + (case_dir / "outputs" / "metrics.json").write_bytes(payload) + result = { + "result_id": "r1", + "path": "outputs/metrics.json", + "sha256": file_digest(payload), + "size_bytes": len(payload), + } + verified = verify_result_artifact_payload(case_dir, result) + assert verified.digest == file_digest(payload) + + (case_dir / "outputs" / "metrics.json").write_bytes(payload + b"x") + with pytest.raises(ValueError, match=PAYLOAD_DIGEST_MISMATCH): + verify_result_artifact_payload(case_dir, result) + + (case_dir / "outputs" / "metrics.json").write_bytes(payload) + result_bad_size = dict(result) + result_bad_size["size_bytes"] = len(payload) + 1 + with pytest.raises(ValueError, match=PAYLOAD_SIZE_MISMATCH): + verify_result_artifact_payload(case_dir, result_bad_size) From 75f95f6d32eb33b70cebc48a5795956596425e6a Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:01:34 -0700 Subject: [PATCH 13/24] Add asset resolver and verifier wheel/OCI distribution paths. Resolve pinned distribution assets through a single resolver and add wheel/OCI smoke scripts so consumers can verify packaged verifier artifacts reproducibly. --- .github/workflows/distribution.yml | 83 ++++++++ docker/verifier/Dockerfile | 53 +++-- docs/distribution.md | 104 ++++++++-- pins/python-base-image.json | 12 ++ python/pcs_core/asset_resolver.py | 288 ++++++++++++++++++++++++++++ python/tests/test_asset_resolver.py | 78 ++++++++ scripts/test-validator-wheel.sh | 51 +++++ scripts/test-verifier-oci.sh | 52 +++++ scripts/test-verifier-wheel.sh | 87 +++++++++ 9 files changed, 777 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/distribution.yml create mode 100644 pins/python-base-image.json create mode 100644 python/pcs_core/asset_resolver.py create mode 100644 python/tests/test_asset_resolver.py create mode 100644 scripts/test-validator-wheel.sh create mode 100644 scripts/test-verifier-oci.sh create mode 100644 scripts/test-verifier-wheel.sh diff --git a/.github/workflows/distribution.yml b/.github/workflows/distribution.yml new file mode 100644 index 0000000..1c8ff0a --- /dev/null +++ b/.github/workflows/distribution.yml @@ -0,0 +1,83 @@ +name: Distribution + +# Clean-environment validator / verifier wheel + OCI acceptance (PR 11 / A12). +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +jobs: + validator-wheel: + name: Validator-wheel clean install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Validator wheel clean install + run: bash scripts/test-validator-wheel.sh + + verifier-wheel: + name: Verifier-wheel clean install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: Verifier wheel clean install + run: | + export PATH="$HOME/.elan/bin:$PATH" + bash scripts/test-verifier-wheel.sh + + verifier-oci-scaffold: + name: Verifier OCI Dockerfile pin + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Assert Dockerfile base pin + non-root user + run: | + python3 - <<'PY' + from pathlib import Path + import json + import re + + pin = json.loads(Path("pins/python-base-image.json").read_text(encoding="utf-8")) + df = Path("docker/verifier/Dockerfile").read_text(encoding="utf-8") + digest = pin["index_digest"] + assert digest.startswith("sha256:") + assert digest in df, "Dockerfile must pin PYTHON_IMAGE by index digest" + assert "USER pcs" in df + assert "uid 10001" in df or "--uid 10001" in df + assert "org.opencontainers.image.base.digest" in df + # Forbid floating tag-only FROM without digest + for line in df.splitlines(): + if re.match(r"^FROM\s+", line.strip()) and "@sha256:" not in line and "PYTHON_IMAGE" not in line: + raise SystemExit(f"unpinned FROM: {line}") + print("OK verifier OCI Dockerfile pin + non-root") + PY + + verifier-oci: + name: Verifier OCI clean execution + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Verifier OCI build + clean run + run: bash scripts/test-verifier-oci.sh + + distribution-gate: + name: Distribution matrix gate + runs-on: ubuntu-latest + needs: + - validator-wheel + - verifier-wheel + - verifier-oci-scaffold + - verifier-oci + steps: + - name: All distribution jobs passed + run: echo "OK distribution matrix gate" diff --git a/docker/verifier/Dockerfile b/docker/verifier/Dockerfile index fbfe027..4662782 100644 --- a/docker/verifier/Dockerfile +++ b/docker/verifier/Dockerfile @@ -1,24 +1,31 @@ # syntax=docker/dockerfile:1.7 -# PCS verifier distribution (OCI scaffold). -# Ships pinned Lean toolchain assets + pcs-core with Lean sources for PF-Core / PCS -# envelope verification. Image signing (cosign/sigstore) is documented in -# docs/distribution.md; enable once org signing keys are available. +# PCS verifier distribution (OCI). +# Base image pinned by digest (pins/python-base-image.json). Image signing, +# SBOM, and provenance attestations are documented in docs/distribution.md. -ARG PYTHON_IMAGE=python:3.12-slim-bookworm +# Multi-platform index digest for python:3.12-slim-bookworm +ARG PYTHON_IMAGE=python@sha256:d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b FROM ${PYTHON_IMAGE} AS base ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PATH="/root/.elan/bin:${PATH}" \ - PCS_PRODUCT=verifier + ELAN_HOME=/opt/elan \ + PATH="/opt/elan/bin:${PATH}" \ + PCS_PRODUCT=verifier \ + HOME=/home/pcs RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates git build-essential \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 pcs \ + && useradd --system --uid 10001 --gid pcs --create-home --home-dir /home/pcs pcs \ + && mkdir -p /opt/elan /opt/pcs-core /work \ + && chown -R pcs:pcs /opt/elan /opt/pcs-core /work /home/pcs # Pin elan by version + sha256 (pins/elan.json). Fail closed on mismatch. -COPY pins/elan.json /tmp/elan.json +COPY --chown=pcs:pcs pins/elan.json /tmp/elan.json +USER pcs RUN set -eux; \ ELAN_VERSION="$(python3 -c 'import json; print(json.load(open("/tmp/elan.json"))["version"])')"; \ ELAN_SHA="$(python3 -c 'import json; print(json.load(open("/tmp/elan.json"))["sha256"])')"; \ @@ -27,25 +34,29 @@ RUN set -eux; \ curl -sSfL "$ELAN_URL" -o /tmp/elan.tar.gz; \ echo "${ELAN_SHA} /tmp/elan.tar.gz" | sha256sum -c -; \ tar -xzf /tmp/elan.tar.gz -C /tmp; \ - /tmp/elan-init -y --default-toolchain none; \ - elan default "$LEAN_TC"; \ + ELAN_HOME=/opt/elan /tmp/elan-init -y --default-toolchain none --no-modify-path; \ + /opt/elan/bin/elan default "$LEAN_TC"; \ rm -rf /tmp/elan.tar.gz /tmp/elan-init /tmp/elan.json WORKDIR /opt/pcs-core -COPY schemas ./schemas -COPY catalog ./catalog -COPY lean ./lean -COPY python ./python -COPY pins ./pins -COPY test_vectors ./test_vectors -COPY VERSION ./VERSION +USER root +COPY --chown=pcs:pcs schemas ./schemas +COPY --chown=pcs:pcs catalog ./catalog +COPY --chown=pcs:pcs lean ./lean +COPY --chown=pcs:pcs python ./python +COPY --chown=pcs:pcs pins ./pins +COPY --chown=pcs:pcs test_vectors ./test_vectors +COPY --chown=pcs:pcs VERSION ./VERSION -RUN pip install --no-cache-dir -e "./python" \ +USER pcs +RUN pip install --user --no-cache-dir -e "./python" \ && cd lean \ && lake build PCS \ && lake build PFCore +ENV PATH="/home/pcs/.local/bin:${PATH}" + WORKDIR /work ENTRYPOINT ["pcs"] CMD ["capabilities"] @@ -53,4 +64,6 @@ CMD ["capabilities"] LABEL org.opencontainers.image.title="pcs-core-verifier" \ org.opencontainers.image.description="PCS Lean verifier distribution" \ org.opencontainers.image.source="https://github.com/SentinelOps-CI/pcs-core" \ - org.opencontainers.image.licenses="Apache-2.0" + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.base.name="docker.io/library/python:3.12-slim-bookworm" \ + org.opencontainers.image.base.digest="sha256:d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b" diff --git a/docs/distribution.md b/docs/distribution.md index 946718b..c8e6816 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -4,6 +4,13 @@ pcs-core ships two supported products. `pcs capabilities` reports which backends actually available on the current machine and never claims Lean or live CertifyEdge when those assets are absent. +Asset locations (Lean root, PF-Core / PCS kernels, generated proofs, pins, catalogs, +schemas) are resolved exclusively through +[`python/pcs_core/asset_resolver.py`](../python/pcs_core/asset_resolver.py). Compilers, +hashers, bundle assembly, and proof-reference paths must not hardcode +`repo_root() / "lean"`. Override with `PCS_DISTRIBUTION_ROOT`, `PCS_LEAN_ROOT`, +`PCS_PINS_DIR`, or `PCS_CATALOG_DIR` when needed. + ## Validator package (default Python wheel) **Contains** @@ -33,6 +40,19 @@ Expected product line: `pcs product: validator` unless a full checkout plus `lak are present. Lean subcommands may still be listed for developer checkouts; capability detection and command failures remain the source of truth for what is available. +### Clean-environment acceptance (validator wheel) + +From a fresh virtualenv with only the built validator wheel installed (no repo checkout +on `PYTHONPATH`, no `lake` on `PATH`): + +1. Schema validation succeeds (`pcs validate `). +2. Semantic validation succeeds (`pcs examples check` or release-chain validate). +3. `pcs capabilities --json` reports `product: validator` and + `lean_toolchain` / `pf_core_kernel` / `pcs_envelope_kernel` as `false`. + +CI job: `validator-wheel` in [`.github/workflows/distribution.yml`](../.github/workflows/distribution.yml). +Local: `bash scripts/test-validator-wheel.sh`. + ## Verifier distribution **Contains** @@ -41,30 +61,74 @@ detection and command failures remain the source of truth for what is available. - Lake project under `lean/` - PF-Core and PCS Lean sources - Generated-proof and proof-binding tooling -- Release-bundle tooling (`pcs pf-core bundle-release`) +- Release-bundle tooling (`pcs pf-core bundle-release`, `pcs pf-core verify-bundle`) ### OCI image (primary ship vehicle) -Scaffold Dockerfile: [`docker/verifier/Dockerfile`](../docker/verifier/Dockerfile). +Dockerfile: [`docker/verifier/Dockerfile`](../docker/verifier/Dockerfile). + +- Base image pinned by **digest** (`pins/python-base-image.json`). +- Runs as non-root user `pcs` (uid/gid `10001`). +- Elan / Lean tools live under `/opt/elan` (owned by `pcs`). ```bash docker build -f docker/verifier/Dockerfile -t pcs-core-verifier:local . -docker run --rm pcs-core-verifier:local capabilities +docker run --rm --user 10001:10001 pcs-core-verifier:local capabilities ``` -**Image signing (gap until org keys exist)** +### Signed images, SBOM, and provenance + +Publish path (once org signing keys / GitHub OIDC are configured): + +1. Build and push by digest: + + ```bash + docker buildx build --push \ + -f docker/verifier/Dockerfile \ + -t ghcr.io/sentinelops-ci/pcs-core-verifier:vX.Y.Z \ + -t ghcr.io/sentinelops-ci/pcs-core-verifier:sha- \ + . + ``` + +2. Attach SBOM (CycloneDX) and provenance attestations: -1. Build and tag by digest: `docker buildx build --push …` and record the digest in - release notes. -2. Sign with cosign once SentinelOps-CI signing keys / GitHub OIDC are configured: + ```bash + # SBOM (example with syft) + syft packages pcs-core-verifier:vX.Y.Z -o cyclonedx-json > pcs-core-verifier.cdx.json + + # GitHub artifact attestations / build provenance + # (.github/workflows/release-provenance.yml: + # actions/attest-build-provenance + actions/attest-sbom; + # consumer job: scripts/verify-release-provenance.sh) + ``` + +3. Sign with cosign (keyless OIDC preferred): + + ```bash + cosign sign --yes ghcr.io/sentinelops-ci/pcs-core-verifier@sha256: + cosign attest --yes --predicate pcs-core-verifier.cdx.json --type cyclonedx \ + ghcr.io/sentinelops-ci/pcs-core-verifier@sha256: + cosign verify ghcr.io/sentinelops-ci/pcs-core-verifier@sha256: + ``` + +4. Publish the image digest, cosign signature, SBOM digest, and provenance statement + in the GitHub Release assets. Consumers verify: + + ```bash + cosign verify ghcr.io/sentinelops-ci/pcs-core-verifier@sha256: + cosign verify-attestation --type cyclonedx \ + ghcr.io/sentinelops-ci/pcs-core-verifier@sha256: + ``` + +CI job: `Verifier OCI clean execution` in [`.github/workflows/distribution.yml`](../.github/workflows/distribution.yml) +(`scripts/test-verifier-oci.sh`). Local: ```bash -cosign sign --yes ghcr.io/sentinelops-ci/pcs-core-verifier@sha256: -cosign verify ghcr.io/sentinelops-ci/pcs-core-verifier@sha256: +bash scripts/test-verifier-oci.sh ``` -3. Publish the digest + signature in the GitHub Release assets. Until signing infra is - live, treat the Dockerfile + pin files as the reproducible build contract. +Signed image publish (cosign / GHCR) remains org-gated until signing keys / OIDC are provisioned. +Operator runbook: [pf-core/operator-release-gates.md](pf-core/operator-release-gates.md). ### Optional full wheel @@ -75,6 +139,23 @@ bash scripts/build-verifier-wheel.sh Embeds `lean/` and `pins/` under `pcs_core/` for `importlib.resources` style layouts. Prefer the OCI image for production verifiers. +### Clean-environment acceptance (verifier wheel) + +From a fresh virtualenv with the verifier wheel installed and pinned `lake` on `PATH`: + +1. Bundled Lean assets are located (`asset_resolver.lean_root()` / capabilities paths). +2. `compute_pfcore_kernel_hash()` is non-empty and matches checkout kernel hash. +3. `compute_lean_environment_hash()` matches the bundled lake/toolchain inputs. +4. PF-Core proof compiles (`pcs pf-core lean-check` on TraceSafeR fixture). +5. PCS envelope proof path remains available (`lake build PCS`). +6. Bundle assembly succeeds (`pcs pf-core bundle-release`). +7. Independent bundle verification succeeds (`pcs pf-core verify-bundle`). + +CI jobs: `Validator-wheel clean install`, `Verifier-wheel clean install`, and +`Verifier OCI clean execution` in [`.github/workflows/distribution.yml`](../.github/workflows/distribution.yml). +Local: `bash scripts/test-validator-wheel.sh`, `bash scripts/test-verifier-wheel.sh` (requires elan/lake), +`bash scripts/test-verifier-oci.sh` (requires Docker). + ## Capability matrix | Capability | Validator wheel | Full checkout + lake | Verifier OCI | @@ -94,6 +175,7 @@ Prefer the OCI image for production verifiers. | File | Purpose | |------|---------| | `pins/elan.json` | Elan archive URL + sha256 | +| `pins/python-base-image.json` | Verifier OCI base image digests | | `pins/certifyedge.json` | CertifyEdge image digest placeholder | | `pins/github-actions.json` | Immutable Action SHAs | | `lean/lean-toolchain` | Lean 4 version | diff --git a/pins/python-base-image.json b/pins/python-base-image.json new file mode 100644 index 0000000..e56e9d4 --- /dev/null +++ b/pins/python-base-image.json @@ -0,0 +1,12 @@ +{ + "image": "docker.io/library/python", + "tag": "3.12-slim-bookworm", + "index_digest": "sha256:d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b", + "amd64_digest": "sha256:72d3d75f2639ab82b34b29390ad3d6e0827c775befee94edda8e9976818f488d", + "dockerfile_from": "python@sha256:d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b", + "notes": [ + "Pin the multi-platform index digest in docker/verifier/Dockerfile ARG PYTHON_IMAGE.", + "amd64_digest is recorded for single-arch rebuilds and SBOM correlation.", + "Refresh digests deliberately; never float on the mutable tag alone." + ] +} diff --git a/python/pcs_core/asset_resolver.py b/python/pcs_core/asset_resolver.py new file mode 100644 index 0000000..96113af --- /dev/null +++ b/python/pcs_core/asset_resolver.py @@ -0,0 +1,288 @@ +"""Authoritative resolver for PCS / PF-Core distribution assets. + +Every compiler, hash, bundle, and proof-reference path must resolve Lean roots, +kernel sources, generated-proof directories, pins, and catalogs through this +module instead of hardcoding ``repo_root() / \"lean\"`` (or equivalent). + +Resolution order (highest precedence first): + +1. Explicit environment overrides (``PCS_DISTRIBUTION_ROOT``, ``PCS_LEAN_ROOT``, + ``PCS_PINS_DIR``, ``PCS_CATALOG_DIR``). +2. Verifier-wheel layout: assets bundled under ``package_dir()`` + (``pcs_core/lean``, ``pcs_core/pins``, ``pcs_core/catalog``). +3. Developer checkout: assets under ``repo_root()``. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from pcs_core.paths import package_dir, repo_root, schemas_dir + +__all__ = [ + "ENV_CATALOG_DIR", + "ENV_DISTRIBUTION_ROOT", + "ENV_LEAN_ROOT", + "ENV_PINS_DIR", + "catalog_dir", + "distribution_root", + "lean_root", + "pcs_generated_root", + "pcs_kernel_root", + "pf_core_generated_root", + "pf_core_kernel_root", + "pin_path", + "pins_dir", + "proof_ref_from_path", + "relative_to_distribution", + "require_lean_root", + "resolver_report", + "schemas_dir", +] + +ENV_DISTRIBUTION_ROOT = "PCS_DISTRIBUTION_ROOT" +ENV_LEAN_ROOT = "PCS_LEAN_ROOT" +ENV_PINS_DIR = "PCS_PINS_DIR" +ENV_CATALOG_DIR = "PCS_CATALOG_DIR" + + +def _env_path(name: str) -> Path | None: + raw = os.environ.get(name, "").strip() + if not raw: + return None + return Path(raw).expanduser().resolve() + + +def _is_lean_project(path: Path) -> bool: + return path.is_dir() and (path / "lakefile.lean").is_file() + + +def _has_pins(path: Path) -> bool: + return path.is_dir() and (path / "elan.json").is_file() + + +def _has_catalog(path: Path) -> bool: + return path.is_dir() and (path / "pf_core.catalog.json").is_file() + + +def distribution_root() -> Path | None: + """Return the root that owns ``lean/``, ``pins/``, and ``catalog/``. + + Returns ``None`` when neither a verifier-wheel layout nor a checkout is + detectable (validator-only installs without Lean assets). + """ + override = _env_path(ENV_DISTRIBUTION_ROOT) + if override is not None: + return override if override.is_dir() else None + + bundled_lean = package_dir() / "lean" + if _is_lean_project(bundled_lean): + return package_dir() + + checkout_lean = repo_root() / "lean" + if _is_lean_project(checkout_lean): + return repo_root() + + # Pins / catalog alone (validator wheel) still define a distribution root. + if _has_pins(package_dir() / "pins") or _has_catalog(package_dir() / "catalog"): + return package_dir() + if _has_pins(repo_root() / "pins") or _has_catalog(repo_root() / "catalog"): + return repo_root() + return None + + +def lean_root(*, required: bool = False) -> Path | None: + """Locate the Lake project root (contains ``lakefile.lean``).""" + override = _env_path(ENV_LEAN_ROOT) + if override is not None: + if _is_lean_project(override): + return override + if required: + raise FileNotFoundError( + f"{ENV_LEAN_ROOT}={override} is not a Lean project (missing lakefile.lean)" + ) + return None + + dist = distribution_root() + if dist is not None: + candidate = dist / "lean" + if _is_lean_project(candidate): + return candidate + + # Fall back to the historical checkout layout even when lakefile is absent + # so callers that only need a Path keep working in incomplete trees. + fallback = repo_root() / "lean" + if _is_lean_project(fallback): + return fallback + if required: + raise FileNotFoundError( + "Lean project not found. Install the verifier wheel / OCI image, " + f"set {ENV_LEAN_ROOT}, or use a full pcs-core checkout." + ) + return None if not fallback.exists() else fallback + + +def require_lean_root() -> Path: + """Return the Lean project root or raise ``FileNotFoundError``.""" + root = lean_root(required=True) + assert root is not None + return root + + +def pf_core_kernel_root() -> Path: + """PF-Core kernel sources (excludes write target under Generated/).""" + return require_lean_root() / "PFCore" + + +def pcs_kernel_root() -> Path: + """PCS envelope kernel sources.""" + return require_lean_root() / "PCS" + + +def pf_core_generated_root() -> Path: + """Directory for generated PF-Core proof modules.""" + return pf_core_kernel_root() / "Generated" + + +def pcs_generated_root() -> Path: + """Directory for generated PCS envelope proof modules.""" + return pcs_kernel_root() / "Generated" + + +def pins_dir(*, required: bool = False) -> Path | None: + """Locate the supply-chain pins directory.""" + override = _env_path(ENV_PINS_DIR) + if override is not None: + if _has_pins(override) or override.is_dir(): + return override + if required: + raise FileNotFoundError(f"{ENV_PINS_DIR}={override} is not a pins directory") + return None + + for candidate in ( + package_dir() / "pins", + (distribution_root() or Path()) / "pins", + repo_root() / "pins", + ): + if candidate == Path("pins"): + continue + if _has_pins(candidate): + return candidate + + if required: + raise FileNotFoundError( + "pins/ not found. Install pcs-core with pins embedded or use a full checkout." + ) + return None + + +def pin_path(name: str, *, required: bool = True) -> Path | None: + """Resolve a pin file such as ``elan.json`` or ``python-base-image.json``. + + When ``required`` is False, returns ``None`` if the pins directory or file + is absent instead of raising. + """ + filename = name if name.endswith(".json") else f"{name}.json" + root = pins_dir(required=False) + if root is None: + if required: + raise FileNotFoundError(f"pins directory unavailable for {filename}") + return None + path = root / filename + if not path.is_file(): + if required: + raise FileNotFoundError(f"pin file not found: {path}") + return None + return path + + +def catalog_dir(*, required: bool = False) -> Path | None: + """Locate the PF-Core / domain catalog directory.""" + override = _env_path(ENV_CATALOG_DIR) + if override is not None: + if _has_catalog(override) or override.is_dir(): + return override + if required: + raise FileNotFoundError(f"{ENV_CATALOG_DIR}={override} is not a catalog directory") + return None + + for candidate in ( + package_dir() / "catalog", + (distribution_root() or Path()) / "catalog", + repo_root() / "catalog", + ): + if candidate == Path("catalog"): + continue + if _has_catalog(candidate): + return candidate + + if required: + raise FileNotFoundError( + "catalog/ not found. Install pcs-core from a release wheel or use a full checkout." + ) + return None + + +def relative_to_distribution(path: Path) -> str: + """Return a stable posix-relative asset path for digests and proof refs. + + Prefers ``distribution_root()``; falls back to ``repo_root()``; finally + returns the absolute path with forward slashes when the file lives outside + either root (ephemeral temp proofs). + """ + resolved = path.resolve() + for root in (distribution_root(), repo_root()): + if root is None: + continue + try: + return resolved.relative_to(root.resolve()).as_posix() + except ValueError: + continue + lean = lean_root() + if lean is not None: + try: + rel = resolved.relative_to(lean.resolve()).as_posix() + return f"lean/{rel}" + except ValueError: + pass + return str(resolved).replace("\\", "/") + + +def proof_ref_from_path(path: Path) -> str: + """Stable proof_term_ref for a generated Lean file.""" + return relative_to_distribution(path) + + +def resolver_report() -> dict[str, Any]: + """Machine-readable summary of resolved asset locations.""" + lean = lean_root() + pins = pins_dir() + catalog = catalog_dir() + try: + schemas = str(schemas_dir()) + schemas_ok = True + except FileNotFoundError: + schemas = None + schemas_ok = False + return { + "distribution_root": str(distribution_root()) if distribution_root() else None, + "lean_root": str(lean) if lean else None, + "pf_core_kernel_root": str(lean / "PFCore") if lean else None, + "pcs_kernel_root": str(lean / "PCS") if lean else None, + "pf_core_generated_root": str(lean / "PFCore" / "Generated") if lean else None, + "pcs_generated_root": str(lean / "PCS" / "Generated") if lean else None, + "pins_dir": str(pins) if pins else None, + "catalog_dir": str(catalog) if catalog else None, + "schemas_dir": schemas, + "schemas_available": schemas_ok, + "lean_project_present": bool(lean and _is_lean_project(lean)), + "pf_core_kernel_present": bool( + lean + and ((lean / "PFCore" / "Basic.lean").is_file() or (lean / "PFCore.lean").is_file()) + ), + "pcs_kernel_present": bool( + lean and ((lean / "PCS" / "Basic.lean").is_file() or (lean / "PCS.lean").is_file()) + ), + } diff --git a/python/tests/test_asset_resolver.py b/python/tests/test_asset_resolver.py new file mode 100644 index 0000000..23d6fe8 --- /dev/null +++ b/python/tests/test_asset_resolver.py @@ -0,0 +1,78 @@ +"""Tests for the authoritative PCS/PF-Core asset resolver.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pcs_core import asset_resolver as ar +from pcs_core.paths import package_dir, repo_root + + +def test_distribution_root_is_checkout_or_package() -> None: + root = ar.distribution_root() + assert root is not None + assert root in {repo_root().resolve(), package_dir().resolve()} or root.is_dir() + + +def test_lean_root_resolves_lakefile() -> None: + lean = ar.lean_root() + assert lean is not None + assert (lean / "lakefile.lean").is_file() + assert ar.require_lean_root() == lean + + +def test_kernel_and_generated_roots() -> None: + pf = ar.pf_core_kernel_root() + pcs = ar.pcs_kernel_root() + assert pf.is_dir() + assert pcs.is_dir() + assert ar.pf_core_generated_root() == pf / "Generated" + assert ar.pcs_generated_root() == pcs / "Generated" + + +def test_pins_and_catalog() -> None: + pins = ar.pins_dir() + assert pins is not None + assert (pins / "elan.json").is_file() + assert ar.pin_path("elan.json").is_file() + assert ar.pin_path("python-base-image.json").is_file() + catalog = ar.catalog_dir() + assert catalog is not None + assert (catalog / "pf_core.catalog.json").is_file() + + +def test_relative_to_distribution_stable_for_kernel_file() -> None: + lean = ar.require_lean_root() + sample = next((lean / "PFCore").glob("*.lean")) + rel = ar.relative_to_distribution(sample) + assert rel.replace("\\", "/").startswith("lean/PFCore/") + assert not Path(rel).is_absolute() + + +def test_lean_root_env_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + fake = tmp_path / "lean" + fake.mkdir() + (fake / "lakefile.lean").write_text("-- test\n", encoding="utf-8") + monkeypatch.setenv(ar.ENV_LEAN_ROOT, str(fake)) + assert ar.lean_root() == fake.resolve() + + +def test_resolver_report_shape() -> None: + report = ar.resolver_report() + assert "lean_root" in report + assert "pins_dir" in report + assert "catalog_dir" in report + assert report["lean_project_present"] is True + assert report["schemas_available"] is True + + +def test_capabilities_surfaces_resolver_paths() -> None: + from pcs_core.capabilities import detect_capabilities + + report = detect_capabilities() + paths = report["paths"] + assert "distribution_root" in paths + assert "pins_dir" in paths + assert paths["lean_dir"] is not None diff --git a/scripts/test-validator-wheel.sh b/scripts/test-validator-wheel.sh new file mode 100644 index 0000000..4bdcdc7 --- /dev/null +++ b/scripts/test-validator-wheel.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Clean-environment acceptance for the default validator wheel. +# Schema + semantic validation succeed; Lean capabilities report unavailable. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/pcs-validator-wheel.XXXXXX")" +cleanup() { rm -rf "${WORK}"; } +trap cleanup EXIT + +cd "${ROOT}/python" +pip install --upgrade build >/dev/null +rm -rf dist +python -m build --wheel +WHEEL="$(ls -1 dist/pcs_core-*.whl | head -n1)" +test -n "${WHEEL}" + +python -m venv "${WORK}/venv" +# shellcheck disable=SC1091 +source "${WORK}/venv/bin/activate" +pip install --upgrade pip >/dev/null +pip install -c "${ROOT}/python/requirements.lock" "${ROOT}/python/${WHEEL}" + +# Ensure the checkout is not on PYTHONPATH. +unset PYTHONPATH +cd "${WORK}" + +pcs capabilities --json > "${WORK}/caps.json" +python3 - <<'PY' +import json, pathlib, sys +caps = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert caps["product"] == "validator", caps +c = caps["capabilities"] +assert c["schema_validation"] is True +assert c["lean_toolchain"] is False +assert c["pf_core_kernel"] is False +assert c["pcs_envelope_kernel"] is False +print("OK validator capabilities report Lean unavailable") +PY +"${WORK}/caps.json" + +# Copy fixtures into the clean env (wheel does not ship examples/). +mkdir -p "${WORK}/fixtures" +cp "${ROOT}/examples/science_claim_bundle.certified.valid.json" "${WORK}/fixtures/" +cp "${ROOT}/examples/tool_use_trace.valid.json" "${WORK}/fixtures/" +cp -R "${ROOT}/examples/labtrust-release" "${WORK}/fixtures/labtrust-release" + +pcs validate "${WORK}/fixtures/science_claim_bundle.certified.valid.json" +pcs validate "${WORK}/fixtures/tool_use_trace.valid.json" +pcs validate-release-chain "${WORK}/fixtures/labtrust-release/" +echo "OK validator wheel clean-environment checks" diff --git a/scripts/test-verifier-oci.sh b/scripts/test-verifier-oci.sh new file mode 100644 index 0000000..013eaad --- /dev/null +++ b/scripts/test-verifier-oci.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Clean-environment acceptance for the verifier OCI image. +# Builds docker/verifier/Dockerfile and runs capabilities + a fixture lean-check. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="${PCS_VERIFIER_OCI_IMAGE:-pcs-core-verifier:ci-local}" +TAG_FILE="${PCS_VERIFIER_OCI_ID_FILE:-}" + +if ! command -v docker >/dev/null 2>&1; then + echo "FAIL: docker not on PATH (required for verifier OCI clean execution)" >&2 + exit 1 +fi + +cd "${ROOT}" +echo "Building verifier OCI image ${IMAGE} ..." +docker build -f docker/verifier/Dockerfile -t "${IMAGE}" . + +if [ -n "${TAG_FILE}" ]; then + docker image inspect --format '{{.Id}}' "${IMAGE}" > "${TAG_FILE}" +fi + +echo "Running capabilities as non-root uid 10001 ..." +docker run --rm --user 10001:10001 "${IMAGE}" capabilities --json >/tmp/pcs-verifier-oci-caps.json +python3 - <<'PY' +import json +from pathlib import Path + +caps = json.loads(Path("/tmp/pcs-verifier-oci-caps.json").read_text(encoding="utf-8")) +assert caps.get("product") == "verifier", caps +c = caps.get("capabilities") or {} +assert c.get("lean_toolchain") is True, caps +assert c.get("pf_core_kernel") is True, caps +assert c.get("pcs_envelope_kernel") is True, caps +print("OK verifier OCI capabilities") +PY + +# Mount a fixture for lean-check (examples are not copied into the image). +FIXTURE_HOST="${ROOT}/examples/pf-core-valid/tool_use_trace_compiled" +test -f "${FIXTURE_HOST}/pfcore_trace.json" + +echo "Running lean-check inside OCI image ..." +docker run --rm --user 10001:10001 \ + -v "${FIXTURE_HOST}:/work/fixture:ro" \ + -w /work \ + "${IMAGE}" \ + pf-core lean-check \ + --trace /work/fixture/pfcore_trace.json \ + --out /tmp/pfcore-oci-cert.json \ + --result-out /tmp/pfcore-oci-lean-check.json + +echo "OK verifier OCI clean execution" diff --git a/scripts/test-verifier-wheel.sh b/scripts/test-verifier-wheel.sh new file mode 100644 index 0000000..8841b5b --- /dev/null +++ b/scripts/test-verifier-wheel.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Clean-environment acceptance for the verifier wheel (Lean assets embedded). +# Requires lake on PATH (install via scripts/install-elan-verified.sh). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! command -v lake >/dev/null 2>&1; then + echo "FAIL: lake not on PATH; install pinned elan first" >&2 + exit 1 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/pcs-verifier-wheel.XXXXXX")" +cleanup() { rm -rf "${WORK}"; } +trap cleanup EXIT + +bash "${ROOT}/scripts/build-verifier-wheel.sh" +WHEEL="$(ls -1 "${ROOT}/python/dist"/pcs_core-*.whl | head -n1)" +test -n "${WHEEL}" + +python3 -m venv "${WORK}/venv" +# shellcheck disable=SC1091 +source "${WORK}/venv/bin/activate" +pip install --upgrade pip >/dev/null +pip install -c "${ROOT}/python/requirements.lock" "${WHEEL}" + +unset PYTHONPATH +cd "${WORK}" + +# Copy fixtures needed for lean-check / bundle (examples are not in the wheel). +mkdir -p "${WORK}/fixtures" +cp -R "${ROOT}/examples/pf-core-valid/tool_use_trace_compiled" "${WORK}/fixtures/tool_use" + +python3 - <<'PY' +from pcs_core.asset_resolver import lean_root, pins_dir, resolver_report +from pcs_core.pf_core_lean_codegen import ( + compute_lean_environment_hash, + compute_pfcore_kernel_hash, +) + +report = resolver_report() +assert report["lean_project_present"] is True, report +assert report["pf_core_kernel_present"] is True, report +assert report["pcs_kernel_present"] is True, report +assert lean_root() is not None +assert pins_dir() is not None + +kernel = compute_pfcore_kernel_hash() +env = compute_lean_environment_hash() +assert kernel.startswith("sha256:") and len(kernel) > 16, kernel +assert env.startswith("sha256:") and len(env) > 16, env +assert kernel != "sha256:" + ("0" * 64) +print(f"OK bundled assets kernel={kernel[:18]}… env={env[:18]}…") +PY + +pcs capabilities --json > "${WORK}/caps.json" +python3 - <<'PY' +import json, pathlib, sys +caps = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert caps["product"] == "verifier", caps +c = caps["capabilities"] +assert c["lean_toolchain"] is True +assert c["pf_core_kernel"] is True +assert c["pcs_envelope_kernel"] is True +print("OK verifier capabilities") +PY +"${WORK}/caps.json" + +# Pre-build kernels once so lean-check does not rebuild from a missing lake-packages tree. +( + cd "$(python3 -c 'from pcs_core.asset_resolver import require_lean_root; print(require_lean_root())')" + lake build PCS + lake build PFCore +) + +pcs pf-core lean-check \ + --trace "${WORK}/fixtures/tool_use/pfcore_trace.json" \ + --out "${WORK}/cert.json" \ + --result-out "${WORK}/lean_check_result.json" + +pcs pf-core bundle-release \ + --trace "${WORK}/fixtures/tool_use/pfcore_trace.json" \ + --cert "${WORK}/cert.json" \ + --lean-check-result "${WORK}/lean_check_result.json" \ + --out "${WORK}/bundle" + +pcs pf-core verify-bundle "${WORK}/bundle" +echo "OK verifier wheel clean-environment checks" From 02726b814d0bb2921a2f81b1ea08cc009f3897d9 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:01:58 -0700 Subject: [PATCH 14/24] Make release profiles declarative and engine-driven. Move profile requirements into explicit specs evaluated by the engine so operators can reason about gate composition without reading imperative glue. --- python/pcs_core/release_profile_engine.py | 655 +++++++++++++++++--- python/pcs_core/release_profile_specs.py | 628 ++++++++++++++++++- python/tests/test_release_profile_engine.py | 177 ++++++ 3 files changed, 1362 insertions(+), 98 deletions(-) create mode 100644 python/tests/test_release_profile_engine.py diff --git a/python/pcs_core/release_profile_engine.py b/python/pcs_core/release_profile_engine.py index b6cc2ac..4d9e1ef 100644 --- a/python/pcs_core/release_profile_engine.py +++ b/python/pcs_core/release_profile_engine.py @@ -1,26 +1,26 @@ """Declarative release-profile engine for multi-domain PCS release-chain validation. Profile-specific modules remain as compatibility wrappers that delegate here. -The engine is driven by ``ReleaseProfileSpec`` (artifact/commit/handoff registries) -plus ``WorkflowProfile.v0`` metadata (handoff sequence, required registry entries, -status policy). Domain validators may still run via ``legacy_validator`` until -their checks are fully expressed as declarative bindings. +The engine is driven by ``ReleaseProfileSpec``: exact/optional artifact sets, +handoff completeness/order, status and commit requirements, certificate and +bundle-identity propagation, semantic validators, proof/import/payload/signature +requirements. Field bindings use JSON Pointer (RFC 6901). + +Unknown workflow profiles fail closed with ``UnknownWorkflowProfile``. """ from __future__ import annotations import json from collections.abc import Callable, Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any +from pcs_core.bundle_identity import resolve_certified_bundle_identity_hash from pcs_core.registry_data import registry_entries from pcs_core.release_chain import ReleaseChainIssue, _issue -from pcs_core.release_chain_profiles import ( - LABTRUST_WORKFLOW_PROFILE_ID, - detect_workflow_profile_id, -) +from pcs_core.release_chain_profiles import detect_workflow_profile_id from pcs_core.release_fixtures import ( MANIFEST_NAME, _load_json, @@ -39,24 +39,143 @@ None, ] +UNKNOWN_WORKFLOW_PROFILE = "UnknownWorkflowProfile" + + +def resolve_json_pointer(document: Any, pointer: str) -> Any: + """Resolve an RFC 6901 JSON Pointer against ``document``. + + Returns ``None`` when the pointer cannot be resolved. The empty pointer + ``\"\"`` returns the document itself. + """ + if pointer == "": + return document + if not isinstance(pointer, str) or not pointer.startswith("/"): + return None + current: Any = document + for raw in pointer[1:].split("/"): + token = raw.replace("~1", "/").replace("~0", "~") + if isinstance(current, dict): + if token not in current: + return None + current = current[token] + elif isinstance(current, list): + try: + index = int(token) + except ValueError: + return None + if index < 0 or index >= len(current): + return None + current = current[index] + else: + return None + return current + @dataclass(frozen=True) class CertificateIdBinding: - """Declarative certificate-id / hash propagation requirement.""" + """Declarative certificate-id / hash propagation requirement (JSON Pointer).""" source_artifact: str - source_field: str + source_pointer: str target_artifact: str - target_field: str - mode: str = "equals" # equals | ref_contains + target_pointer: str + mode: str = "equals" # equals | array_contains + issue_code: str = "certificate_id_mismatch" + require_source: bool = True @dataclass(frozen=True) class StatusRequirement: artifact: str - field: str - required_value: str + pointer: str + required_value: Any + issue_code: str + skip_if_artifact_missing: bool = True + + +@dataclass(frozen=True) +class SourceCommitRequirement: + """Require ``artifact#pointer`` equals ``manifest[manifest_commit_key]``.""" + + artifact: str + pointer: str + manifest_commit_key: str + issue_code: str + + +@dataclass(frozen=True) +class ProvenanceCommitRequirement: + """Scan nested ``source_repo``/``source_commit`` pairs under an artifact.""" + + artifact: str + expected_repo: str + manifest_commit_key: str + issue_code: str + nested_root_pointer: str = "" + + +@dataclass(frozen=True) +class BundleIdentityBinding: + """Compare a field to the resolved certified-bundle identity hash.""" + + artifact: str + pointer: str + issue_code: str + require_present: bool = True + missing_issue_code: str | None = None + + +@dataclass(frozen=True) +class ImportRequirement: + artifact: str + pointer: str + required_value: Any + issue_code: str + + +@dataclass(frozen=True) +class ProofRequirement: + """Require a non-empty value at ``artifact#pointer``.""" + + artifact: str + pointer: str issue_code: str + message: str | None = None + + +@dataclass(frozen=True) +class PayloadBinding: + """Bind a declared digest (and optional size) to payload bytes under the release root.""" + + artifact: str + path_pointer: str + digest_pointer: str + issue_code: str = "payload_digest_mismatch" + size_pointer: str | None = None + size_issue_code: str = "payload_size_mismatch" + missing_issue_code: str = "payload_missing" + + +@dataclass(frozen=True) +class SignatureRequirement: + """Require a non-empty signature (or signature digest) field.""" + + artifact: str + pointer: str + issue_code: str = "signature_missing" + + +@dataclass(frozen=True) +class ArrayElementBan: + """Ban a value on each element of an array (e.g. rejected tool calls).""" + + artifact: str + array_pointer: str + element_pointer: str + banned_value: Any + issue_code: str + message_template: str | None = None @dataclass(frozen=True) @@ -64,24 +183,41 @@ class ReleaseProfileSpec: """Declarative release-profile specification consumed by the engine.""" workflow_profile_id: str - manifest_artifacts: tuple[str, ...] - release_pcs_artifacts: tuple[str, ...] - handoff_files: tuple[str, ...] - commit_keys: tuple[str, ...] + required_artifacts: tuple[str, ...] + optional_artifacts: tuple[str, ...] = () + release_pcs_artifacts: tuple[str, ...] = () + handoff_files: tuple[str, ...] = () + commit_keys: tuple[str, ...] = () enforce_manifest_workflow_id: bool = True require_exact_manifest_artifact_set: bool = True + handoff_require_complete: bool = False + handoff_enforce_order: bool = True status_requirements: tuple[StatusRequirement, ...] = () + source_commit_requirements: tuple[SourceCommitRequirement, ...] = () + provenance_commit_requirements: tuple[ProvenanceCommitRequirement, ...] = () certificate_bindings: tuple[CertificateIdBinding, ...] = () + bundle_identity_bindings: tuple[BundleIdentityBinding, ...] = () + import_requirements: tuple[ImportRequirement, ...] = () + proof_requirements: tuple[ProofRequirement, ...] = () + payload_bindings: tuple[PayloadBinding, ...] = () + signature_requirements: tuple[SignatureRequirement, ...] = () + array_element_bans: tuple[ArrayElementBan, ...] = () domain_checks: DomainCheckFn | None = None semantic_validators: Mapping[str, Callable[[dict[str, Any]], list[str]]] = field( default_factory=dict, ) + semantic_issue_mapper: Callable[[str, str], str] | None = None alignment_checker: Callable[[Path, list[str]], None] | None = None alignment_issue_mapper: Callable[[str], str] | None = None - # Until full declarative parity is proven, run the battle-tested validator body. + # Retained only for side-by-side parity harnesses; production specs leave this None. legacy_validator: LegacyValidatorFn | None = None run_workflow_profile_declarations: bool = True + @property + def manifest_artifacts(self) -> tuple[str, ...]: + """Backward-compatible alias for the required artifact set.""" + return self.required_artifacts + _PROFILE_REGISTRY: dict[str, ReleaseProfileSpec] = {} @@ -106,6 +242,26 @@ def resolve_release_profile(directory: Path) -> ReleaseProfileSpec | None: return None +def normalized_issue_codes(issues: list[ReleaseChainIssue]) -> frozenset[str]: + return frozenset(issue.code for issue in issues) + + +def compare_legacy_and_declarative( + directory: Path, + spec: ReleaseProfileSpec, +) -> tuple[frozenset[str], frozenset[str]]: + """Return ``(legacy_codes, declarative_codes)`` for side-by-side parity.""" + if spec.legacy_validator is None: + raise ValueError( + f"profile {spec.workflow_profile_id!r} has no legacy_validator for parity", + ) + base = directory.resolve() + legacy_issues = list(spec.legacy_validator(base)) + declarative_spec = replace(spec, legacy_validator=None) + declarative_issues = run_structural_release_profile_validation(base, declarative_spec) + return normalized_issue_codes(legacy_issues), normalized_issue_codes(declarative_issues) + + def validate_workflow_profile_declarations( base: Path, spec: ReleaseProfileSpec, @@ -123,7 +279,7 @@ def validate_workflow_profile_declarations( return None handoff_sequence = profile.get("handoff_sequence") - if isinstance(handoff_sequence, list) and spec.handoff_files: + if isinstance(handoff_sequence, list) and spec.handoff_files and spec.handoff_enforce_order: expected = [str(item) for item in handoff_sequence if isinstance(item, str)] expected_index = {kind: index for index, kind in enumerate(expected)} # Prefer HandoffManifest.v0 files for sequence ordering; legacy handoff_to_*.json @@ -151,7 +307,6 @@ def validate_workflow_profile_declarations( ) continue manifest_kinds.append(kind) - # Relative order of first occurrences must follow the profile sequence. seen: list[str] = [] for kind in manifest_kinds: if kind not in seen: @@ -201,25 +356,49 @@ def validate_workflow_profile_declarations( return profile -def _read_field(doc: dict[str, Any], dotted_or_simple: str) -> Any: - if "." not in dotted_or_simple and "[" not in dotted_or_simple: - return doc.get(dotted_or_simple) - if dotted_or_simple == "certificates[0].certificate_id": - certs = doc.get("certificates") - if isinstance(certs, list) and certs and isinstance(certs[0], dict): - return certs[0].get("certificate_id") - return None - if dotted_or_simple.startswith("verified_input."): - verified = doc.get("verified_input") - if isinstance(verified, dict): - return verified.get(dotted_or_simple.split(".", 1)[1]) - return None - current: Any = doc - for part in dotted_or_simple.split("."): - if not isinstance(current, dict): - return None - current = current.get(part) - return current +def _repo_matches(repo: str, expected_repo: str) -> bool: + return expected_repo.lower() in repo.lower() + + +def _iter_provenance_pairs(obj: Any) -> Any: + if isinstance(obj, dict): + repo = obj.get("source_repo") + commit = obj.get("source_commit") + if isinstance(repo, str) and isinstance(commit, str): + yield repo, commit + for value in obj.values(): + yield from _iter_provenance_pairs(value) + elif isinstance(obj, list): + for item in obj: + yield from _iter_provenance_pairs(item) + + +def _default_semantic_issue_code(artifact: str, message: str) -> str: + if "missing_code_commit" in message or ( + "zero" in message and "commit" in message and "computation" in artifact + ): + return "missing_code_commit" + if "exit_code" in message: + return "nonzero_exit_code" + return "schema_validation_failed" + + +def _default_alignment_issue_code(message: str) -> str: + if "policy_hash" in message: + return "policy_hash_mismatch" + if "dataset_hash" in message: + return "dataset_hash_mismatch" + if "environment_hash" in message: + return "environment_hash_mismatch" + if "result_hashes" in message or "result_hash" in message: + return "result_hash_mismatch" + if "run_receipt_hash" in message: + return "run_receipt_hash_mismatch" + if "nonzero_exit_code" in message or "exit_code" in message: + return "nonzero_exit_code" + if "code_commit" in message: + return "missing_code_commit" + return "trace_hash_mismatch" def run_structural_release_profile_validation( @@ -283,26 +462,47 @@ def run_structural_release_profile_validation( issues.append(_issue("schema_validation_failed", "manifest artifacts must be an object")) return issues + required = set(spec.required_artifacts) + optional = set(spec.optional_artifacts) + allowed = required | optional + present_keys = set(artifacts) + if spec.require_exact_manifest_artifact_set: - if set(artifacts) != set(spec.manifest_artifacts): - missing = sorted(set(spec.manifest_artifacts) - set(artifacts)) - extra = sorted(set(artifacts) - set(spec.manifest_artifacts)) - if missing: - issues.append( - _issue( - "schema_validation_failed", - f"manifest artifacts missing keys: {missing}", - ), - ) - if extra: - issues.append( - _issue( - "schema_validation_failed", - f"manifest artifacts unexpected keys: {extra}", - ), - ) + if optional: + missing = sorted(required - present_keys) + unexpected = sorted(present_keys - allowed) + else: + missing = sorted(required - present_keys) + unexpected = sorted(present_keys - required) + if missing: + issues.append( + _issue( + "schema_validation_failed", + f"manifest artifacts missing keys: {missing}", + ), + ) + if unexpected: + issues.append( + _issue( + "schema_validation_failed", + f"manifest artifacts unexpected keys: {unexpected}", + ), + ) + else: + missing = sorted(required - present_keys) + if missing: + issues.append( + _issue( + "schema_validation_failed", + f"manifest artifacts missing keys: {missing}", + ), + ) - for name in spec.manifest_artifacts: + check_names = sorted(present_keys & allowed) if optional else list(spec.required_artifacts) + if not optional and spec.require_exact_manifest_artifact_set: + check_names = list(spec.required_artifacts) + + for name in check_names: path = base / name if not path.is_file(): issues.append(_issue("artifact_missing", f"missing artifact file {name}")) @@ -320,8 +520,17 @@ def run_structural_release_profile_validation( ), ) + # Required artifacts not listed above (non-exact mode) still need presence checks. + for name in spec.required_artifacts: + if name in check_names: + continue + path = base / name + if not path.is_file(): + issues.append(_issue("artifact_missing", f"missing artifact file {name}")) + scan_errors: list[str] = [] - for name in spec.manifest_artifacts: + docs_to_scan = list(dict.fromkeys([*spec.required_artifacts, *check_names])) + for name in docs_to_scan: path = base / name if not path.is_file(): continue @@ -333,8 +542,9 @@ def run_structural_release_profile_validation( continue validator = spec.semantic_validators.get(name) if validator is not None: + mapper = spec.semantic_issue_mapper or _default_semantic_issue_code for msg in validator(doc): - issues.append(_issue("schema_validation_failed", msg, artifact=name)) + issues.append(_issue(mapper(name, msg), msg, artifact=name)) _scan_forbidden_values(doc, label=name, errors=scan_errors) for msg in scan_errors: artifact = msg.split(":", 1)[0] if ":" in msg else None @@ -348,7 +558,7 @@ def run_structural_release_profile_validation( if spec.alignment_checker is not None: alignment_errors: list[str] = [] spec.alignment_checker(base, alignment_errors) - mapper = spec.alignment_issue_mapper or (lambda _msg: "trace_hash_mismatch") + mapper = spec.alignment_issue_mapper or _default_alignment_issue_code for msg in alignment_errors: issues.append(_issue(mapper(msg), msg)) @@ -367,6 +577,17 @@ def run_structural_release_profile_validation( ), ) + if spec.handoff_require_complete: + for handoff_name in spec.handoff_files: + if not (base / handoff_name).is_file(): + issues.append( + _issue( + "artifact_missing", + f"missing required handoff file {handoff_name}", + artifact=handoff_name, + ), + ) + for handoff_name in spec.handoff_files: handoff_path = base / handoff_name if handoff_path.is_file(): @@ -385,15 +606,27 @@ def run_structural_release_profile_validation( validate_workflow_profile_declarations(base, spec, issues) for requirement in spec.status_requirements: - doc = _load_json(base / requirement.artifact) + path = base / requirement.artifact + if not path.is_file(): + if requirement.skip_if_artifact_missing: + continue + issues.append( + _issue( + requirement.issue_code, + f"{requirement.artifact} missing for status requirement", + artifact=requirement.artifact, + ), + ) + continue + doc = _load_json(path) if not isinstance(doc, dict): continue - actual = _read_field(doc, requirement.field) + actual = resolve_json_pointer(doc, requirement.pointer) if actual != requirement.required_value: issues.append( _issue( requirement.issue_code, - f"{requirement.artifact}.{requirement.field} must be " + f"{requirement.artifact}{requirement.pointer} must be " f"{requirement.required_value!r} (got {actual!r})", artifact=requirement.artifact, expected=requirement.required_value, @@ -401,41 +634,278 @@ def run_structural_release_profile_validation( ), ) + for requirement in spec.source_commit_requirements: + expected = commits.get(requirement.manifest_commit_key) + if not isinstance(expected, str): + continue + doc = _load_json(base / requirement.artifact) + if not isinstance(doc, dict): + continue + actual = resolve_json_pointer(doc, requirement.pointer) + if actual != expected: + issues.append( + _issue( + requirement.issue_code, + f"{requirement.artifact}{requirement.pointer} {actual!r} " + f"!= manifest.{requirement.manifest_commit_key} {expected}", + artifact=requirement.artifact, + expected=expected, + actual=actual, + ), + ) + + for requirement in spec.provenance_commit_requirements: + expected = commits.get(requirement.manifest_commit_key) + if not isinstance(expected, str): + continue + doc = _load_json(base / requirement.artifact) + if not isinstance(doc, dict): + continue + root = ( + resolve_json_pointer(doc, requirement.nested_root_pointer) + if requirement.nested_root_pointer + else doc + ) + if root is None: + continue + for repo, commit in _iter_provenance_pairs(root): + if _repo_matches(repo, requirement.expected_repo) and commit != expected: + issues.append( + _issue( + requirement.issue_code, + f"{requirement.artifact}: source_commit {commit} " + f"!= manifest.{requirement.manifest_commit_key} {expected}", + artifact=requirement.artifact, + expected=expected, + actual=commit, + ), + ) + for binding in spec.certificate_bindings: source = _load_json(base / binding.source_artifact) target = _load_json(base / binding.target_artifact) - if not isinstance(source, dict) or not isinstance(target, dict): + if not isinstance(source, dict): continue - expected = _read_field(source, binding.source_field) + expected = resolve_json_pointer(source, binding.source_pointer) if not isinstance(expected, str) or not expected: + if binding.require_source: + continue + continue + if not isinstance(target, dict): + issues.append( + _issue( + binding.issue_code, + f"{binding.target_artifact}{binding.target_pointer}: " + f"certificate ID is required", + artifact=binding.target_artifact, + expected=expected, + ), + ) continue if binding.mode == "equals": - actual = _read_field(target, binding.target_field) - if actual != expected: + actual = resolve_json_pointer(target, binding.target_pointer) + if actual is None: + issues.append( + _issue( + binding.issue_code, + f"{binding.target_artifact}{binding.target_pointer}: " + f"certificate ID is required", + artifact=binding.target_artifact, + expected=expected, + ), + ) + elif actual != expected: issues.append( _issue( - "certificate_id_mismatch", - f"{binding.target_artifact}.{binding.target_field}: " + binding.issue_code, + f"{binding.target_artifact}{binding.target_pointer}: " f"expected {expected!r}, got {actual!r}", artifact=binding.target_artifact, expected=expected, actual=actual, ), ) - elif binding.mode == "ref_contains": - from pcs_core.release_chain import _certificate_ref_contains - - if not _certificate_ref_contains(target, binding.target_field, expected): + elif binding.mode == "array_contains": + refs = resolve_json_pointer(target, binding.target_pointer) + if not isinstance(refs, list) or expected not in refs: issues.append( _issue( - "certificate_id_mismatch", - f"{binding.target_artifact}.{binding.target_field}.certificate_refs " + binding.issue_code, + f"{binding.target_artifact}{binding.target_pointer} " f"must contain {expected!r}", artifact=binding.target_artifact, expected=expected, ), ) + for requirement in spec.import_requirements: + doc = _load_json(base / requirement.artifact) + if not isinstance(doc, dict): + continue + actual = resolve_json_pointer(doc, requirement.pointer) + if actual != requirement.required_value: + issues.append( + _issue( + requirement.issue_code, + f"{requirement.artifact}{requirement.pointer} must be " + f"{requirement.required_value!r} (got {actual!r})", + artifact=requirement.artifact, + expected=requirement.required_value, + actual=actual, + ), + ) + + for requirement in spec.proof_requirements: + doc = _load_json(base / requirement.artifact) + if not isinstance(doc, dict): + issues.append( + _issue( + requirement.issue_code, + requirement.message + or f"{requirement.artifact}{requirement.pointer} is required", + artifact=requirement.artifact, + ), + ) + continue + actual = resolve_json_pointer(doc, requirement.pointer) + if actual is None or actual == "" or actual == {}: + issues.append( + _issue( + requirement.issue_code, + requirement.message + or f"{requirement.artifact}{requirement.pointer} is required", + artifact=requirement.artifact, + ), + ) + + bundle_identity = resolve_certified_bundle_identity_hash( + base, + manifest_artifacts=artifacts if isinstance(artifacts, dict) else None, + ) + for binding in spec.bundle_identity_bindings: + if not bundle_identity: + continue + doc = _load_json(base / binding.artifact) + if not isinstance(doc, dict): + continue + # When the pointer is nested, skip if the parent object is absent so + # companion proof_requirements own the "missing parent" issue code. + parent_pointer, _, _leaf = binding.pointer.rpartition("/") + if parent_pointer: + parent = resolve_json_pointer(doc, parent_pointer) + if not isinstance(parent, dict): + continue + actual = resolve_json_pointer(doc, binding.pointer) + if not actual: + if binding.require_present: + issues.append( + _issue( + binding.missing_issue_code or binding.issue_code, + f"{binding.artifact}{binding.pointer} is required", + artifact=binding.artifact, + ), + ) + continue + if actual != bundle_identity: + issues.append( + _issue( + binding.issue_code, + f"{binding.artifact}{binding.pointer} {actual} " + f"!= certified bundle identity hash {bundle_identity}", + artifact=binding.artifact, + expected=bundle_identity, + actual=actual, + ), + ) + + for binding in spec.payload_bindings: + doc = _load_json(base / binding.artifact) + if not isinstance(doc, dict): + continue + rel_path = resolve_json_pointer(doc, binding.path_pointer) + if not isinstance(rel_path, str) or not rel_path: + continue + from pcs_core.safe_paths import UnsafePathError, resolve_contained_file + + try: + payload_path = resolve_contained_file(base, rel_path) + except UnsafePathError as exc: + message = str(exc).lower() + if "does not resolve" in message or "not a regular file" in message: + code = binding.missing_issue_code + else: + code = "payload_path_unsafe" + issues.append( + _issue( + code, + f"{binding.artifact}{binding.path_pointer}: {exc}", + artifact=binding.artifact, + ), + ) + continue + payload_bytes = payload_path.read_bytes() + actual_digest = file_digest(payload_bytes) + expected_digest = resolve_json_pointer(doc, binding.digest_pointer) + if expected_digest != actual_digest: + issues.append( + _issue( + binding.issue_code, + f"{binding.artifact}{binding.digest_pointer}: expected {expected_digest}, " + f"got {actual_digest}", + artifact=binding.artifact, + expected=expected_digest, + actual=actual_digest, + ), + ) + if binding.size_pointer: + expected_size = resolve_json_pointer(doc, binding.size_pointer) + actual_size = len(payload_bytes) + if expected_size != actual_size: + issues.append( + _issue( + binding.size_issue_code, + f"{binding.artifact}{binding.size_pointer}: expected {expected_size}, " + f"got {actual_size}", + artifact=binding.artifact, + expected=expected_size, + actual=actual_size, + ), + ) + + for requirement in spec.signature_requirements: + doc = _load_json(base / requirement.artifact) + if not isinstance(doc, dict): + continue + actual = resolve_json_pointer(doc, requirement.pointer) + if actual is None or actual == "": + issues.append( + _issue( + requirement.issue_code, + f"{requirement.artifact}{requirement.pointer} signature is required", + artifact=requirement.artifact, + ), + ) + + for ban in spec.array_element_bans: + doc = _load_json(base / ban.artifact) + if not isinstance(doc, dict): + continue + array = resolve_json_pointer(doc, ban.array_pointer) + if not isinstance(array, list): + continue + for index, element in enumerate(array): + if not isinstance(element, dict): + continue + value = resolve_json_pointer(element, ban.element_pointer) + if value == ban.banned_value: + message = ban.message_template or ( + f"{ban.artifact}{ban.array_pointer}/{index}{ban.element_pointer} " + f"is {ban.banned_value!r}" + ) + if ban.message_template and "{index}" in ban.message_template: + message = ban.message_template.format(index=index) + issues.append(_issue(ban.issue_code, message, artifact=ban.artifact)) + if spec.domain_checks is not None: spec.domain_checks(base, manifest, commits, issues) @@ -448,8 +918,9 @@ def run_release_profile_validation( ) -> list[ReleaseChainIssue]: """Run release-profile validation for ``spec``. - When ``legacy_validator`` is set, that body remains the source of truth for - parity; WorkflowProfile declaration checks are still applied on top. + When ``legacy_validator`` is set (parity harness only), that body remains the + comparison source of truth. Production profiles leave it unset and run the + declarative pipeline alone. """ base = directory.resolve() if spec.legacy_validator is not None: @@ -461,18 +932,30 @@ def run_release_profile_validation( def validate_release_directory(directory: Path) -> list[ReleaseChainIssue]: - """Detect profile and run the declarative engine (LabTrust default).""" + """Detect profile and run the declarative engine; unknown profiles fail closed.""" # Ensure specs are registered. import pcs_core.release_profile_specs # noqa: F401 - spec = resolve_release_profile(directory) - if spec is None: - spec = get_release_profile(LABTRUST_WORKFLOW_PROFILE_ID) + base = directory.resolve() + workflow_id = detect_workflow_profile_id(base) + if workflow_id is None: + if not (base / MANIFEST_NAME).is_file(): + return [ + _issue("manifest_missing", f"{MANIFEST_NAME} not found in {base}"), + ] + return [ + _issue( + UNKNOWN_WORKFLOW_PROFILE, + "unable to detect workflow profile for release directory", + ), + ] + spec = get_release_profile(workflow_id) if spec is None: return [ _issue( - "schema_validation_failed", - "no release profile registered for directory", + UNKNOWN_WORKFLOW_PROFILE, + f"unknown workflow profile {workflow_id!r}", + actual=workflow_id, ), ] - return run_release_profile_validation(directory, spec) + return run_release_profile_validation(base, spec) diff --git a/python/pcs_core/release_profile_specs.py b/python/pcs_core/release_profile_specs.py index 6c89a5b..2e67eaa 100644 --- a/python/pcs_core/release_profile_specs.py +++ b/python/pcs_core/release_profile_specs.py @@ -2,19 +2,57 @@ Compatibility wrappers in ``release_chain``, ``tool_use_release_chain``, and ``computation_release_chain`` delegate to the engine with these specs. Domain -validator bodies remain attached via ``legacy_validator`` until structural -parity is fully proven against the declarative pipeline alone. +logic is expressed as declarative bindings (JSON Pointer) plus optional +semantic/alignment hooks — not separate release-chain validator modules. """ from __future__ import annotations from pathlib import Path +from typing import Any from pcs_core.computation_release_chain import ( COMPUTATION_COMMIT_KEYS, COMPUTATION_HANDOFF_FILES, COMPUTATION_MANIFEST_ARTIFACTS, COMPUTATION_RELEASE_PCS_ARTIFACTS, + _validate_computation_alignment, + _validate_computation_scientific_memory_report, +) +from pcs_core.computation_validate import ( + COMPUTATION_RUN_RECEIPT_FILE, + COMPUTATION_WITNESS_FILE, + DATASET_RECEIPT_FILE, + DUPLICATE_RESULT_DECLARATION, + ENVIRONMENT_RECEIPT_FILE, + PAYLOAD_DIGEST_MISMATCH, + PAYLOAD_MISSING, + PAYLOAD_PATH_UNSAFE, + PAYLOAD_SIZE_MISMATCH, + RESULT_ARTIFACT_FILE, + validate_computation_run_receipt_semantics, + validate_computation_witness_semantics, + validate_dataset_receipt_semantics, + validate_environment_receipt_semantics, + validate_result_artifact_semantics, + validate_result_payloads_in_release, +) +from pcs_core.release_profile_engine import ( + ArrayElementBan, + BundleIdentityBinding, + CertificateIdBinding, + ImportRequirement, + ProofRequirement, + ProvenanceCommitRequirement, + ReleaseProfileSpec, + SignatureRequirement, + SourceCommitRequirement, + StatusRequirement, + register_release_profile, +) +from pcs_core.release_chain import ( + _validate_scientific_memory_report_json, + _validate_trace_json, ) from pcs_core.release_chain_profiles import ( COMPUTATION_WORKFLOW_PROFILE_ID, @@ -22,17 +60,24 @@ TOOL_USE_WORKFLOW_PROFILE_ID, ) from pcs_core.release_fixtures import ( + CERTIFYEDGE_SOURCE_REPO, COMMIT_KEYS, + LABTRUST_SOURCE_REPO, MANIFEST_ARTIFACTS, + PF_SOURCE_REPO, RELEASE_PCS_ARTIFACTS, + _validate_trace_hash_alignment, ) -from pcs_core.release_profile_engine import ReleaseProfileSpec, register_release_profile from pcs_core.tool_use_release_chain import ( TOOL_USE_COMMIT_KEYS, TOOL_USE_HANDOFF_FILES, TOOL_USE_MANIFEST_ARTIFACTS, TOOL_USE_RELEASE_PCS_ARTIFACTS, + _validate_tool_use_scientific_memory_report, + _validate_tool_use_trace_hash_alignment, + _validate_tool_use_trace_json, ) +from pcs_core.validate import ValidationError, validate_file LABTRUST_HANDOFF_FILES = ( "handoff_to_certifyedge.json", @@ -44,54 +89,613 @@ ) -def _labtrust_legacy(directory: Path): +def labtrust_legacy_validator(directory: Path): + """Parity harness: LabTrust legacy body (not used in production path).""" from pcs_core.release_chain import _validate_labtrust_release_chain_impl return _validate_labtrust_release_chain_impl(directory) -def _tool_use_legacy(directory: Path): +def tool_use_legacy_validator(directory: Path): + """Parity harness: tool-use legacy body (not used in production path).""" from pcs_core.tool_use_release_chain import _validate_tool_use_release_chain_impl return _validate_tool_use_release_chain_impl(directory) -def _computation_legacy(directory: Path): +def computation_legacy_validator(directory: Path): + """Parity harness: computation legacy body (not used in production path).""" from pcs_core.computation_release_chain import _validate_computation_release_chain_impl return _validate_computation_release_chain_impl(directory) +def _computation_semantic_issue_code(artifact: str, message: str) -> str: + if artifact == COMPUTATION_RUN_RECEIPT_FILE: + if "missing_code_commit" in message or "zero" in message: + return "missing_code_commit" + if "exit_code" in message: + return "nonzero_exit_code" + return "schema_validation_failed" + + +def _tool_use_alignment_issue_code(message: str) -> str: + if "policy_hash" in message: + return "policy_hash_mismatch" + return "trace_hash_mismatch" + + +def _computation_alignment_issue_code(message: str) -> str: + if "dataset_hash" in message: + return "dataset_hash_mismatch" + if "environment_hash" in message: + return "environment_hash_mismatch" + if "result_hashes" in message: + return "result_hash_mismatch" + if "run_receipt_hash" in message: + return "run_receipt_hash_mismatch" + if "nonzero_exit_code" in message or "exit_code" in message: + return "nonzero_exit_code" + if "code_commit" in message: + return "missing_code_commit" + return "schema_validation_failed" + + +def _tool_use_domain_checks( + base: Path, + _manifest: dict[str, Any], + _commits: dict[str, Any], + issues: list, +) -> None: + from pcs_core.release_chain import _issue + from pcs_core.release_fixtures import _load_json + + manifest_v0 = _load_json(base / "release_manifest.v0.json") + if manifest_v0: + if manifest_v0.get("workflow_profile_id") != TOOL_USE_WORKFLOW_PROFILE_ID: + issues.append( + _issue( + "schema_validation_failed", + "release_manifest.v0.json workflow_profile_id must match tool-use profile", + ), + ) + try: + validate_file(base / "release_manifest.v0.json") + except ValidationError as exc: + issues.append( + _issue( + "schema_validation_failed", + f"release_manifest.v0.json: pcs validate failed: {exc}", + artifact="release_manifest.v0.json", + ), + ) + + +def _computation_payload_issue_code(message: str) -> str: + if DUPLICATE_RESULT_DECLARATION in message: + return DUPLICATE_RESULT_DECLARATION + if PAYLOAD_DIGEST_MISMATCH in message: + return PAYLOAD_DIGEST_MISMATCH + if PAYLOAD_SIZE_MISMATCH in message: + return PAYLOAD_SIZE_MISMATCH + if PAYLOAD_PATH_UNSAFE in message: + return PAYLOAD_PATH_UNSAFE + if PAYLOAD_MISSING in message: + return PAYLOAD_MISSING + return "schema_validation_failed" + + +def _computation_domain_checks( + base: Path, + _manifest: dict[str, Any], + _commits: dict[str, Any], + issues: list, +) -> None: + from pcs_core.release_chain import _issue + from pcs_core.release_fixtures import _load_json + + witness = _load_json(base / COMPUTATION_WITNESS_FILE) + if witness and witness.get("status") != "CertificateChecked": + if witness.get("status") == "Rejected": + issues.append( + _issue( + "rejected_computation_witness", + "computation_witness.status is Rejected", + ), + ) + else: + issues.append( + _issue( + "schema_validation_failed", + "computation_witness.status must be CertificateChecked", + ), + ) + + for msg in validate_result_payloads_in_release(base): + issues.append( + _issue( + _computation_payload_issue_code(msg), + msg, + artifact=RESULT_ARTIFACT_FILE, + ), + ) + + manifest_v0 = _load_json(base / "release_manifest.v0.json") + if manifest_v0: + if manifest_v0.get("workflow_profile_id") != COMPUTATION_WORKFLOW_PROFILE_ID: + issues.append( + _issue( + "schema_validation_failed", + "release_manifest.v0.json workflow_profile_id must match computation profile", + ), + ) + try: + validate_file(base / "release_manifest.v0.json") + except ValidationError as exc: + issues.append( + _issue( + "schema_validation_failed", + f"release_manifest.v0.json: pcs validate failed: {exc}", + artifact="release_manifest.v0.json", + ), + ) + + LABTRUST_RELEASE_PROFILE = register_release_profile( ReleaseProfileSpec( workflow_profile_id=LABTRUST_WORKFLOW_PROFILE_ID, - manifest_artifacts=MANIFEST_ARTIFACTS, + required_artifacts=MANIFEST_ARTIFACTS, release_pcs_artifacts=RELEASE_PCS_ARTIFACTS, handoff_files=LABTRUST_HANDOFF_FILES, commit_keys=COMMIT_KEYS, enforce_manifest_workflow_id=False, - legacy_validator=_labtrust_legacy, + handoff_require_complete=False, + handoff_enforce_order=True, + semantic_validators={ + "trace.json": _validate_trace_json, + "scientific_memory_import_report.json": _validate_scientific_memory_report_json, + }, + alignment_checker=_validate_trace_hash_alignment, + alignment_issue_mapper=lambda _msg: "trace_hash_mismatch", + status_requirements=( + StatusRequirement( + artifact="verification_result.json", + pointer="/status", + required_value="ProofChecked", + issue_code="schema_validation_failed", + ), + StatusRequirement( + artifact="trace_certificate.json", + pointer="/status", + required_value="CertificateChecked", + issue_code="schema_validation_failed", + ), + StatusRequirement( + artifact="signed_science_claim_bundle.json", + pointer="/verification_result/status", + required_value="ProofChecked", + issue_code="schema_validation_failed", + ), + ), + source_commit_requirements=( + SourceCommitRequirement( + artifact="runtime_receipt.json", + pointer="/source_commit", + manifest_commit_key="labtrust_gym_commit", + issue_code="labtrust_commit_mismatch", + ), + SourceCommitRequirement( + artifact="trace_certificate.json", + pointer="/source_commit", + manifest_commit_key="certifyedge_commit", + issue_code="certifyedge_commit_mismatch", + ), + SourceCommitRequirement( + artifact="verification_result.json", + pointer="/source_commit", + manifest_commit_key="provability_fabric_commit", + issue_code="pf_commit_mismatch", + ), + SourceCommitRequirement( + artifact="signed_science_claim_bundle.json", + pointer="/source_commit", + manifest_commit_key="provability_fabric_commit", + issue_code="pf_commit_mismatch", + ), + SourceCommitRequirement( + artifact="scientific_memory_import_report.json", + pointer="/source_commit", + manifest_commit_key="scientific_memory_commit", + issue_code="scientific_memory_commit_mismatch", + ), + SourceCommitRequirement( + artifact="scientific_memory_import_report.json", + pointer="/scientific_memory_commit", + manifest_commit_key="scientific_memory_commit", + issue_code="scientific_memory_commit_mismatch", + ), + ), + provenance_commit_requirements=( + ProvenanceCommitRequirement( + artifact="science_claim_bundle.pending.json", + expected_repo=LABTRUST_SOURCE_REPO, + manifest_commit_key="labtrust_gym_commit", + issue_code="labtrust_commit_mismatch", + ), + ProvenanceCommitRequirement( + artifact="science_claim_bundle.certified.json", + expected_repo=LABTRUST_SOURCE_REPO, + manifest_commit_key="labtrust_gym_commit", + issue_code="labtrust_commit_mismatch", + ), + ProvenanceCommitRequirement( + artifact="signed_science_claim_bundle.json", + expected_repo=LABTRUST_SOURCE_REPO, + manifest_commit_key="labtrust_gym_commit", + issue_code="labtrust_commit_mismatch", + nested_root_pointer="/science_claim_bundle", + ), + ProvenanceCommitRequirement( + artifact="science_claim_bundle.certified.json", + expected_repo=CERTIFYEDGE_SOURCE_REPO, + manifest_commit_key="certifyedge_commit", + issue_code="certifyedge_commit_mismatch", + ), + ProvenanceCommitRequirement( + artifact="signed_science_claim_bundle.json", + expected_repo=CERTIFYEDGE_SOURCE_REPO, + manifest_commit_key="certifyedge_commit", + issue_code="certifyedge_commit_mismatch", + ), + ProvenanceCommitRequirement( + artifact="verification_result.json", + expected_repo=PF_SOURCE_REPO, + manifest_commit_key="provability_fabric_commit", + issue_code="pf_commit_mismatch", + ), + ProvenanceCommitRequirement( + artifact="signed_science_claim_bundle.json", + expected_repo=PF_SOURCE_REPO, + manifest_commit_key="provability_fabric_commit", + issue_code="pf_commit_mismatch", + ), + ), + certificate_bindings=( + CertificateIdBinding( + source_artifact="trace_certificate.json", + source_pointer="/certificate_id", + target_artifact="science_claim_bundle.certified.json", + target_pointer="/certificates/0/certificate_id", + ), + CertificateIdBinding( + source_artifact="trace_certificate.json", + source_pointer="/certificate_id", + target_artifact="science_claim_bundle.certified.json", + target_pointer="/claim_artifact/certificate_refs", + mode="array_contains", + ), + CertificateIdBinding( + source_artifact="trace_certificate.json", + source_pointer="/certificate_id", + target_artifact="science_claim_bundle.certified.json", + target_pointer="/evidence_bundle/certificate_refs", + mode="array_contains", + ), + CertificateIdBinding( + source_artifact="trace_certificate.json", + source_pointer="/certificate_id", + target_artifact="verification_result.json", + target_pointer="/verified_input/certificate_id", + ), + CertificateIdBinding( + source_artifact="trace_certificate.json", + source_pointer="/certificate_id", + target_artifact="signed_science_claim_bundle.json", + target_pointer="/science_claim_bundle/certificates/0/certificate_id", + ), + ), + bundle_identity_bindings=( + BundleIdentityBinding( + artifact="verification_result.json", + pointer="/verified_input/bundle_hash", + issue_code="verified_input_hash_mismatch", + require_present=True, + ), + BundleIdentityBinding( + artifact="signed_science_claim_bundle.json", + pointer="/signed_input_bundle_hash", + issue_code="signed_input_hash_mismatch", + require_present=True, + ), + ), + import_requirements=( + ImportRequirement( + artifact="scientific_memory_import_report.json", + pointer="/verification_status", + required_value="passed", + issue_code="scientific_memory_import_failed", + ), + ImportRequirement( + artifact="scientific_memory_import_report.json", + pointer="/strict", + required_value=True, + issue_code="scientific_memory_import_failed", + ), + ImportRequirement( + artifact="scientific_memory_import_report.json", + pointer="/allow_legacy", + required_value=False, + issue_code="legacy_import_detected", + ), + ImportRequirement( + artifact="scientific_memory_import_report.json", + pointer="/bundle_shape", + required_value="pcs_core", + issue_code="legacy_import_detected", + ), + ), + proof_requirements=( + ProofRequirement( + artifact="verification_result.json", + pointer="/verified_input", + issue_code="schema_validation_failed", + message="verification_result.verified_input is required for release chain fixtures", + ), + ), + signature_requirements=( + SignatureRequirement( + artifact="signed_science_claim_bundle.json", + pointer="/signature_or_digest", + ), + ), ), ) TOOL_USE_RELEASE_PROFILE = register_release_profile( ReleaseProfileSpec( workflow_profile_id=TOOL_USE_WORKFLOW_PROFILE_ID, - manifest_artifacts=TOOL_USE_MANIFEST_ARTIFACTS, + required_artifacts=TOOL_USE_MANIFEST_ARTIFACTS, release_pcs_artifacts=TOOL_USE_RELEASE_PCS_ARTIFACTS, handoff_files=TOOL_USE_HANDOFF_FILES, commit_keys=TOOL_USE_COMMIT_KEYS, - legacy_validator=_tool_use_legacy, + handoff_require_complete=False, + handoff_enforce_order=True, + semantic_validators={ + "tool_use_trace.valid.json": _validate_tool_use_trace_json, + "tool_use_trace.json": _validate_tool_use_trace_json, + "scientific_memory_import_report.json": _validate_tool_use_scientific_memory_report, + }, + alignment_checker=_validate_tool_use_trace_hash_alignment, + alignment_issue_mapper=_tool_use_alignment_issue_code, + status_requirements=( + StatusRequirement( + artifact="verification_result.json", + pointer="/status", + required_value="ProofChecked", + issue_code="schema_validation_failed", + ), + StatusRequirement( + artifact="tool_use_certificate.valid.json", + pointer="/status", + required_value="CertificateChecked", + issue_code="rejected_certificate", + ), + ), + source_commit_requirements=( + SourceCommitRequirement( + artifact="tool_use_trace.valid.json", + pointer="/source_commit", + manifest_commit_key="agent_runtime_commit", + issue_code="agent_runtime_commit_mismatch", + ), + SourceCommitRequirement( + artifact="runtime_receipt.json", + pointer="/source_commit", + manifest_commit_key="agent_runtime_commit", + issue_code="agent_runtime_commit_mismatch", + ), + SourceCommitRequirement( + artifact="tool_use_certificate.valid.json", + pointer="/source_commit", + manifest_commit_key="certifyedge_commit", + issue_code="certifyedge_commit_mismatch", + ), + SourceCommitRequirement( + artifact="verification_result.json", + pointer="/source_commit", + manifest_commit_key="provability_fabric_commit", + issue_code="pf_commit_mismatch", + ), + SourceCommitRequirement( + artifact="signed_science_claim_bundle.json", + pointer="/source_commit", + manifest_commit_key="provability_fabric_commit", + issue_code="pf_commit_mismatch", + ), + SourceCommitRequirement( + artifact="scientific_memory_import_report.json", + pointer="/source_commit", + manifest_commit_key="scientific_memory_commit", + issue_code="scientific_memory_commit_mismatch", + ), + ), + certificate_bindings=( + CertificateIdBinding( + source_artifact="tool_use_certificate.valid.json", + source_pointer="/certificate_id", + target_artifact="science_claim_bundle.certified.json", + target_pointer="/certificates/0/certificate_id", + ), + CertificateIdBinding( + source_artifact="tool_use_certificate.valid.json", + source_pointer="/certificate_id", + target_artifact="science_claim_bundle.certified.json", + target_pointer="/claim_artifact/certificate_refs", + mode="array_contains", + ), + ), + bundle_identity_bindings=( + BundleIdentityBinding( + artifact="verification_result.json", + pointer="/verified_input/bundle_hash", + issue_code="verified_input_hash_mismatch", + require_present=False, + ), + ), + import_requirements=( + ImportRequirement( + artifact="scientific_memory_import_report.json", + pointer="/verification_status", + required_value="passed", + issue_code="scientific_memory_import_failed", + ), + ), + array_element_bans=( + ArrayElementBan( + artifact="tool_use_trace.valid.json", + array_pointer="/tool_calls", + element_pointer="/authorization_status", + banned_value="rejected", + issue_code="unauthorized_tool_call", + message_template="tool_calls[{index}].authorization_status is rejected", + ), + ), + signature_requirements=( + SignatureRequirement( + artifact="signed_science_claim_bundle.json", + pointer="/signature_or_digest", + ), + ), + domain_checks=_tool_use_domain_checks, ), ) COMPUTATION_RELEASE_PROFILE = register_release_profile( ReleaseProfileSpec( workflow_profile_id=COMPUTATION_WORKFLOW_PROFILE_ID, - manifest_artifacts=COMPUTATION_MANIFEST_ARTIFACTS, + required_artifacts=COMPUTATION_MANIFEST_ARTIFACTS, release_pcs_artifacts=COMPUTATION_RELEASE_PCS_ARTIFACTS, handoff_files=COMPUTATION_HANDOFF_FILES, commit_keys=COMPUTATION_COMMIT_KEYS, - legacy_validator=_computation_legacy, + handoff_require_complete=False, + handoff_enforce_order=True, + semantic_validators={ + DATASET_RECEIPT_FILE: validate_dataset_receipt_semantics, + ENVIRONMENT_RECEIPT_FILE: validate_environment_receipt_semantics, + COMPUTATION_RUN_RECEIPT_FILE: validate_computation_run_receipt_semantics, + RESULT_ARTIFACT_FILE: validate_result_artifact_semantics, + COMPUTATION_WITNESS_FILE: validate_computation_witness_semantics, + "scientific_memory_import_report.json": _validate_computation_scientific_memory_report, + }, + semantic_issue_mapper=_computation_semantic_issue_code, + alignment_checker=_validate_computation_alignment, + alignment_issue_mapper=_computation_alignment_issue_code, + status_requirements=( + StatusRequirement( + artifact="verification_result.json", + pointer="/status", + required_value="ProofChecked", + issue_code="schema_validation_failed", + ), + ), + source_commit_requirements=( + SourceCommitRequirement( + artifact=DATASET_RECEIPT_FILE, + pointer="/source_commit", + manifest_commit_key="scientific_computation_commit", + issue_code="scientific_computation_commit_mismatch", + ), + SourceCommitRequirement( + artifact=ENVIRONMENT_RECEIPT_FILE, + pointer="/source_commit", + manifest_commit_key="scientific_computation_commit", + issue_code="scientific_computation_commit_mismatch", + ), + SourceCommitRequirement( + artifact=COMPUTATION_RUN_RECEIPT_FILE, + pointer="/source_commit", + manifest_commit_key="scientific_computation_commit", + issue_code="scientific_computation_commit_mismatch", + ), + SourceCommitRequirement( + artifact=RESULT_ARTIFACT_FILE, + pointer="/source_commit", + manifest_commit_key="scientific_computation_commit", + issue_code="scientific_computation_commit_mismatch", + ), + SourceCommitRequirement( + artifact=COMPUTATION_WITNESS_FILE, + pointer="/source_commit", + manifest_commit_key="certifyedge_commit", + issue_code="certifyedge_commit_mismatch", + ), + SourceCommitRequirement( + artifact="verification_result.json", + pointer="/source_commit", + manifest_commit_key="provability_fabric_commit", + issue_code="pf_commit_mismatch", + ), + SourceCommitRequirement( + artifact="signed_science_claim_bundle.json", + pointer="/source_commit", + manifest_commit_key="provability_fabric_commit", + issue_code="pf_commit_mismatch", + ), + SourceCommitRequirement( + artifact="scientific_memory_import_report.json", + pointer="/source_commit", + manifest_commit_key="scientific_memory_commit", + issue_code="scientific_memory_commit_mismatch", + ), + ), + certificate_bindings=( + CertificateIdBinding( + source_artifact=COMPUTATION_WITNESS_FILE, + source_pointer="/witness_id", + target_artifact="science_claim_bundle.certified.json", + target_pointer="/certificates/0/certificate_id", + ), + CertificateIdBinding( + source_artifact=COMPUTATION_WITNESS_FILE, + source_pointer="/witness_id", + target_artifact="science_claim_bundle.certified.json", + target_pointer="/claim_artifact/certificate_refs", + mode="array_contains", + ), + ), + bundle_identity_bindings=( + BundleIdentityBinding( + artifact="verification_result.json", + pointer="/verified_input/bundle_hash", + issue_code="verified_input_hash_mismatch", + require_present=False, + ), + ), + import_requirements=( + ImportRequirement( + artifact="scientific_memory_import_report.json", + pointer="/verification_status", + required_value="passed", + issue_code="scientific_memory_import_failed", + ), + ), + signature_requirements=( + SignatureRequirement( + artifact="signed_science_claim_bundle.json", + pointer="/signature_or_digest", + ), + ), + domain_checks=_computation_domain_checks, ), ) + + +def parity_profile_specs() -> tuple[ReleaseProfileSpec, ...]: + """Specs with legacy validators attached for side-by-side parity checks.""" + from dataclasses import replace + + return ( + replace(LABTRUST_RELEASE_PROFILE, legacy_validator=labtrust_legacy_validator), + replace(TOOL_USE_RELEASE_PROFILE, legacy_validator=tool_use_legacy_validator), + replace(COMPUTATION_RELEASE_PROFILE, legacy_validator=computation_legacy_validator), + ) diff --git a/python/tests/test_release_profile_engine.py b/python/tests/test_release_profile_engine.py new file mode 100644 index 0000000..49c9bb5 --- /dev/null +++ b/python/tests/test_release_profile_engine.py @@ -0,0 +1,177 @@ +"""Declarative release-profile engine: UnknownWorkflowProfile + legacy parity.""" + +from __future__ import annotations + +import json +from dataclasses import replace +from pathlib import Path + +import pytest + +from pcs_core.paths import examples_dir +from pcs_core.release_profile_engine import ( + UNKNOWN_WORKFLOW_PROFILE, + compare_legacy_and_declarative, + normalized_issue_codes, + resolve_json_pointer, + run_structural_release_profile_validation, + validate_release_directory, +) +from pcs_core.release_profile_specs import ( + COMPUTATION_RELEASE_PROFILE, + LABTRUST_RELEASE_PROFILE, + TOOL_USE_RELEASE_PROFILE, + computation_legacy_validator, + labtrust_legacy_validator, + parity_profile_specs, + tool_use_legacy_validator, +) + +LABTRUST = examples_dir() / "labtrust-release" +TOOL_USE = examples_dir() / "tool-use-release" +COMPUTATION = examples_dir() / "computation-release" +LABTRUST_INVALID = examples_dir() / "labtrust-release-invalid" +TOOL_USE_INVALID = examples_dir() / "tool-use-release-invalid" +COMPUTATION_INVALID = examples_dir() / "computation-release-invalid" + +_VALID_ROOTS = { + LABTRUST_RELEASE_PROFILE.workflow_profile_id: LABTRUST, + TOOL_USE_RELEASE_PROFILE.workflow_profile_id: TOOL_USE, + COMPUTATION_RELEASE_PROFILE.workflow_profile_id: COMPUTATION, +} + +_INVALID_ROOTS = ( + (LABTRUST_INVALID, labtrust_legacy_validator, LABTRUST_RELEASE_PROFILE), + (TOOL_USE_INVALID, tool_use_legacy_validator, TOOL_USE_RELEASE_PROFILE), + (COMPUTATION_INVALID, computation_legacy_validator, COMPUTATION_RELEASE_PROFILE), +) + + +def test_resolve_json_pointer_rfc6901() -> None: + doc = {"a": {"b": [{"c": 1}, {"c": 2}]}, "x/y": 3, "m~n": 4} + assert resolve_json_pointer(doc, "") is doc + assert resolve_json_pointer(doc, "/a/b/0/c") == 1 + assert resolve_json_pointer(doc, "/a/b/1/c") == 2 + assert resolve_json_pointer(doc, "/x~1y") == 3 + assert resolve_json_pointer(doc, "/m~0n") == 4 + assert resolve_json_pointer(doc, "/missing") is None + assert resolve_json_pointer(doc, "a/b") is None + + +def test_unknown_workflow_profile_when_id_not_registered(tmp_path: Path) -> None: + (tmp_path / "workflow_profile.v0.json").write_text( + json.dumps( + { + "schema_version": "v0", + "artifact_type": "WorkflowProfile.v0", + "workflow_id": "domain.unknown_profile_v0", + "name": "unknown", + }, + ) + + "\n", + encoding="utf-8", + ) + (tmp_path / "RELEASE_FIXTURE_MANIFEST.json").write_text( + json.dumps( + { + "workflow_profile_id": "domain.unknown_profile_v0", + "artifacts": {}, + }, + ) + + "\n", + encoding="utf-8", + ) + issues = validate_release_directory(tmp_path) + assert any(issue.code == UNKNOWN_WORKFLOW_PROFILE for issue in issues) + assert issues[0].actual == "domain.unknown_profile_v0" + + +def test_unknown_workflow_profile_not_labtrust_fallback(tmp_path: Path) -> None: + """Directories with a manifest but no detectable registered profile fail closed.""" + (tmp_path / "RELEASE_FIXTURE_MANIFEST.json").write_text( + json.dumps({"artifacts": {"only.json": "sha256:" + "ab" * 32}}) + "\n", + encoding="utf-8", + ) + (tmp_path / "only.json").write_text("{}\n", encoding="utf-8") + issues = validate_release_directory(tmp_path) + codes = {issue.code for issue in issues} + assert UNKNOWN_WORKFLOW_PROFILE in codes + assert "manifest_missing" not in codes + + +def test_empty_directory_still_reports_manifest_missing(tmp_path: Path) -> None: + issues = validate_release_directory(tmp_path) + assert {issue.code for issue in issues} == {"manifest_missing"} + + +def test_production_profiles_have_no_legacy_validator() -> None: + assert LABTRUST_RELEASE_PROFILE.legacy_validator is None + assert TOOL_USE_RELEASE_PROFILE.legacy_validator is None + assert COMPUTATION_RELEASE_PROFILE.legacy_validator is None + + +@pytest.mark.parametrize( + "spec", + list(parity_profile_specs()), + ids=lambda spec: spec.workflow_profile_id, +) +def test_legacy_declarative_parity_on_valid_fixtures(spec) -> None: + path = _VALID_ROOTS[spec.workflow_profile_id] + if not (path / "RELEASE_FIXTURE_MANIFEST.json").is_file(): + pytest.skip(f"missing release fixture {path}") + legacy_codes, declarative_codes = compare_legacy_and_declarative(path, spec) + assert legacy_codes == declarative_codes == frozenset() + + +@pytest.mark.parametrize( + ("invalid_root", "legacy_fn", "base_spec"), + _INVALID_ROOTS, + ids=("labtrust", "tool-use", "computation"), +) +def test_legacy_declarative_parity_on_invalid_fixtures( + invalid_root: Path, + legacy_fn, + base_spec, +) -> None: + if not invalid_root.is_dir(): + pytest.skip(f"missing invalid root {invalid_root}") + spec = replace(base_spec, legacy_validator=legacy_fn) + cases = sorted(path for path in invalid_root.iterdir() if path.is_dir()) + assert cases, f"expected invalid fixtures under {invalid_root}" + for case_dir in cases: + legacy_codes, declarative_codes = compare_legacy_and_declarative(case_dir, spec) + assert legacy_codes == declarative_codes, ( + f"{case_dir.name}: legacy={sorted(legacy_codes)} " + f"declarative={sorted(declarative_codes)}" + ) + + +def test_structural_validation_passes_all_valid_domains() -> None: + for path, spec in ( + (LABTRUST, LABTRUST_RELEASE_PROFILE), + (TOOL_USE, TOOL_USE_RELEASE_PROFILE), + (COMPUTATION, COMPUTATION_RELEASE_PROFILE), + ): + if not (path / "RELEASE_FIXTURE_MANIFEST.json").is_file(): + pytest.skip(f"missing {path}") + assert run_structural_release_profile_validation(path, spec) == [] + assert validate_release_directory(path) == [] + + +def test_labtrust_invalid_fixture_codes_via_declarative_engine() -> None: + expected = { + "placeholder_commit": "placeholder_commit_detected", + "mismatched_certificate_id": "certificate_id_mismatch", + "mismatched_trace_hash": "trace_hash_mismatch", + "mismatched_certified_bundle_hash": "verified_input_hash_mismatch", + "failed_scientific_memory_import": "scientific_memory_import_failed", + "legacy_import_mode": "legacy_import_detected", + } + for name, code in expected.items(): + path = LABTRUST_INVALID / name + if not path.is_dir(): + pytest.skip(f"missing {path}") + codes = normalized_issue_codes( + run_structural_release_profile_validation(path, LABTRUST_RELEASE_PROFILE), + ) + assert code in codes From ce118722c9c6280084c74bf8a57f27fd2996f17a Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:02:05 -0700 Subject: [PATCH 15/24] Separate legacy and release canonical JSON hash APIs. Keep legacy digests for compatibility while release hashing rejects unsafe floats and out-of-range integers, backed by expanded cross-language vectors. --- docs/hash-canonicalization.md | 36 ++- python/pcs_core/hash.py | 49 +++- python/tests/test_canonical_hash_release.py | 95 +++++++ rust/crates/pcs-core/src/hash.rs | 256 +++++++++++++++++- .../hash/artifact_registry.vector.json | 4 +- .../deeply_nested/canonical.txt | 1 + .../deeply_nested/digest.txt | 1 + .../deeply_nested/input.json | 40 +++ .../escaped_control_characters/canonical.txt | 1 + .../escaped_control_characters/digest.txt | 1 + .../escaped_control_characters/input.json | 5 + .../exponent_float/expected_rejection.txt | 1 + .../exponent_float/input.json | 1 + .../exponent_float/legacy_digest.txt | 1 + .../float_value/expected_rejection.txt | 1 + .../canonical_json_v1/float_value/input.json | 1 + .../float_value/legacy_digest.txt | 1 + .../expected_rejection.txt | 1 + .../integer_above_safe_max/input.json | 1 + .../integer_above_safe_max/legacy_digest.txt | 1 + .../expected_rejection.txt | 1 + .../integer_below_safe_min/input.json | 1 + .../integer_below_safe_min/legacy_digest.txt | 1 + .../max_safe_integer/canonical.txt | 1 + .../max_safe_integer/digest.txt | 1 + .../max_safe_integer/input.json | 5 + .../min_safe_integer/canonical.txt | 1 + .../min_safe_integer/digest.txt | 1 + .../min_safe_integer/input.json | 5 + .../expected_rejection.txt | 1 + .../negative_zero_float/input.json | 1 + .../negative_zero_float/legacy_digest.txt | 1 + .../unicode_combining_forms/canonical.txt | 1 + .../unicode_combining_forms/digest.txt | 1 + .../unicode_combining_forms/input.json | 5 + .../hash/canonical_json_v1/vectors.json | 52 ++++ .../hash/computation_witness.vector.json | 4 +- test_vectors/hash/result_artifact.vector.json | 4 +- .../hash/semantic_check_execution.vector.json | 4 +- typescript/packages/core/src/hash.ts | 117 +++++++- 40 files changed, 677 insertions(+), 29 deletions(-) create mode 100644 python/tests/test_canonical_hash_release.py create mode 100644 test_vectors/hash/canonical_json_v1/deeply_nested/canonical.txt create mode 100644 test_vectors/hash/canonical_json_v1/deeply_nested/digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/deeply_nested/input.json create mode 100644 test_vectors/hash/canonical_json_v1/escaped_control_characters/canonical.txt create mode 100644 test_vectors/hash/canonical_json_v1/escaped_control_characters/digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/escaped_control_characters/input.json create mode 100644 test_vectors/hash/canonical_json_v1/exponent_float/expected_rejection.txt create mode 100644 test_vectors/hash/canonical_json_v1/exponent_float/input.json create mode 100644 test_vectors/hash/canonical_json_v1/exponent_float/legacy_digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/float_value/expected_rejection.txt create mode 100644 test_vectors/hash/canonical_json_v1/float_value/input.json create mode 100644 test_vectors/hash/canonical_json_v1/float_value/legacy_digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/integer_above_safe_max/expected_rejection.txt create mode 100644 test_vectors/hash/canonical_json_v1/integer_above_safe_max/input.json create mode 100644 test_vectors/hash/canonical_json_v1/integer_above_safe_max/legacy_digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/integer_below_safe_min/expected_rejection.txt create mode 100644 test_vectors/hash/canonical_json_v1/integer_below_safe_min/input.json create mode 100644 test_vectors/hash/canonical_json_v1/integer_below_safe_min/legacy_digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/max_safe_integer/canonical.txt create mode 100644 test_vectors/hash/canonical_json_v1/max_safe_integer/digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/max_safe_integer/input.json create mode 100644 test_vectors/hash/canonical_json_v1/min_safe_integer/canonical.txt create mode 100644 test_vectors/hash/canonical_json_v1/min_safe_integer/digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/min_safe_integer/input.json create mode 100644 test_vectors/hash/canonical_json_v1/negative_zero_float/expected_rejection.txt create mode 100644 test_vectors/hash/canonical_json_v1/negative_zero_float/input.json create mode 100644 test_vectors/hash/canonical_json_v1/negative_zero_float/legacy_digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/unicode_combining_forms/canonical.txt create mode 100644 test_vectors/hash/canonical_json_v1/unicode_combining_forms/digest.txt create mode 100644 test_vectors/hash/canonical_json_v1/unicode_combining_forms/input.json diff --git a/docs/hash-canonicalization.md b/docs/hash-canonicalization.md index cd6702f..d61cc54 100644 --- a/docs/hash-canonicalization.md +++ b/docs/hash-canonicalization.md @@ -21,12 +21,33 @@ plus a strict number policy for new integrity envelopes. | Arrays | Order preserved (semantically significant) | | Serialization | Compact JSON (`","` / `":"` separators), UTF-8, `ensure_ascii=False` / unescaped non-ASCII | | Digest | SHA-256 over UTF-8 bytes, encoded as `sha256:` + 64 lowercase hex digits | -| Floats (strict policy) | Prohibited; store normalized decimal strings instead | -| Integers (strict policy) | Must lie in `[-9007199254740991, 9007199254740991]` | +| Floats (release policy) | Prohibited (`float_prohibited`); store normalized decimal strings instead | +| Negative zero (release policy) | Prohibited (`negative_zero`) | +| Integers (release policy) | Must lie in `[-9007199254740991, 9007199254740991]` (`integer_out_of_range`) | -Legacy `canonical_hash(...)` remains digests-compatible with Phase 0 vectors and does -not enable the strict number policy by default. Pass `enforce_number_policy=True` -(Python) when hashing new v1 integrity envelopes. +## APIs + +Cross-language bindings expose two explicit entry points: + +| API | Number policy | Use | +|-----|---------------|-----| +| `canonical_hash_legacy` / `canonicalHashLegacy` | Off | Phase 0 vectors and digests-compatible hashing | +| `canonical_hash_release` / `canonicalHashRelease` | Always on | Release integrity envelopes | + +`canonical_hash` / `canonicalHash` remains an alias of the legacy path (optional +`enforce_number_policy` / `enforceNumberPolicy` flag in Python and TypeScript). + +Release hashing must either return the same digest as every other language or raise +the same normalized rejection code: + +- `float_prohibited` +- `integer_out_of_range` +- `negative_zero` + +Legacy hashing remains digests-compatible with Phase 0 vectors. Note that ECMAScript +`JSON.stringify` collapses IEEE negative zero to `0`, so legacy digests for float +`-0` inputs may differ on TypeScript while release mode still rejects with +`negative_zero`. v0 artifacts may omit `canonicalization_version`; consumers treat them as Canonical JSON v1. New signed/hashed envelopes should set `"canonicalization_version": "v1"`. @@ -55,9 +76,10 @@ pcs shared-hash-vectors verify # test_vectors/hash/ |----------|--------| | `python/tests/hash_vectors/` | Per-artifact canonical JSON and digests | | `test_vectors/hash/` | Cross-language parity across Python, Rust, and TypeScript | -| `test_vectors/hash/canonical_json_v1/` | Edge-case vectors (Unicode, escapes, ordering, integers) | +| `test_vectors/hash/canonical_json_v1/` | Edge-case vectors (Unicode, escapes, ordering, integers, release rejects) | -Each per-artifact directory contains `input.json`, `canonical.txt`, and `digest.txt`. +Each accept case directory contains `input.json`, `canonical.txt`, and `digest.txt`. +Release reject cases add `expected_rejection.txt` and `legacy_digest.txt`. Regenerate vectors after an intentional algorithm change. diff --git a/python/pcs_core/hash.py b/python/pcs_core/hash.py index 576f625..29e9503 100644 --- a/python/pcs_core/hash.py +++ b/python/pcs_core/hash.py @@ -8,6 +8,7 @@ import hashlib import json +import math from typing import Any # v0 compatibility field: integrity digest mistaken historically for a signature. @@ -31,10 +32,20 @@ SAFE_INTEGER_MIN = -9007199254740991 SAFE_INTEGER_MAX = 9007199254740991 +# Normalized rejection codes shared with Rust and TypeScript release hashing. +REJECTION_FLOAT_PROHIBITED = "float_prohibited" +REJECTION_INTEGER_OUT_OF_RANGE = "integer_out_of_range" +REJECTION_NEGATIVE_ZERO = "negative_zero" + class CanonicalizationError(ValueError): """Raised when a value cannot be represented under Canonical JSON v1 rules.""" + def __init__(self, code: str, message: str, *, path: str = "$") -> None: + self.code = code + self.path = path + super().__init__(message) + def domain_separated_signing_message( *, @@ -58,23 +69,33 @@ def domain_separated_signing_message( def assert_canonical_number_policy(value: Any, *, path: str = "$") -> None: """Enforce Canonical JSON v1 number policy (strict / release hashing). - Floats are prohibited. Integers outside the safe-integer range are prohibited. - Callers that must carry non-integer decimals should store them as normalized - decimal strings rather than JSON numbers. + Floats and negative zero are prohibited. Integers outside the safe-integer + range are prohibited. Callers that must carry non-integer decimals should + store them as normalized decimal strings rather than JSON numbers. """ if isinstance(value, bool): return if isinstance(value, int): if value < SAFE_INTEGER_MIN or value > SAFE_INTEGER_MAX: raise CanonicalizationError( + REJECTION_INTEGER_OUT_OF_RANGE, f"{path}: integer {value} outside safe-integer range " - f"[{SAFE_INTEGER_MIN}, {SAFE_INTEGER_MAX}]" + f"[{SAFE_INTEGER_MIN}, {SAFE_INTEGER_MAX}]", + path=path, ) return if isinstance(value, float): + if value == 0.0 and math.copysign(1.0, value) < 0.0: + raise CanonicalizationError( + REJECTION_NEGATIVE_ZERO, + f"{path}: negative zero is prohibited under Canonical JSON v1", + path=path, + ) raise CanonicalizationError( + REJECTION_FLOAT_PROHIBITED, f"{path}: float values are prohibited under Canonical JSON v1; " - "use a normalized decimal string instead" + "use a normalized decimal string instead", + path=path, ) if isinstance(value, dict): for key, child in value.items(): @@ -127,6 +148,24 @@ def canonical_hash( return f"sha256:{digest}" +def canonical_hash_legacy(data: dict[str, Any]) -> str: + """Hash without the strict number policy (Phase 0 / legacy digest compatibility).""" + return canonical_hash(data, enforce_number_policy=False) + + +def canonical_hash_release(data: dict[str, Any]) -> str: + """Hash with the strict number policy always enforced (release integrity envelopes).""" + return canonical_hash(data, enforce_number_policy=True) + + +def try_canonical_hash_release(data: dict[str, Any]) -> tuple[str | None, str | None]: + """Return ``(digest, None)`` or ``(None, rejection_code)`` for cross-language vectors.""" + try: + return canonical_hash_release(data), None + except CanonicalizationError as exc: + return None, exc.code + + def attach_artifact_digest( data: dict[str, Any], *, diff --git a/python/tests/test_canonical_hash_release.py b/python/tests/test_canonical_hash_release.py new file mode 100644 index 0000000..8b64943 --- /dev/null +++ b/python/tests/test_canonical_hash_release.py @@ -0,0 +1,95 @@ +"""PR13: canonical_hash_legacy vs canonical_hash_release parity.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from pcs_core.hash import ( + CANONICALIZATION_VERSION, + REJECTION_FLOAT_PROHIBITED, + REJECTION_INTEGER_OUT_OF_RANGE, + REJECTION_NEGATIVE_ZERO, + SAFE_INTEGER_MAX, + SAFE_INTEGER_MIN, + CanonicalizationError, + canonical_hash, + canonical_hash_legacy, + canonical_hash_release, + canonical_json_bytes, + try_canonical_hash_release, +) + +REPO = Path(__file__).resolve().parents[2] +CANON_V1 = REPO / "test_vectors" / "hash" / "canonical_json_v1" + + +def test_legacy_aliases_canonical_hash() -> None: + payload = {"schema_version": "v0", "artifact_type": "CanonicalProbe.v0", "n": 1} + assert canonical_hash_legacy(payload) == canonical_hash(payload) + + +def test_release_matches_legacy_on_safe_payloads() -> None: + payload = { + "schema_version": "v0", + "artifact_type": "CanonicalProbe.v0", + "lo": SAFE_INTEGER_MIN, + "hi": SAFE_INTEGER_MAX, + } + assert canonical_hash_release(payload) == canonical_hash_legacy(payload) + + +def test_release_rejects_with_normalized_codes() -> None: + with pytest.raises(CanonicalizationError) as float_exc: + canonical_hash_release({"x": 1.5}) + assert float_exc.value.code == REJECTION_FLOAT_PROHIBITED + + with pytest.raises(CanonicalizationError) as hi_exc: + canonical_hash_release({"x": SAFE_INTEGER_MAX + 1}) + assert hi_exc.value.code == REJECTION_INTEGER_OUT_OF_RANGE + + with pytest.raises(CanonicalizationError) as lo_exc: + canonical_hash_release({"x": SAFE_INTEGER_MIN - 1}) + assert lo_exc.value.code == REJECTION_INTEGER_OUT_OF_RANGE + + with pytest.raises(CanonicalizationError) as neg_exc: + canonical_hash_release({"x": -0.0}) + assert neg_exc.value.code == REJECTION_NEGATIVE_ZERO + + +def test_try_canonical_hash_release_result() -> None: + digest, code = try_canonical_hash_release({"ok": True}) + assert digest is not None and code is None + digest, code = try_canonical_hash_release({"x": 1.5}) + assert digest is None and code == REJECTION_FLOAT_PROHIBITED + + +def test_canonical_json_v1_accept_vectors() -> None: + vectors = json.loads((CANON_V1 / "vectors.json").read_text(encoding="utf-8")) + assert vectors["canonicalization_version"] == CANONICALIZATION_VERSION + for case in vectors["cases"]: + case_id = case["case_id"] + payload = json.loads((CANON_V1 / case_id / "input.json").read_text(encoding="utf-8")) + assert canonical_json_bytes(payload).decode("utf-8") == case["canonical_json"] + digest = canonical_hash_legacy(payload) + assert digest == case["expected_digest"] + assert canonical_hash_release(payload) == digest + assert (CANON_V1 / case_id / "digest.txt").read_text(encoding="utf-8").strip() == digest + + +def test_canonical_json_v1_release_reject_vectors() -> None: + vectors = json.loads((CANON_V1 / "vectors.json").read_text(encoding="utf-8")) + for case in vectors["release_reject_cases"]: + case_id = case["case_id"] + payload = json.loads((CANON_V1 / case_id / "input.json").read_text(encoding="utf-8")) + digest, code = try_canonical_hash_release(payload) + assert digest is None + assert code == case["expected_rejection"] + assert ( + CANON_V1 / case_id / "expected_rejection.txt" + ).read_text(encoding="utf-8").strip() == case["expected_rejection"] + legacy = canonical_hash_legacy(payload) + assert legacy == case["legacy_digest"] + assert (CANON_V1 / case_id / "legacy_digest.txt").read_text(encoding="utf-8").strip() == legacy diff --git a/rust/crates/pcs-core/src/hash.rs b/rust/crates/pcs-core/src/hash.rs index 8ccfd05..02cd289 100644 --- a/rust/crates/pcs-core/src/hash.rs +++ b/rust/crates/pcs-core/src/hash.rs @@ -1,5 +1,6 @@ -use serde_json::Value; +use serde_json::{Number, Value}; use sha2::{Digest, Sha256}; +use std::fmt; /// v0 compatibility field: integrity digest historically named like a signature. pub const SIGNATURE_FIELD: &str = "signature_or_digest"; @@ -14,6 +15,37 @@ pub const CANONICALIZATION_VERSION: &str = "v1"; pub const SAFE_INTEGER_MIN: i64 = -9007199254740991; pub const SAFE_INTEGER_MAX: i64 = 9007199254740991; +/// Normalized rejection codes shared with Python and TypeScript release hashing. +pub const REJECTION_FLOAT_PROHIBITED: &str = "float_prohibited"; +pub const REJECTION_INTEGER_OUT_OF_RANGE: &str = "integer_out_of_range"; +pub const REJECTION_NEGATIVE_ZERO: &str = "negative_zero"; + +/// Error raised when a value cannot be represented under Canonical JSON v1 rules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalizationError { + pub code: String, + pub message: String, + pub path: String, +} + +impl CanonicalizationError { + pub fn new(code: impl Into, message: impl Into, path: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + path: path.into(), + } + } +} + +impl fmt::Display for CanonicalizationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for CanonicalizationError {} + fn is_excluded_hash_field(key: &str) -> bool { key == SIGNATURE_FIELD || key == ARTIFACT_DIGEST_FIELD || key == SIGNATURE_OBJECT_FIELD } @@ -62,18 +94,104 @@ pub fn domain_separated_signing_message( )) } +fn is_negative_zero(num: &Number) -> bool { + num.as_f64() + .is_some_and(|f| f == 0.0 && f.is_sign_negative()) +} + +fn assert_number_policy_number(num: &Number, path: &str) -> Result<(), CanonicalizationError> { + if is_negative_zero(num) { + return Err(CanonicalizationError::new( + REJECTION_NEGATIVE_ZERO, + format!("{path}: negative zero is prohibited under Canonical JSON v1"), + path, + )); + } + if let Some(i) = num.as_i64() { + if i < SAFE_INTEGER_MIN || i > SAFE_INTEGER_MAX { + return Err(CanonicalizationError::new( + REJECTION_INTEGER_OUT_OF_RANGE, + format!( + "{path}: integer {i} outside safe-integer range [{SAFE_INTEGER_MIN}, {SAFE_INTEGER_MAX}]" + ), + path, + )); + } + return Ok(()); + } + if let Some(u) = num.as_u64() { + if u > SAFE_INTEGER_MAX as u64 { + return Err(CanonicalizationError::new( + REJECTION_INTEGER_OUT_OF_RANGE, + format!( + "{path}: integer {u} outside safe-integer range [{SAFE_INTEGER_MIN}, {SAFE_INTEGER_MAX}]" + ), + path, + )); + } + return Ok(()); + } + // Non-integer / float form (including values that only fit in f64). + Err(CanonicalizationError::new( + REJECTION_FLOAT_PROHIBITED, + format!( + "{path}: float values are prohibited under Canonical JSON v1; \ + use a normalized decimal string instead" + ), + path, + )) +} + +/// Enforce Canonical JSON v1 number policy (strict / release hashing). +pub fn assert_canonical_number_policy(value: &Value, path: &str) -> Result<(), CanonicalizationError> { + match value { + Value::Null | Value::Bool(_) | Value::String(_) => Ok(()), + Value::Number(num) => assert_number_policy_number(num, path), + Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + assert_canonical_number_policy(child, &format!("{path}[{index}]"))?; + } + Ok(()) + } + Value::Object(map) => { + for (key, child) in map { + assert_canonical_number_policy(child, &format!("{path}.{key}"))?; + } + Ok(()) + } + } +} + pub fn canonicalize_for_hash(data: &serde_json::Value) -> serde_json::Value { + canonicalize_for_hash_with_policy(data, false).expect("legacy canonicalization cannot fail") +} + +pub fn canonicalize_for_hash_with_policy( + data: &serde_json::Value, + enforce_number_policy: bool, +) -> Result { let mut obj = data .as_object() .expect("artifact root must be object") .clone(); obj.retain(|k, _| !is_excluded_hash_field(k)); - sort_value(Value::Object(obj)) + let payload = Value::Object(obj); + if enforce_number_policy { + assert_canonical_number_policy(&payload, "$")?; + } + Ok(sort_value(payload)) } pub fn canonical_json_string(data: &serde_json::Value) -> String { - let canonical = canonicalize_for_hash(data); - serde_json::to_string(&canonical).expect("serialize canonical json") + canonical_json_string_with_policy(data, false).expect("legacy canonicalization cannot fail") +} + +pub fn canonical_json_string_with_policy( + data: &serde_json::Value, + enforce_number_policy: bool, +) -> Result { + let canonical = canonicalize_for_hash_with_policy(data, enforce_number_policy)?; + Ok(serde_json::to_string(&canonical).expect("serialize canonical json")) } pub fn canonical_json_bytes(data: &serde_json::Value) -> Vec { @@ -81,6 +199,136 @@ pub fn canonical_json_bytes(data: &serde_json::Value) -> Vec { } pub fn canonical_hash(data: &serde_json::Value) -> String { + canonical_hash_legacy(data) +} + +/// Hash without the strict number policy (Phase 0 / legacy digest compatibility). +pub fn canonical_hash_legacy(data: &serde_json::Value) -> String { let digest = Sha256::digest(canonical_json_bytes(data)); format!("sha256:{:x}", digest) } + +/// Hash with the strict number policy always enforced (release integrity envelopes). +pub fn canonical_hash_release(data: &serde_json::Value) -> Result { + let bytes = canonical_json_string_with_policy(data, true)?.into_bytes(); + let digest = Sha256::digest(bytes); + Ok(format!("sha256:{:x}", digest)) +} + +/// Return `Ok(digest)` or `Err(rejection_code)` for cross-language vectors. +pub fn try_canonical_hash_release(data: &serde_json::Value) -> Result { + canonical_hash_release(data).map_err(|err| err.code) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::fs; + use std::path::PathBuf; + + fn canon_v1_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../test_vectors/hash/canonical_json_v1") + } + + #[test] + fn release_rejects_float_with_normalized_code() { + let err = canonical_hash_release(&json!({"x": 1.5})).unwrap_err(); + assert_eq!(err.code, REJECTION_FLOAT_PROHIBITED); + } + + #[test] + fn release_rejects_out_of_range_integer() { + let err = canonical_hash_release(&json!({"x": SAFE_INTEGER_MAX + 1})).unwrap_err(); + assert_eq!(err.code, REJECTION_INTEGER_OUT_OF_RANGE); + let err = canonical_hash_release(&json!({"x": SAFE_INTEGER_MIN - 1})).unwrap_err(); + assert_eq!(err.code, REJECTION_INTEGER_OUT_OF_RANGE); + } + + #[test] + fn release_rejects_negative_zero() { + let value = serde_json::from_str::(r#"{"x":-0.0}"#).unwrap(); + let err = canonical_hash_release(&value).unwrap_err(); + assert_eq!(err.code, REJECTION_NEGATIVE_ZERO); + } + + #[test] + fn release_accepts_safe_integer_boundaries() { + let value = json!({ + "artifact_type": "CanonicalProbe.v0", + "hi": SAFE_INTEGER_MAX, + "lo": SAFE_INTEGER_MIN, + "schema_version": "v0", + }); + let digest = canonical_hash_release(&value).unwrap(); + assert_eq!(digest, canonical_hash_legacy(&value)); + assert!(digest.starts_with("sha256:")); + } + + #[test] + fn legacy_still_hashes_floats() { + let digest = canonical_hash_legacy(&json!({"x": 1.5})); + assert!(digest.starts_with("sha256:")); + } + + #[test] + fn canonical_json_v1_accept_vectors() { + let root = canon_v1_dir(); + let catalog: Value = + serde_json::from_str(&fs::read_to_string(root.join("vectors.json")).unwrap()).unwrap(); + assert_eq!( + catalog["canonicalization_version"].as_str().unwrap(), + CANONICALIZATION_VERSION + ); + for case in catalog["cases"].as_array().unwrap() { + let case_id = case["case_id"].as_str().unwrap(); + let data: Value = serde_json::from_str( + &fs::read_to_string(root.join(case_id).join("input.json")).unwrap(), + ) + .unwrap(); + let expected_digest = case["expected_digest"].as_str().unwrap(); + let expected_canonical = case["canonical_json"].as_str().unwrap(); + assert_eq!( + canonical_json_string(&data), + expected_canonical, + "{case_id} canonical" + ); + assert_eq!( + canonical_hash_legacy(&data), + expected_digest, + "{case_id} legacy" + ); + assert_eq!( + canonical_hash_release(&data).unwrap(), + expected_digest, + "{case_id} release" + ); + } + } + + #[test] + fn canonical_json_v1_release_reject_vectors() { + let root = canon_v1_dir(); + let catalog: Value = + serde_json::from_str(&fs::read_to_string(root.join("vectors.json")).unwrap()).unwrap(); + for case in catalog["release_reject_cases"].as_array().unwrap() { + let case_id = case["case_id"].as_str().unwrap(); + let data: Value = serde_json::from_str( + &fs::read_to_string(root.join(case_id).join("input.json")).unwrap(), + ) + .unwrap(); + let expected = case["expected_rejection"].as_str().unwrap(); + let legacy_digest = case["legacy_digest"].as_str().unwrap(); + assert_eq!( + try_canonical_hash_release(&data).unwrap_err(), + expected, + "{case_id} rejection" + ); + assert_eq!( + canonical_hash_legacy(&data), + legacy_digest, + "{case_id} legacy digest" + ); + } + } +} diff --git a/test_vectors/hash/artifact_registry.vector.json b/test_vectors/hash/artifact_registry.vector.json index 1723a9c..06bd3f8 100644 --- a/test_vectors/hash/artifact_registry.vector.json +++ b/test_vectors/hash/artifact_registry.vector.json @@ -1,6 +1,6 @@ { "artifact_type": "ArtifactRegistry.v0", "input_file": "examples/artifact_registry.valid.json", - "expected_digest": "sha256:571c5e466142a4173e66a1bb3ece88652994a01a47a5af369e8a8b4a99c30f30", - "canonical_json": "{\"entries\":{\"ArtifactIntegrity.v1\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ArtifactIntegrity.v1\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"canonicalization_version\",\"artifact_digest\",\"signature\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ArtifactIntegrity.v1.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"domain_separated_signature\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"no_signature_or_digest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"ArtifactRegistry.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ArtifactRegistry.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"registry_id\",\"registry_version\",\"entries\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ArtifactRegistry.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"entries_cover_required_artifact_types\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}]},\"AssumptionSet.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"HumanReviewed\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"AssumptionSet.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"assumption_set_id\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/AssumptionSet.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"BenchmarkArtifactRef.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"pcs-bench\",\"labtrust-gym\",\"certifyedge\",\"provability-fabric\",\"scientific-memory\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkArtifactRef.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"pcs-bench\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"path\",\"sha256\",\"role\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkArtifactRef.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkCase.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkCase.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"case_id\",\"task_id\",\"workflow_id\",\"case_kind\",\"input_artifacts\",\"expected_status\",\"expected_system_outcome\",\"expected_failure_code\",\"expected_responsible_component\",\"expected_repair_hint_kind\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkCase.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkMetricRegistry.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkMetricRegistry.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"registry_id\",\"registry_version\",\"metrics\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkMetricRegistry.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkRegistry.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkRegistry.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"registry_id\",\"registry_version\",\"suites\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkRegistry.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkReport.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkReport.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"report_id\",\"benchmark_suite_id\",\"runs\",\"metrics\",\"metric_summaries\",\"summary\",\"coverage\",\"failures\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkReport.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkRun.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkRun.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"run_id\",\"task_id\",\"case_id\",\"started_at\",\"completed_at\",\"commands\",\"artifacts_produced\",\"observed_status\",\"observed_failure_code\",\"observed_responsible_component\",\"observed_repair_hint\",\"duration_ms\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkRun.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkTask.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkTask.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"task_id\",\"workflow_id\",\"domain\",\"description\",\"input_case_set\",\"expected_outputs\",\"metrics\",\"success_criteria\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkTask.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"ClaimArtifact.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"CertificateChecked\",\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ClaimArtifact.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"CertifyEdge\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_id\",\"assumption_set_ref\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/ClaimArtifact.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"assumption_set_ref_present\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"ComponentReleaseFragment.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Rejected\",\"Stale\",\"Deprecated\"],\"artifact_type\":\"ComponentReleaseFragment.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"pcs-core\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"component\",\"source_repo\",\"source_commit\",\"artifacts\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/ComponentReleaseFragment.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"component_artifacts_match_release_pins\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"ComputationRunReceipt.v0\":{\"allowed_runtime_producers\":[\"scientific-computation demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ComputationRunReceipt.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"scientific-computation demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"run_id\",\"workflow_id\",\"command\",\"code_repo\",\"code_commit\",\"dataset_receipt_ref\",\"environment_receipt_ref\",\"started_at\",\"completed_at\",\"exit_code\",\"stdout_hash\",\"stderr_hash\",\"result_artifact_refs\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"scientific-computation demo producer\",\"schema\":\"schemas/ComputationRunReceipt.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"}]},\"ComputationWitness.v0\":{\"allowed_runtime_producers\":[\"CertifyEdge\"],\"allowed_statuses\":[\"CertificateChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ComputationWitness.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"CertifyEdge\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"witness_id\",\"workflow_id\",\"dataset_hash\",\"environment_hash\",\"run_receipt_hash\",\"result_hashes\",\"code_repo\",\"code_commit\",\"checker\",\"checker_version\",\"status\",\"violations\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"CertifyEdge\",\"schema\":\"schemas/ComputationWitness.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"dataset_hash_matches_receipt\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"environment_hash_matches_receipt\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"run_receipt_hash_matches_declared_run\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"result_hashes_match_result_artifacts\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"code_commit_present\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"computation_status_checked_for_release\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"source_commit_matches_release_manifest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"}]},\"ConformanceRun.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ConformanceRun.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"run_id\",\"suite\",\"status\",\"checks_passed\",\"checks_failed\",\"failures\",\"started_at\",\"completed_at\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ConformanceRun.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"CoverageReport.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"CoverageReport.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"coverage_id\",\"metric\",\"numerator\",\"denominator\",\"coverage_ratio\",\"details\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/CoverageReport.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"DatasetReceipt.v0\":{\"allowed_runtime_producers\":[\"scientific-computation demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"DatasetReceipt.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"scientific-computation demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"dataset_id\",\"dataset_name\",\"dataset_version\",\"files\",\"aggregate_hash\",\"source_uri\",\"source_repo\",\"source_commit\",\"license\",\"created_at\",\"signature_or_digest\"],\"runtime_producer\":\"scientific-computation demo producer\",\"schema\":\"schemas/DatasetReceipt.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"}]},\"EnvironmentReceipt.v0\":{\"allowed_runtime_producers\":[\"scientific-computation demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"EnvironmentReceipt.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"scientific-computation demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"environment_id\",\"environment_kind\",\"os\",\"architecture\",\"language_runtimes\",\"packages\",\"container_image\",\"container_digest\",\"hardware_summary\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"scientific-computation demo producer\",\"schema\":\"schemas/EnvironmentReceipt.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"}]},\"EvidenceBundle.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"CertificateChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"EvidenceBundle.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"CertifyEdge\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"bundle_id\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/EvidenceBundle.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"certificate_refs_resolve\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"producer_responsible\"}]},\"ExplainQualityReport.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"Provability Fabric\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ExplainQualityReport.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"report_id\",\"suite_id\",\"case_id\",\"producer_id\",\"required_sections\",\"sections\",\"sections_present_count\",\"sections_required_count\",\"quality_score\",\"gaps\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ExplainQualityReport.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"FailureCaseManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"FailureCaseManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"manifest_id\",\"case_id\",\"task_id\",\"failure_code\",\"responsible_component\",\"repair_hint_kind\",\"message\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/FailureCaseManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"FailureLocalizationResult.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"FailureLocalizationResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"result_id\",\"run_id\",\"case_id\",\"expected_failure_code\",\"observed_failure_code\",\"expected_responsible_component\",\"observed_responsible_component\",\"localized_correctly\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/FailureLocalizationResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"HandoffManifest.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Rejected\",\"Stale\",\"Deprecated\"],\"artifact_type\":\"HandoffManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"handoff_id\",\"handoff_kind\",\"input_artifacts\",\"expected_outputs\",\"invariants\",\"status\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/HandoffManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"handoff_input_hashes_when_validated\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"LeanCheckResult.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"LeanCheckResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"check_id\",\"proof_obligation_id\",\"lean_module\",\"lean_theorem\",\"status\",\"checked_at\",\"lean_version\",\"source_repo\",\"source_commit\",\"failure_reason\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/LeanCheckResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"obligation_results_match_proof_obligation\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_theorem_in_catalog\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}]},\"MetricSummary.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"pcs-bench\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"MetricSummary.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"pcs-bench\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"metric_id\",\"score\",\"applicability\",\"numerator\",\"denominator\",\"reason\",\"details\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/MetricSummary.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"PCSProjectionManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PCSProjectionManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"projection_id\",\"release_id\",\"workflow_id\",\"entries\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PCSProjectionManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"projection_entries_nonempty_values\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"projection_hash_binds_proof_obligation\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreAction.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreAction.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreAction.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreCapability.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreCapability.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreCapability.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreCertificate.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"RuntimeChecked\",\"CertificateChecked\",\"LeanKernelChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"PFCoreCertificate.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"certificate_id\",\"claim_class\",\"source_repo\",\"source_commit\",\"signature_or_digest\",\"trace_hash\",\"claim_class\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreCertificate.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"claim_class_matches_assurance\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_kernel_proof\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_library_build\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreContract.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreContract.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreContract.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreEvent.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreEvent.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"AgentRuntime\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"AgentRuntime\",\"schema\":\"schemas/PFCoreEvent.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreHandoff.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreHandoff.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreHandoff.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreKernelManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreKernelManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"files\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreKernelManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"unique_kernel_paths\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCorePrincipal.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCorePrincipal.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCorePrincipal.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreReleaseBundleManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreReleaseBundleManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"trace_path\",\"certificate_path\",\"trace_hash\",\"kernel_manifest_path\",\"pfcore_kernel_hash\",\"lean_environment_hash\",\"certificate_mode\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreReleaseBundleManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"schema_valid_before_path_follow\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreResource.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreResource.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreResource.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreRuntimeObservation.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"RuntimeChecked\",\"CertificateChecked\",\"LeanKernelChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"AgentRuntime\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"observation_id\",\"claim_class\",\"source_repo\",\"source_commit\",\"signature_or_digest\",\"observed_at\",\"payload_hash\"],\"runtime_producer\":\"AgentRuntime\",\"schema\":\"schemas/PFCoreRuntimeObservation.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"claim_class_matches_assurance\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_kernel_proof\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_library_build\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreTrace.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"RuntimeChecked\",\"CertificateChecked\",\"LeanKernelChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"PFCoreTrace.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"trace_id\",\"claim_class\",\"source_repo\",\"source_commit\",\"signature_or_digest\",\"trace_hash\",\"events\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreTrace.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"claim_class_matches_assurance\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_kernel_proof\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_library_build\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PcsBenchIngest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"pcs-bench\",\"labtrust-gym\",\"certifyedge\",\"provability-fabric\",\"scientific-memory\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PcsBenchIngest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"pcs-bench\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"producer_id\",\"suite_id\",\"workflow_id\",\"benchmark_runs\",\"coverage_reports\",\"failure_localization_reports\",\"explain_quality_reports\",\"profile_coverage_reports\",\"commands\",\"logs\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PcsBenchIngest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"ProfileCoverageReport.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"Provability Fabric\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ProfileCoverageReport.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"Provability Fabric\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"coverage_id\",\"workflow_profile_id\",\"producer_id\",\"artifact_types_required\",\"artifact_types_covered\",\"semantic_checks_required\",\"semantic_checks_covered\",\"handoff_steps_required\",\"handoff_steps_covered\",\"numerator\",\"denominator\",\"coverage_ratio\",\"details\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ProfileCoverageReport.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"ProofObligation.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ProofObligation.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"obligation_id\",\"release_id\",\"workflow_id\",\"obligations\",\"source_artifacts\",\"lean_module\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ProofObligation.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"obligations_reference_known_kinds\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}]},\"ReleaseChainValidationResult.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ReleaseChainValidationResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"validation_id\",\"release_id\",\"status\",\"checks\",\"artifacts_checked\",\"failure_codes\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ReleaseChainValidationResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"status_matches_check_outcomes\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"validator_responsible\"}]},\"ReleaseManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Rejected\",\"Stale\",\"Deprecated\"],\"artifact_type\":\"ReleaseManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"release_id\",\"release_candidate\",\"validation_profile\",\"producer_repos\",\"artifacts\",\"release_status\",\"chain_root\",\"release_chain_validation_result\",\"canonical_signed_bundle\",\"canonical_claim_id\",\"limitations_notice\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ReleaseManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"release_mode_commit_policy\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"artifact_hashes_match_files\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"ResultArtifact.v0\":{\"allowed_runtime_producers\":[\"scientific-computation demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ResultArtifact.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"scientific-computation demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"result_id\",\"result_kind\",\"path\",\"sha256\",\"size_bytes\",\"media_type\",\"description\",\"produced_by_run\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"scientific-computation demo producer\",\"schema\":\"schemas/ResultArtifact.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"}]},\"RuntimeReceipt.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"RuntimeObserved\",\"RuntimeChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"RuntimeReceipt.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"receipt_id\",\"trace_hash\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/RuntimeReceipt.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"trace_hash_present\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"source_commit_matches_release_manifest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"ScienceClaimBundle.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"CertificateChecked\",\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ScienceClaimBundle.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"bundle_id\",\"assumption_set\",\"runtime_receipts\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/ScienceClaimBundle.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"non_empty_runtime_receipts\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"certified_bundle_has_certificate_when_checked\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"SignedScienceClaimBundle.v0\":{\"allowed_runtime_producers\":[\"Provability Fabric\"],\"allowed_statuses\":[\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"SignedScienceClaimBundle.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"Provability Fabric\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"signed_bundle_id\",\"signed_input_bundle_hash\",\"science_claim_bundle\",\"verification_result\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"Provability Fabric\",\"schema\":\"schemas/SignedScienceClaimBundle.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"signed_input_bundle_hash_matches_certified\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"embedded_bundle_passes_science_claim_semantics\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"Provability Fabric\",\"severity\":\"producer_responsible\"}]},\"SourceSpan.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"Extracted\",\"Rejected\",\"Stale\"],\"artifact_type\":\"SourceSpan.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"source_span_id\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/SourceSpan.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"ToolUseCertificate.v0\":{\"allowed_runtime_producers\":[\"CertifyEdge\"],\"allowed_statuses\":[\"CertificateChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ToolUseCertificate.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"CertifyEdge\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"certificate_id\",\"trace_hash\",\"policy_hash\",\"property_id\",\"checker\",\"checker_version\",\"status\",\"violations\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"CertifyEdge\",\"schema\":\"schemas/ToolUseCertificate.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"tool_trace_hash_matches_certificate\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"policy_hash_matches_certificate\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"certificate_status_checked_for_release\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"no_unauthorized_tool_calls\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"source_commit_matches_release_manifest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"}]},\"ToolUseTrace.v0\":{\"allowed_runtime_producers\":[\"agent-tool-use demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ToolUseTrace.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"agent-tool-use demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"trace_id\",\"workflow_id\",\"agent_id\",\"policy_id\",\"policy_hash\",\"started_at\",\"completed_at\",\"tool_calls\",\"trace_hash\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"agent-tool-use demo producer\",\"schema\":\"schemas/ToolUseTrace.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"trace_hash_present\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"agent-tool-use demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"no_unknown_authorization_status\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"agent-tool-use demo producer\",\"severity\":\"release_blocking\"}]},\"TraceCertificate.v0\":{\"allowed_runtime_producers\":[\"CertifyEdge\"],\"allowed_statuses\":[\"CertificatePending\",\"CertificateChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"TraceCertificate.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"LabTrust-Gym\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"CertifyEdge\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"certificate_id\",\"trace_hash\",\"spec_hash\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"CertifyEdge\",\"schema\":\"schemas/TraceCertificate.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"trace_hash_matches_runtime_receipt\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"status_is_certificate_checked_for_release\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"source_commit_matches_release_manifest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"}]},\"VerificationResult.v0\":{\"allowed_runtime_producers\":[\"Provability Fabric\"],\"allowed_statuses\":[\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"VerificationResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"Provability Fabric\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"verification_id\",\"status\",\"verified_input\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"Provability Fabric\",\"schema\":\"schemas/VerificationResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"verified_input_bundle_hash_matches_certified\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"failed_checks_block_import_ready_status\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"}]},\"WorkflowProfile.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"WorkflowProfile.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"workflow_id\",\"domain\",\"description\",\"runtime_artifacts\",\"certificate_artifacts\",\"handoff_sequence\",\"required_registry_entries\",\"required_admission_profile\",\"status_policy\",\"failure_modes\",\"limitations_notice\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/WorkflowProfile.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"required_registry_entries_registered\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}]}},\"registry_id\":\"pcs-artifact-registry-v0.1\",\"registry_version\":\"0.1.0\",\"schema_version\":\"v0\"}" + "expected_digest": "sha256:523658c76a705ef3dc3e4c277bc5624bdcedc770d773f18ca33127ed568967fa", + "canonical_json": "{\"entries\":{\"ArtifactIntegrity.v1\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ArtifactIntegrity.v1\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"canonicalization_version\",\"artifact_digest\",\"signature\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ArtifactIntegrity.v1.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"domain_separated_signature\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"no_signature_or_digest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"ArtifactRegistry.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ArtifactRegistry.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"registry_id\",\"registry_version\",\"entries\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ArtifactRegistry.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"entries_cover_required_artifact_types\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}]},\"AssumptionSet.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"HumanReviewed\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"AssumptionSet.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"assumption_set_id\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/AssumptionSet.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"BenchmarkArtifactRef.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"pcs-bench\",\"labtrust-gym\",\"certifyedge\",\"provability-fabric\",\"scientific-memory\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkArtifactRef.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"pcs-bench\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"path\",\"sha256\",\"role\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkArtifactRef.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkCase.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkCase.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"case_id\",\"task_id\",\"workflow_id\",\"case_kind\",\"input_artifacts\",\"expected_status\",\"expected_system_outcome\",\"expected_failure_code\",\"expected_responsible_component\",\"expected_repair_hint_kind\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkCase.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkMetricRegistry.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkMetricRegistry.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"registry_id\",\"registry_version\",\"metrics\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkMetricRegistry.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkRegistry.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkRegistry.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"registry_id\",\"registry_version\",\"suites\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkRegistry.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkReport.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkReport.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"report_id\",\"benchmark_suite_id\",\"runs\",\"metrics\",\"metric_summaries\",\"summary\",\"coverage\",\"failures\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkReport.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkRun.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkRun.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"run_id\",\"task_id\",\"case_id\",\"started_at\",\"completed_at\",\"commands\",\"artifacts_produced\",\"observed_status\",\"observed_failure_code\",\"observed_responsible_component\",\"observed_repair_hint\",\"duration_ms\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkRun.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"BenchmarkTask.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"BenchmarkTask.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"task_id\",\"workflow_id\",\"domain\",\"description\",\"input_case_set\",\"expected_outputs\",\"metrics\",\"success_criteria\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/BenchmarkTask.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"ClaimArtifact.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"CertificateChecked\",\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ClaimArtifact.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"CertifyEdge\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_id\",\"assumption_set_ref\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/ClaimArtifact.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"assumption_set_ref_present\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"ComponentReleaseFragment.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Rejected\",\"Stale\",\"Deprecated\"],\"artifact_type\":\"ComponentReleaseFragment.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"pcs-core\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"component\",\"source_repo\",\"source_commit\",\"artifacts\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/ComponentReleaseFragment.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"component_artifacts_match_release_pins\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"ComputationRunReceipt.v0\":{\"allowed_runtime_producers\":[\"scientific-computation demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ComputationRunReceipt.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"scientific-computation demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"run_id\",\"workflow_id\",\"command\",\"code_repo\",\"code_commit\",\"dataset_receipt_ref\",\"environment_receipt_ref\",\"started_at\",\"completed_at\",\"exit_code\",\"stdout_hash\",\"stderr_hash\",\"result_artifact_refs\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"scientific-computation demo producer\",\"schema\":\"schemas/ComputationRunReceipt.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"}]},\"ComputationWitness.v0\":{\"allowed_runtime_producers\":[\"CertifyEdge\"],\"allowed_statuses\":[\"CertificateChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ComputationWitness.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"CertifyEdge\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"witness_id\",\"workflow_id\",\"dataset_hash\",\"environment_hash\",\"run_receipt_hash\",\"result_hashes\",\"code_repo\",\"code_commit\",\"checker\",\"checker_version\",\"status\",\"violations\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"CertifyEdge\",\"schema\":\"schemas/ComputationWitness.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"dataset_hash_matches_receipt\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"environment_hash_matches_receipt\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"run_receipt_hash_matches_declared_run\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"result_hashes_match_result_artifacts\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"code_commit_present\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"computation_status_checked_for_release\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"source_commit_matches_release_manifest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"}]},\"ConformanceRun.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ConformanceRun.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"run_id\",\"suite\",\"status\",\"checks_passed\",\"checks_failed\",\"failures\",\"started_at\",\"completed_at\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ConformanceRun.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"CoverageReport.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"CoverageReport.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"coverage_id\",\"metric\",\"numerator\",\"denominator\",\"coverage_ratio\",\"details\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/CoverageReport.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"DatasetReceipt.v0\":{\"allowed_runtime_producers\":[\"scientific-computation demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"DatasetReceipt.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"scientific-computation demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"dataset_id\",\"dataset_name\",\"dataset_version\",\"files\",\"aggregate_hash\",\"source_uri\",\"source_repo\",\"source_commit\",\"license\",\"created_at\",\"signature_or_digest\"],\"runtime_producer\":\"scientific-computation demo producer\",\"schema\":\"schemas/DatasetReceipt.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"}]},\"EnvironmentReceipt.v0\":{\"allowed_runtime_producers\":[\"scientific-computation demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"EnvironmentReceipt.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"scientific-computation demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"environment_id\",\"environment_kind\",\"os\",\"architecture\",\"language_runtimes\",\"packages\",\"container_image\",\"container_digest\",\"hardware_summary\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"scientific-computation demo producer\",\"schema\":\"schemas/EnvironmentReceipt.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"}]},\"EvidenceBundle.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"CertificateChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"EvidenceBundle.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"CertifyEdge\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"bundle_id\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/EvidenceBundle.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"certificate_refs_resolve\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"producer_responsible\"}]},\"ExplainQualityReport.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"Provability Fabric\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ExplainQualityReport.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"report_id\",\"suite_id\",\"case_id\",\"producer_id\",\"required_sections\",\"sections\",\"sections_present_count\",\"sections_required_count\",\"quality_score\",\"gaps\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ExplainQualityReport.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"FailureCaseManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"FailureCaseManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"manifest_id\",\"case_id\",\"task_id\",\"failure_code\",\"responsible_component\",\"repair_hint_kind\",\"message\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/FailureCaseManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"FailureLocalizationResult.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"FailureLocalizationResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"result_id\",\"run_id\",\"case_id\",\"expected_failure_code\",\"observed_failure_code\",\"expected_responsible_component\",\"observed_responsible_component\",\"localized_correctly\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/FailureLocalizationResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"HandoffManifest.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Rejected\",\"Stale\",\"Deprecated\"],\"artifact_type\":\"HandoffManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"handoff_id\",\"handoff_kind\",\"input_artifacts\",\"expected_outputs\",\"invariants\",\"status\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/HandoffManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"handoff_input_hashes_when_validated\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"LeanCheckResult.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"LeanCheckResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"check_id\",\"proof_obligation_id\",\"lean_module\",\"lean_theorem\",\"status\",\"checked_at\",\"lean_version\",\"source_repo\",\"source_commit\",\"failure_reason\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/LeanCheckResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"obligation_results_match_proof_obligation\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_theorem_in_catalog\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}]},\"MetricSummary.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"pcs-bench\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"MetricSummary.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"pcs-bench\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"metric_id\",\"score\",\"applicability\",\"numerator\",\"denominator\",\"reason\",\"details\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/MetricSummary.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"PCSProjectionManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PCSProjectionManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"projection_id\",\"release_id\",\"workflow_id\",\"entries\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PCSProjectionManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"projection_entries_nonempty_values\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"projection_hash_binds_proof_obligation\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreAction.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreAction.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreAction.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreBundleVerificationResult.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreBundleVerificationResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"ok\",\"bundle_dir\",\"verifier\",\"verifier_version\",\"checks\",\"issues\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreBundleVerificationResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreCapability.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreCapability.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreCapability.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreCertificate.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"RuntimeChecked\",\"CertificateChecked\",\"LeanKernelChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"PFCoreCertificate.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"certificate_id\",\"claim_class\",\"source_repo\",\"source_commit\",\"signature_or_digest\",\"trace_hash\",\"claim_class\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreCertificate.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"claim_class_matches_assurance\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_kernel_proof\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_library_build\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreContract.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreContract.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreContract.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreEffectFrame.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreEffectFrame.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreEffectFrame.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreEvent.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreEvent.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"AgentRuntime\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"AgentRuntime\",\"schema\":\"schemas/PFCoreEvent.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreEvidenceManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreEvidenceManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"evidence_selection_policy\",\"evidence_selection_policy_version\",\"files\",\"evidence_manifest_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreEvidenceManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"manifest_digest_matches\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreHandoff.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreHandoff.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreHandoff.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreKernelManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreKernelManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"files\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreKernelManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"unique_kernel_paths\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCorePrincipal.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCorePrincipal.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCorePrincipal.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreReleaseBundleManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreReleaseBundleManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"trace_path\",\"certificate_path\",\"trace_hash\",\"kernel_manifest_path\",\"pfcore_kernel_hash\",\"lean_environment_hash\",\"certificate_mode\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreReleaseBundleManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"schema_valid_before_path_follow\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"closed_evidence_digests\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreResource.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreResource.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreResource.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreRuntimeObservation.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"RuntimeChecked\",\"CertificateChecked\",\"LeanKernelChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"AgentRuntime\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"observation_id\",\"claim_class\",\"source_repo\",\"source_commit\",\"signature_or_digest\",\"observed_at\",\"payload_hash\"],\"runtime_producer\":\"AgentRuntime\",\"schema\":\"schemas/PFCoreRuntimeObservation.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"claim_class_matches_assurance\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_kernel_proof\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_library_build\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreTheoremManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PFCoreTheoremManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"generated_module_name\",\"proof_file_hash\",\"semantic_projection_hash\",\"certificate_mode\",\"final_witness_theorem\",\"final_witness_proposition\",\"theorems\",\"theorem_manifest_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreTheoremManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"manifest_digest_matches\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PFCoreTrace.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"AgentRuntime\"],\"allowed_statuses\":[\"Draft\",\"RuntimeChecked\",\"CertificateChecked\",\"LeanKernelChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"PFCoreTrace.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"artifact_type\",\"trace_id\",\"claim_class\",\"source_repo\",\"source_commit\",\"signature_or_digest\",\"trace_hash\",\"events\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PFCoreTrace.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"explicit_artifact_type\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"schema_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"claim_class_matches_assurance\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_kernel_proof\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"lean_library_build\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"PcsBenchIngest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"pcs-bench\",\"labtrust-gym\",\"certifyedge\",\"provability-fabric\",\"scientific-memory\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"PcsBenchIngest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"pcs-bench\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"producer_id\",\"suite_id\",\"workflow_id\",\"benchmark_runs\",\"coverage_reports\",\"failure_localization_reports\",\"explain_quality_reports\",\"profile_coverage_reports\",\"commands\",\"logs\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/PcsBenchIngest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"ProfileCoverageReport.v0\":{\"allowed_runtime_producers\":[\"pcs-core\",\"Provability Fabric\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ProfileCoverageReport.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"Provability Fabric\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"coverage_id\",\"workflow_profile_id\",\"producer_id\",\"artifact_types_required\",\"artifact_types_covered\",\"semantic_checks_required\",\"semantic_checks_covered\",\"handoff_steps_required\",\"handoff_steps_covered\",\"numerator\",\"denominator\",\"coverage_ratio\",\"details\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ProfileCoverageReport.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[]},\"ProofObligation.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"ProofObligation.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"obligation_id\",\"release_id\",\"workflow_id\",\"obligations\",\"source_artifacts\",\"lean_module\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ProofObligation.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"obligations_reference_known_kinds\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}]},\"ReleaseChainValidationResult.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ReleaseChainValidationResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"validation_id\",\"release_id\",\"status\",\"checks\",\"artifacts_checked\",\"failure_codes\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ReleaseChainValidationResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"status_matches_check_outcomes\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"validator_responsible\"}]},\"ReleaseManifest.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Rejected\",\"Stale\",\"Deprecated\"],\"artifact_type\":\"ReleaseManifest.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"pcs-core\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"release_id\",\"release_candidate\",\"validation_profile\",\"producer_repos\",\"artifacts\",\"release_status\",\"chain_root\",\"release_chain_validation_result\",\"canonical_signed_bundle\",\"canonical_claim_id\",\"limitations_notice\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/ReleaseManifest.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"release_mode_commit_policy\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"artifact_hashes_match_files\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"ResultArtifact.v0\":{\"allowed_runtime_producers\":[\"scientific-computation demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ResultArtifact.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"scientific-computation demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"result_id\",\"result_kind\",\"path\",\"sha256\",\"size_bytes\",\"media_type\",\"description\",\"produced_by_run\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"scientific-computation demo producer\",\"schema\":\"schemas/ResultArtifact.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"payload_bytes_match_digest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"}]},\"RuntimeReceipt.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"RuntimeObserved\",\"RuntimeChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"RuntimeReceipt.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"receipt_id\",\"trace_hash\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/RuntimeReceipt.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"trace_hash_present\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"source_commit_matches_release_manifest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"ScienceClaimBundle.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"CertificateChecked\",\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ScienceClaimBundle.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"bundle_id\",\"assumption_set\",\"runtime_receipts\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/ScienceClaimBundle.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"non_empty_runtime_receipts\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"certified_bundle_has_certificate_when_checked\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"SignedScienceClaimBundle.v0\":{\"allowed_runtime_producers\":[\"Provability Fabric\"],\"allowed_statuses\":[\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"SignedScienceClaimBundle.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"Provability Fabric\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"signed_bundle_id\",\"signed_input_bundle_hash\",\"science_claim_bundle\",\"verification_result\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"Provability Fabric\",\"schema\":\"schemas/SignedScienceClaimBundle.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"signed_input_bundle_hash_matches_certified\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"embedded_bundle_passes_science_claim_semantics\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"Provability Fabric\",\"severity\":\"producer_responsible\"}]},\"SourceSpan.v0\":{\"allowed_runtime_producers\":[\"LabTrust-Gym\"],\"allowed_statuses\":[\"Draft\",\"Extracted\",\"Rejected\",\"Stale\"],\"artifact_type\":\"SourceSpan.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"LabTrust-Gym\"],\"producer\":\"LabTrust-Gym\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"source_span_id\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"LabTrust-Gym\",\"schema\":\"schemas/SourceSpan.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"source_commit_not_placeholder\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"}]},\"ToolUseCertificate.v0\":{\"allowed_runtime_producers\":[\"CertifyEdge\"],\"allowed_statuses\":[\"CertificateChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ToolUseCertificate.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"CertifyEdge\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"certificate_id\",\"trace_hash\",\"policy_hash\",\"property_id\",\"checker\",\"checker_version\",\"status\",\"violations\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"CertifyEdge\",\"schema\":\"schemas/ToolUseCertificate.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"tool_trace_hash_matches_certificate\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"policy_hash_matches_certificate\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"certificate_status_checked_for_release\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"no_unauthorized_tool_calls\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"source_commit_matches_release_manifest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"signature_or_digest_valid\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"}]},\"ToolUseTrace.v0\":{\"allowed_runtime_producers\":[\"agent-tool-use demo producer\"],\"allowed_statuses\":[\"Draft\",\"RuntimeObserved\",\"Rejected\",\"Stale\"],\"artifact_type\":\"ToolUseTrace.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"pcs-core\"],\"producer\":\"agent-tool-use demo producer\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"trace_id\",\"workflow_id\",\"agent_id\",\"policy_id\",\"policy_hash\",\"started_at\",\"completed_at\",\"tool_calls\",\"trace_hash\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"agent-tool-use demo producer\",\"schema\":\"schemas/ToolUseTrace.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"trace_hash_present\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"agent-tool-use demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"no_unknown_authorization_status\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"agent-tool-use demo producer\",\"severity\":\"release_blocking\"}]},\"TraceCertificate.v0\":{\"allowed_runtime_producers\":[\"CertifyEdge\"],\"allowed_statuses\":[\"CertificatePending\",\"CertificateChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"TraceCertificate.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"CertifyEdge\",\"LabTrust-Gym\",\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"CertifyEdge\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"certificate_id\",\"trace_hash\",\"spec_hash\",\"status\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"CertifyEdge\",\"schema\":\"schemas/TraceCertificate.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"trace_hash_matches_runtime_receipt\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"status_is_certificate_checked_for_release\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"source_commit_matches_release_manifest\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"}]},\"VerificationResult.v0\":{\"allowed_runtime_producers\":[\"Provability Fabric\"],\"allowed_statuses\":[\"ProofChecked\",\"Rejected\",\"Stale\"],\"artifact_type\":\"VerificationResult.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"Provability Fabric\",\"Scientific Memory\"],\"producer\":\"Provability Fabric\",\"release_mode_required\":true,\"required_release_fields\":[\"schema_version\",\"verification_id\",\"status\",\"verified_input\",\"source_repo\",\"source_commit\",\"signature_or_digest\"],\"runtime_producer\":\"Provability Fabric\",\"schema\":\"schemas/VerificationResult.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"verified_input_bundle_hash_matches_certified\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"check_id\":\"failed_checks_block_import_ready_status\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"}]},\"WorkflowProfile.v0\":{\"allowed_runtime_producers\":[\"pcs-core\"],\"allowed_statuses\":[\"Draft\",\"Validated\",\"Deprecated\"],\"artifact_type\":\"WorkflowProfile.v0\",\"canonical_hash_required\":true,\"consumer_repos\":[\"pcs-core\",\"LabTrust-Gym\",\"CertifyEdge\",\"Provability Fabric\",\"Scientific Memory\",\"AgentRuntime\"],\"producer\":\"pcs-core\",\"release_mode_required\":false,\"required_release_fields\":[\"schema_version\",\"workflow_id\",\"domain\",\"description\",\"runtime_artifacts\",\"certificate_artifacts\",\"handoff_sequence\",\"required_registry_entries\",\"required_admission_profile\",\"status_policy\",\"failure_modes\",\"limitations_notice\",\"signature_or_digest\"],\"runtime_producer\":\"pcs-core\",\"schema\":\"schemas/WorkflowProfile.v0.schema.json\",\"schema_owner\":\"pcs-core\",\"semantic_checks\":[{\"allowed_to_skip\":false,\"check_id\":\"required_registry_entries_registered\",\"execution_required_in_release_mode\":true,\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}]}},\"registry_id\":\"pcs-artifact-registry-v0.1\",\"registry_version\":\"0.1.0\",\"schema_version\":\"v0\"}" } diff --git a/test_vectors/hash/canonical_json_v1/deeply_nested/canonical.txt b/test_vectors/hash/canonical_json_v1/deeply_nested/canonical.txt new file mode 100644 index 0000000..078af26 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/deeply_nested/canonical.txt @@ -0,0 +1 @@ +{"artifact_type":"CanonicalProbe.v0","child":{"child":{"child":{"child":{"child":{"child":{"child":{"child":{"child":{"child":{"child":{"child":{"level":11},"level":10},"level":9},"level":8},"level":7},"level":6},"level":5},"level":4},"level":3},"level":2},"level":1},"level":0},"schema_version":"v0"} diff --git a/test_vectors/hash/canonical_json_v1/deeply_nested/digest.txt b/test_vectors/hash/canonical_json_v1/deeply_nested/digest.txt new file mode 100644 index 0000000..cd4456c --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/deeply_nested/digest.txt @@ -0,0 +1 @@ +sha256:d15c46e1c94e75be8bcc55ae83c0b20d95a566a29e9212cb15db7de519d7d3a7 diff --git a/test_vectors/hash/canonical_json_v1/deeply_nested/input.json b/test_vectors/hash/canonical_json_v1/deeply_nested/input.json new file mode 100644 index 0000000..027fdf2 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/deeply_nested/input.json @@ -0,0 +1,40 @@ +{ + "schema_version": "v0", + "artifact_type": "CanonicalProbe.v0", + "child": { + "level": 0, + "child": { + "level": 1, + "child": { + "level": 2, + "child": { + "level": 3, + "child": { + "level": 4, + "child": { + "level": 5, + "child": { + "level": 6, + "child": { + "level": 7, + "child": { + "level": 8, + "child": { + "level": 9, + "child": { + "level": 10, + "child": { + "level": 11 + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/test_vectors/hash/canonical_json_v1/escaped_control_characters/canonical.txt b/test_vectors/hash/canonical_json_v1/escaped_control_characters/canonical.txt new file mode 100644 index 0000000..4efab73 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/escaped_control_characters/canonical.txt @@ -0,0 +1 @@ +{"artifact_type":"CanonicalProbe.v0","schema_version":"v0","text":"a\u0000b\rb\bc\ff"} diff --git a/test_vectors/hash/canonical_json_v1/escaped_control_characters/digest.txt b/test_vectors/hash/canonical_json_v1/escaped_control_characters/digest.txt new file mode 100644 index 0000000..4d6d5cf --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/escaped_control_characters/digest.txt @@ -0,0 +1 @@ +sha256:abc1b7235ed6d0fa0a129ab9bc98087cb05ccaa51197d844b6f8aa7aa61b62d1 diff --git a/test_vectors/hash/canonical_json_v1/escaped_control_characters/input.json b/test_vectors/hash/canonical_json_v1/escaped_control_characters/input.json new file mode 100644 index 0000000..ebd9287 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/escaped_control_characters/input.json @@ -0,0 +1,5 @@ +{ + "schema_version": "v0", + "artifact_type": "CanonicalProbe.v0", + "text": "a\u0000b\rb\bc\ff" +} diff --git a/test_vectors/hash/canonical_json_v1/exponent_float/expected_rejection.txt b/test_vectors/hash/canonical_json_v1/exponent_float/expected_rejection.txt new file mode 100644 index 0000000..e405776 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/exponent_float/expected_rejection.txt @@ -0,0 +1 @@ +float_prohibited diff --git a/test_vectors/hash/canonical_json_v1/exponent_float/input.json b/test_vectors/hash/canonical_json_v1/exponent_float/input.json new file mode 100644 index 0000000..81545fc --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/exponent_float/input.json @@ -0,0 +1 @@ +{"schema_version":"v0","artifact_type":"CanonicalProbe.v0","value":1.23e1} diff --git a/test_vectors/hash/canonical_json_v1/exponent_float/legacy_digest.txt b/test_vectors/hash/canonical_json_v1/exponent_float/legacy_digest.txt new file mode 100644 index 0000000..8b998a2 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/exponent_float/legacy_digest.txt @@ -0,0 +1 @@ +sha256:ee0988ddac37caf27190760427186e9d3581b9187d6b9c050d2654a91c416bad diff --git a/test_vectors/hash/canonical_json_v1/float_value/expected_rejection.txt b/test_vectors/hash/canonical_json_v1/float_value/expected_rejection.txt new file mode 100644 index 0000000..e405776 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/float_value/expected_rejection.txt @@ -0,0 +1 @@ +float_prohibited diff --git a/test_vectors/hash/canonical_json_v1/float_value/input.json b/test_vectors/hash/canonical_json_v1/float_value/input.json new file mode 100644 index 0000000..32b400b --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/float_value/input.json @@ -0,0 +1 @@ +{"schema_version":"v0","artifact_type":"CanonicalProbe.v0","value":1.5} diff --git a/test_vectors/hash/canonical_json_v1/float_value/legacy_digest.txt b/test_vectors/hash/canonical_json_v1/float_value/legacy_digest.txt new file mode 100644 index 0000000..6845ac4 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/float_value/legacy_digest.txt @@ -0,0 +1 @@ +sha256:8f38eb3a19383edf58841a22817e063e3c31c06038c3c5081c4bb667211eed01 diff --git a/test_vectors/hash/canonical_json_v1/integer_above_safe_max/expected_rejection.txt b/test_vectors/hash/canonical_json_v1/integer_above_safe_max/expected_rejection.txt new file mode 100644 index 0000000..71bc260 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/integer_above_safe_max/expected_rejection.txt @@ -0,0 +1 @@ +integer_out_of_range diff --git a/test_vectors/hash/canonical_json_v1/integer_above_safe_max/input.json b/test_vectors/hash/canonical_json_v1/integer_above_safe_max/input.json new file mode 100644 index 0000000..22869b1 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/integer_above_safe_max/input.json @@ -0,0 +1 @@ +{"schema_version":"v0","artifact_type":"CanonicalProbe.v0","value":9007199254740992} diff --git a/test_vectors/hash/canonical_json_v1/integer_above_safe_max/legacy_digest.txt b/test_vectors/hash/canonical_json_v1/integer_above_safe_max/legacy_digest.txt new file mode 100644 index 0000000..b9d0dfc --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/integer_above_safe_max/legacy_digest.txt @@ -0,0 +1 @@ +sha256:856fd46ab7f0835ae7d75ae20c84ac401b0db9e60d2d3ac9d3455f086dda5c4f diff --git a/test_vectors/hash/canonical_json_v1/integer_below_safe_min/expected_rejection.txt b/test_vectors/hash/canonical_json_v1/integer_below_safe_min/expected_rejection.txt new file mode 100644 index 0000000..71bc260 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/integer_below_safe_min/expected_rejection.txt @@ -0,0 +1 @@ +integer_out_of_range diff --git a/test_vectors/hash/canonical_json_v1/integer_below_safe_min/input.json b/test_vectors/hash/canonical_json_v1/integer_below_safe_min/input.json new file mode 100644 index 0000000..f3539da --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/integer_below_safe_min/input.json @@ -0,0 +1 @@ +{"schema_version":"v0","artifact_type":"CanonicalProbe.v0","value":-9007199254740992} diff --git a/test_vectors/hash/canonical_json_v1/integer_below_safe_min/legacy_digest.txt b/test_vectors/hash/canonical_json_v1/integer_below_safe_min/legacy_digest.txt new file mode 100644 index 0000000..79ab7c6 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/integer_below_safe_min/legacy_digest.txt @@ -0,0 +1 @@ +sha256:1e60de6b623c33771d2f304e141197f9b95c2b3b476a31a04b0fa816859f0d87 diff --git a/test_vectors/hash/canonical_json_v1/max_safe_integer/canonical.txt b/test_vectors/hash/canonical_json_v1/max_safe_integer/canonical.txt new file mode 100644 index 0000000..163ff81 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/max_safe_integer/canonical.txt @@ -0,0 +1 @@ +{"artifact_type":"CanonicalProbe.v0","schema_version":"v0","value":9007199254740991} diff --git a/test_vectors/hash/canonical_json_v1/max_safe_integer/digest.txt b/test_vectors/hash/canonical_json_v1/max_safe_integer/digest.txt new file mode 100644 index 0000000..6350549 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/max_safe_integer/digest.txt @@ -0,0 +1 @@ +sha256:a175586b22560dc8d8182a20ef963b0e7dc053825f50e6198dfa833731f6843b diff --git a/test_vectors/hash/canonical_json_v1/max_safe_integer/input.json b/test_vectors/hash/canonical_json_v1/max_safe_integer/input.json new file mode 100644 index 0000000..6aa7057 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/max_safe_integer/input.json @@ -0,0 +1,5 @@ +{ + "schema_version": "v0", + "artifact_type": "CanonicalProbe.v0", + "value": 9007199254740991 +} diff --git a/test_vectors/hash/canonical_json_v1/min_safe_integer/canonical.txt b/test_vectors/hash/canonical_json_v1/min_safe_integer/canonical.txt new file mode 100644 index 0000000..503d65c --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/min_safe_integer/canonical.txt @@ -0,0 +1 @@ +{"artifact_type":"CanonicalProbe.v0","schema_version":"v0","value":-9007199254740991} diff --git a/test_vectors/hash/canonical_json_v1/min_safe_integer/digest.txt b/test_vectors/hash/canonical_json_v1/min_safe_integer/digest.txt new file mode 100644 index 0000000..f4aaa62 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/min_safe_integer/digest.txt @@ -0,0 +1 @@ +sha256:9de1314e4d4e6b55a1eaf175e6bd378d148bb64b57680c9d89350594cf28bc31 diff --git a/test_vectors/hash/canonical_json_v1/min_safe_integer/input.json b/test_vectors/hash/canonical_json_v1/min_safe_integer/input.json new file mode 100644 index 0000000..7479939 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/min_safe_integer/input.json @@ -0,0 +1,5 @@ +{ + "schema_version": "v0", + "artifact_type": "CanonicalProbe.v0", + "value": -9007199254740991 +} diff --git a/test_vectors/hash/canonical_json_v1/negative_zero_float/expected_rejection.txt b/test_vectors/hash/canonical_json_v1/negative_zero_float/expected_rejection.txt new file mode 100644 index 0000000..71806a9 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/negative_zero_float/expected_rejection.txt @@ -0,0 +1 @@ +negative_zero diff --git a/test_vectors/hash/canonical_json_v1/negative_zero_float/input.json b/test_vectors/hash/canonical_json_v1/negative_zero_float/input.json new file mode 100644 index 0000000..5179bd3 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/negative_zero_float/input.json @@ -0,0 +1 @@ +{"schema_version":"v0","artifact_type":"CanonicalProbe.v0","value":-0.0} diff --git a/test_vectors/hash/canonical_json_v1/negative_zero_float/legacy_digest.txt b/test_vectors/hash/canonical_json_v1/negative_zero_float/legacy_digest.txt new file mode 100644 index 0000000..9a411eb --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/negative_zero_float/legacy_digest.txt @@ -0,0 +1 @@ +sha256:8b0c2d611b402e821e8a1508c8c53f06e5ad19c71fed676427822350a46761de diff --git a/test_vectors/hash/canonical_json_v1/unicode_combining_forms/canonical.txt b/test_vectors/hash/canonical_json_v1/unicode_combining_forms/canonical.txt new file mode 100644 index 0000000..c97a9a6 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/unicode_combining_forms/canonical.txt @@ -0,0 +1 @@ +{"artifact_type":"CanonicalProbe.v0","schema_version":"v0","text":"café"} diff --git a/test_vectors/hash/canonical_json_v1/unicode_combining_forms/digest.txt b/test_vectors/hash/canonical_json_v1/unicode_combining_forms/digest.txt new file mode 100644 index 0000000..f560144 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/unicode_combining_forms/digest.txt @@ -0,0 +1 @@ +sha256:e08015c19297963fb750f050d30d625cb5d03a437a6c6aa4890f795d108c3da9 diff --git a/test_vectors/hash/canonical_json_v1/unicode_combining_forms/input.json b/test_vectors/hash/canonical_json_v1/unicode_combining_forms/input.json new file mode 100644 index 0000000..228fbe0 --- /dev/null +++ b/test_vectors/hash/canonical_json_v1/unicode_combining_forms/input.json @@ -0,0 +1,5 @@ +{ + "schema_version": "v0", + "artifact_type": "CanonicalProbe.v0", + "text": "café" +} diff --git a/test_vectors/hash/canonical_json_v1/vectors.json b/test_vectors/hash/canonical_json_v1/vectors.json index f63955f..a4c1d4d 100644 --- a/test_vectors/hash/canonical_json_v1/vectors.json +++ b/test_vectors/hash/canonical_json_v1/vectors.json @@ -50,6 +50,58 @@ "case_id": "exponent_forms_as_strings", "expected_digest": "sha256:4b6578e12b9c789046b101cdc1d0bd0475079fed0434d5c8ffd4505c484d647b", "canonical_json": "{\"artifact_type\":\"CanonicalProbe.v0\",\"schema_version\":\"v0\",\"scientific\":\"1.23e+4\"}" + }, + { + "case_id": "deeply_nested", + "expected_digest": "sha256:d15c46e1c94e75be8bcc55ae83c0b20d95a566a29e9212cb15db7de519d7d3a7", + "canonical_json": "{\"artifact_type\":\"CanonicalProbe.v0\",\"child\":{\"child\":{\"child\":{\"child\":{\"child\":{\"child\":{\"child\":{\"child\":{\"child\":{\"child\":{\"child\":{\"child\":{\"level\":11},\"level\":10},\"level\":9},\"level\":8},\"level\":7},\"level\":6},\"level\":5},\"level\":4},\"level\":3},\"level\":2},\"level\":1},\"level\":0},\"schema_version\":\"v0\"}" + }, + { + "case_id": "unicode_combining_forms", + "expected_digest": "sha256:e08015c19297963fb750f050d30d625cb5d03a437a6c6aa4890f795d108c3da9", + "canonical_json": "{\"artifact_type\":\"CanonicalProbe.v0\",\"schema_version\":\"v0\",\"text\":\"café\"}" + }, + { + "case_id": "escaped_control_characters", + "expected_digest": "sha256:abc1b7235ed6d0fa0a129ab9bc98087cb05ccaa51197d844b6f8aa7aa61b62d1", + "canonical_json": "{\"artifact_type\":\"CanonicalProbe.v0\",\"schema_version\":\"v0\",\"text\":\"a\\u0000b\\rb\\bc\\ff\"}" + }, + { + "case_id": "max_safe_integer", + "expected_digest": "sha256:a175586b22560dc8d8182a20ef963b0e7dc053825f50e6198dfa833731f6843b", + "canonical_json": "{\"artifact_type\":\"CanonicalProbe.v0\",\"schema_version\":\"v0\",\"value\":9007199254740991}" + }, + { + "case_id": "min_safe_integer", + "expected_digest": "sha256:9de1314e4d4e6b55a1eaf175e6bd378d148bb64b57680c9d89350594cf28bc31", + "canonical_json": "{\"artifact_type\":\"CanonicalProbe.v0\",\"schema_version\":\"v0\",\"value\":-9007199254740991}" + } + ], + "release_reject_cases": [ + { + "case_id": "float_value", + "expected_rejection": "float_prohibited", + "legacy_digest": "sha256:8f38eb3a19383edf58841a22817e063e3c31c06038c3c5081c4bb667211eed01" + }, + { + "case_id": "integer_above_safe_max", + "expected_rejection": "integer_out_of_range", + "legacy_digest": "sha256:856fd46ab7f0835ae7d75ae20c84ac401b0db9e60d2d3ac9d3455f086dda5c4f" + }, + { + "case_id": "integer_below_safe_min", + "expected_rejection": "integer_out_of_range", + "legacy_digest": "sha256:1e60de6b623c33771d2f304e141197f9b95c2b3b476a31a04b0fa816859f0d87" + }, + { + "case_id": "negative_zero_float", + "expected_rejection": "negative_zero", + "legacy_digest": "sha256:8b0c2d611b402e821e8a1508c8c53f06e5ad19c71fed676427822350a46761de" + }, + { + "case_id": "exponent_float", + "expected_rejection": "float_prohibited", + "legacy_digest": "sha256:ee0988ddac37caf27190760427186e9d3581b9187d6b9c050d2654a91c416bad" } ] } diff --git a/test_vectors/hash/computation_witness.vector.json b/test_vectors/hash/computation_witness.vector.json index 0c345c2..c6f1e5f 100644 --- a/test_vectors/hash/computation_witness.vector.json +++ b/test_vectors/hash/computation_witness.vector.json @@ -1,6 +1,6 @@ { "artifact_type": "ComputationWitness.v0", "input_file": "examples/computation_witness.valid.json", - "expected_digest": "sha256:68930f59c18ca213df059cec7b3097c5bdfc177bfbadb00ff0e14697f7fe8da8", - "canonical_json": "{\"checker\":\"certifyedge\",\"checker_version\":\"0.1.0\",\"code_commit\":\"e555555555555555555555555555555555555555\",\"code_repo\":\"https://github.com/example/scientific-computation-runner\",\"dataset_hash\":\"sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3\",\"environment_hash\":\"sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01\",\"result_hashes\":[\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],\"run_receipt_hash\":\"sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828\",\"schema_version\":\"v0\",\"source_commit\":\"b222222222222222222222222222222222222222\",\"source_repo\":\"https://github.com/fraware/CertifyEdge\",\"status\":\"CertificateChecked\",\"violations\":[],\"witness_id\":\"witness-sci-comp-repro-001\",\"workflow_id\":\"scientific_computation.reproducibility_v0\"}" + "expected_digest": "sha256:9ff049a2acf18d599e1254337d2daeb97452a0a77bb420f0970f9c22153d967e", + "canonical_json": "{\"checker\":\"certifyedge\",\"checker_version\":\"0.1.0\",\"code_commit\":\"e555555555555555555555555555555555555555\",\"code_repo\":\"https://github.com/example/scientific-computation-runner\",\"dataset_hash\":\"sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3\",\"environment_hash\":\"sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01\",\"result_hashes\":[\"sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c\"],\"run_receipt_hash\":\"sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828\",\"schema_version\":\"v0\",\"source_commit\":\"b222222222222222222222222222222222222222\",\"source_repo\":\"https://github.com/fraware/CertifyEdge\",\"status\":\"CertificateChecked\",\"violations\":[],\"witness_id\":\"witness-sci-comp-repro-001\",\"workflow_id\":\"scientific_computation.reproducibility_v0\"}" } diff --git a/test_vectors/hash/result_artifact.vector.json b/test_vectors/hash/result_artifact.vector.json index daf157c..ce96acd 100644 --- a/test_vectors/hash/result_artifact.vector.json +++ b/test_vectors/hash/result_artifact.vector.json @@ -1,6 +1,6 @@ { "artifact_type": "ResultArtifact.v0", "input_file": "examples/result_artifact.valid.json", - "expected_digest": "sha256:0e5c2b0fe61ffc5536c6bcb91e712a606e7feb19834075060471f70ae72c1887", - "canonical_json": "{\"description\":\"Primary reproducibility metric output\",\"media_type\":\"application/json\",\"path\":\"outputs/metrics.json\",\"produced_by_run\":\"run-sci-comp-001\",\"result_id\":\"result-metric-001\",\"result_kind\":\"metric\",\"schema_version\":\"v0\",\"sha256\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"size_bytes\":2048,\"source_commit\":\"e555555555555555555555555555555555555555\",\"source_repo\":\"https://github.com/example/scientific-computation-runner\"}" + "expected_digest": "sha256:4af53b473a1abb559db89efe906ce86bf974017825e5267ad33c97fb05650a6c", + "canonical_json": "{\"description\":\"Primary reproducibility metric output\",\"media_type\":\"application/json\",\"path\":\"outputs/metrics.json\",\"produced_by_run\":\"run-sci-comp-001\",\"result_id\":\"result-metric-001\",\"result_kind\":\"metric\",\"schema_version\":\"v0\",\"sha256\":\"sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c\",\"size_bytes\":46,\"source_commit\":\"e555555555555555555555555555555555555555\",\"source_repo\":\"https://github.com/example/scientific-computation-runner\"}" } diff --git a/test_vectors/hash/semantic_check_execution.vector.json b/test_vectors/hash/semantic_check_execution.vector.json index a8ee6fc..18cf45a 100644 --- a/test_vectors/hash/semantic_check_execution.vector.json +++ b/test_vectors/hash/semantic_check_execution.vector.json @@ -1,6 +1,6 @@ { "artifact_type": "SemanticCheckExecution.v0", "input_file": "examples/semantic_check_execution.valid.json", - "expected_digest": "sha256:f12bfa9e0707e3782eea0b978b389506b77cb5dac179bfdc05302e8a22d8e43e", - "canonical_json": "{\"checks\":[{\"allowed_to_skip\":false,\"artifact_type\":\"ArtifactIntegrity.v1\",\"check_id\":\"domain_separated_signature\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ArtifactIntegrity.v1.domain_separated_signature\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ArtifactIntegrity.v1\",\"check_id\":\"no_signature_or_digest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ArtifactIntegrity.v1.no_signature_or_digest\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ArtifactRegistry.v0\",\"check_id\":\"entries_cover_required_artifact_types\",\"enforcement_layer\":\"registry_metadata\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ArtifactRegistry.v0.entries_cover_required_artifact_types\",\"responsible_component\":\"pcs-core\",\"severity\":\"required\"},{\"allowed_to_skip\":false,\"artifact_type\":\"AssumptionSet.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"AssumptionSet.v0.source_commit_not_placeholder\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ClaimArtifact.v0\",\"check_id\":\"assumption_set_ref_present\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ClaimArtifact.v0.assumption_set_ref_present\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComponentReleaseFragment.v0\",\"check_id\":\"component_artifacts_match_release_pins\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComponentReleaseFragment.v0.component_artifacts_match_release_pins\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationRunReceipt.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationRunReceipt.v0.signature_or_digest_valid\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationRunReceipt.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationRunReceipt.v0.source_commit_not_placeholder\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"code_commit_present\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.code_commit_present\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"computation_status_checked_for_release\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.computation_status_checked_for_release\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"dataset_hash_matches_receipt\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.dataset_hash_matches_receipt\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"environment_hash_matches_receipt\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.environment_hash_matches_receipt\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"result_hashes_match_result_artifacts\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.result_hashes_match_result_artifacts\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"run_receipt_hash_matches_declared_run\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.run_receipt_hash_matches_declared_run\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.signature_or_digest_valid\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"source_commit_matches_release_manifest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.source_commit_matches_release_manifest\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"DatasetReceipt.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"DatasetReceipt.v0.signature_or_digest_valid\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"DatasetReceipt.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"DatasetReceipt.v0.source_commit_not_placeholder\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"EnvironmentReceipt.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"EnvironmentReceipt.v0.signature_or_digest_valid\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"EnvironmentReceipt.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"EnvironmentReceipt.v0.source_commit_not_placeholder\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"EvidenceBundle.v0\",\"check_id\":\"certificate_refs_resolve\",\"enforcement_layer\":\"consumer\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"EvidenceBundle.v0.certificate_refs_resolve\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"producer_responsible\"},{\"allowed_to_skip\":false,\"artifact_type\":\"HandoffManifest.v0\",\"check_id\":\"handoff_input_hashes_when_validated\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"HandoffManifest.v0.handoff_input_hashes_when_validated\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"LeanCheckResult.v0\",\"check_id\":\"lean_theorem_in_catalog\",\"enforcement_layer\":\"registry_metadata\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"LeanCheckResult.v0.lean_theorem_in_catalog\",\"responsible_component\":\"pcs-core\",\"severity\":\"required\"},{\"allowed_to_skip\":false,\"artifact_type\":\"LeanCheckResult.v0\",\"check_id\":\"obligation_results_match_proof_obligation\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"LeanCheckResult.v0.obligation_results_match_proof_obligation\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PCSProjectionManifest.v0\",\"check_id\":\"projection_entries_nonempty_values\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PCSProjectionManifest.v0.projection_entries_nonempty_values\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PCSProjectionManifest.v0\",\"check_id\":\"projection_hash_binds_proof_obligation\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PCSProjectionManifest.v0.projection_hash_binds_proof_obligation\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreAction.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreAction.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreAction.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreAction.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCapability.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCapability.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCapability.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCapability.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"claim_class_matches_assurance\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.claim_class_matches_assurance\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"lean_kernel_proof\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.lean_kernel_proof\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"lean_library_build\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.lean_library_build\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreContract.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreContract.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreContract.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreContract.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreEvent.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreEvent.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreEvent.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreEvent.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreHandoff.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreHandoff.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreHandoff.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreHandoff.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreKernelManifest.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreKernelManifest.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreKernelManifest.v0\",\"check_id\":\"unique_kernel_paths\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreKernelManifest.v0.unique_kernel_paths\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCorePrincipal.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCorePrincipal.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCorePrincipal.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCorePrincipal.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreReleaseBundleManifest.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreReleaseBundleManifest.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreReleaseBundleManifest.v0\",\"check_id\":\"schema_valid_before_path_follow\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreReleaseBundleManifest.v0.schema_valid_before_path_follow\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreResource.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreResource.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreResource.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreResource.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"claim_class_matches_assurance\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.claim_class_matches_assurance\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"lean_kernel_proof\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.lean_kernel_proof\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"lean_library_build\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.lean_library_build\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"claim_class_matches_assurance\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.claim_class_matches_assurance\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"lean_kernel_proof\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.lean_kernel_proof\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"lean_library_build\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.lean_library_build\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ProofObligation.v0\",\"check_id\":\"obligations_reference_known_kinds\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ProofObligation.v0.obligations_reference_known_kinds\",\"responsible_component\":\"pcs-core\",\"severity\":\"required\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ReleaseChainValidationResult.v0\",\"check_id\":\"status_matches_check_outcomes\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ReleaseChainValidationResult.v0.status_matches_check_outcomes\",\"responsible_component\":\"pcs-core\",\"severity\":\"validator_responsible\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ReleaseManifest.v0\",\"check_id\":\"artifact_hashes_match_files\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ReleaseManifest.v0.artifact_hashes_match_files\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ReleaseManifest.v0\",\"check_id\":\"release_mode_commit_policy\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ReleaseManifest.v0.release_mode_commit_policy\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ResultArtifact.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ResultArtifact.v0.signature_or_digest_valid\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ResultArtifact.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ResultArtifact.v0.source_commit_not_placeholder\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"RuntimeReceipt.v0\",\"check_id\":\"source_commit_matches_release_manifest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"RuntimeReceipt.v0.source_commit_matches_release_manifest\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"RuntimeReceipt.v0\",\"check_id\":\"trace_hash_present\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"RuntimeReceipt.v0.trace_hash_present\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ScienceClaimBundle.v0\",\"check_id\":\"certified_bundle_has_certificate_when_checked\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ScienceClaimBundle.v0.certified_bundle_has_certificate_when_checked\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ScienceClaimBundle.v0\",\"check_id\":\"non_empty_runtime_receipts\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ScienceClaimBundle.v0.non_empty_runtime_receipts\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"SignedScienceClaimBundle.v0\",\"check_id\":\"embedded_bundle_passes_science_claim_semantics\",\"enforcement_layer\":\"consumer\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"SignedScienceClaimBundle.v0.embedded_bundle_passes_science_claim_semantics\",\"responsible_component\":\"Provability Fabric\",\"severity\":\"producer_responsible\"},{\"allowed_to_skip\":false,\"artifact_type\":\"SignedScienceClaimBundle.v0\",\"check_id\":\"signed_input_bundle_hash_matches_certified\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"SignedScienceClaimBundle.v0.signed_input_bundle_hash_matches_certified\",\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"SourceSpan.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"SourceSpan.v0.source_commit_not_placeholder\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"certificate_status_checked_for_release\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.certificate_status_checked_for_release\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"no_unauthorized_tool_calls\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.no_unauthorized_tool_calls\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"policy_hash_matches_certificate\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.policy_hash_matches_certificate\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.signature_or_digest_valid\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"source_commit_matches_release_manifest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.source_commit_matches_release_manifest\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"tool_trace_hash_matches_certificate\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.tool_trace_hash_matches_certificate\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseTrace.v0\",\"check_id\":\"no_unknown_authorization_status\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseTrace.v0.no_unknown_authorization_status\",\"responsible_component\":\"agent-tool-use demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseTrace.v0\",\"check_id\":\"trace_hash_present\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseTrace.v0.trace_hash_present\",\"responsible_component\":\"agent-tool-use demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"TraceCertificate.v0\",\"check_id\":\"source_commit_matches_release_manifest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"TraceCertificate.v0.source_commit_matches_release_manifest\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"TraceCertificate.v0\",\"check_id\":\"status_is_certificate_checked_for_release\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"TraceCertificate.v0.status_is_certificate_checked_for_release\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"TraceCertificate.v0\",\"check_id\":\"trace_hash_matches_runtime_receipt\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"TraceCertificate.v0.trace_hash_matches_runtime_receipt\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"VerificationResult.v0\",\"check_id\":\"failed_checks_block_import_ready_status\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"VerificationResult.v0.failed_checks_block_import_ready_status\",\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"VerificationResult.v0\",\"check_id\":\"verified_input_bundle_hash_matches_certified\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"VerificationResult.v0.verified_input_bundle_hash_matches_certified\",\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"WorkflowProfile.v0\",\"check_id\":\"required_registry_entries_registered\",\"enforcement_layer\":\"registry_metadata\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"WorkflowProfile.v0.required_registry_entries_registered\",\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}],\"policy_id\":\"pcs-semantic-check-execution-v0.1\",\"policy_version\":\"0.1.0\",\"schema_version\":\"v0\",\"severity_definitions\":{\"consumer_responsible\":{\"description\":\"Consumer must execute at import/admission time.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"optional\":{\"description\":\"May be skipped; failures are non-fatal.\",\"downstream_must_report_execution\":false,\"fatal_if_skipped_in_release_mode\":false},\"producer_responsible\":{\"description\":\"Runtime producer must execute and attest before handoff.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"release_blocking\":{\"description\":\"Must run in release mode; blocks Validated/ProofChecked status.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"required\":{\"description\":\"Must run in release mode; failure is fatal.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"validator_responsible\":{\"description\":\"Release validator (pcs-core) must execute and cite in validation results.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"warning_only\":{\"description\":\"Non-blocking advisory check.\",\"downstream_must_report_execution\":false,\"fatal_if_skipped_in_release_mode\":false}}}" + "expected_digest": "sha256:7b9c38850357c43e738cb5f876367f5a7fbc68e1c51d7b6077d8a218199cb700", + "canonical_json": "{\"checks\":[{\"allowed_to_skip\":false,\"artifact_type\":\"ArtifactIntegrity.v1\",\"check_id\":\"domain_separated_signature\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ArtifactIntegrity.v1.domain_separated_signature\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ArtifactIntegrity.v1\",\"check_id\":\"no_signature_or_digest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ArtifactIntegrity.v1.no_signature_or_digest\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ArtifactRegistry.v0\",\"check_id\":\"entries_cover_required_artifact_types\",\"enforcement_layer\":\"registry_metadata\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ArtifactRegistry.v0.entries_cover_required_artifact_types\",\"responsible_component\":\"pcs-core\",\"severity\":\"required\"},{\"allowed_to_skip\":false,\"artifact_type\":\"AssumptionSet.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"AssumptionSet.v0.source_commit_not_placeholder\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ClaimArtifact.v0\",\"check_id\":\"assumption_set_ref_present\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ClaimArtifact.v0.assumption_set_ref_present\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComponentReleaseFragment.v0\",\"check_id\":\"component_artifacts_match_release_pins\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComponentReleaseFragment.v0.component_artifacts_match_release_pins\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationRunReceipt.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationRunReceipt.v0.signature_or_digest_valid\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationRunReceipt.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationRunReceipt.v0.source_commit_not_placeholder\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"code_commit_present\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.code_commit_present\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"computation_status_checked_for_release\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.computation_status_checked_for_release\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"dataset_hash_matches_receipt\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.dataset_hash_matches_receipt\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"environment_hash_matches_receipt\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.environment_hash_matches_receipt\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"result_hashes_match_result_artifacts\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.result_hashes_match_result_artifacts\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"run_receipt_hash_matches_declared_run\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.run_receipt_hash_matches_declared_run\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.signature_or_digest_valid\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ComputationWitness.v0\",\"check_id\":\"source_commit_matches_release_manifest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ComputationWitness.v0.source_commit_matches_release_manifest\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"DatasetReceipt.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"DatasetReceipt.v0.signature_or_digest_valid\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"DatasetReceipt.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"DatasetReceipt.v0.source_commit_not_placeholder\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"EnvironmentReceipt.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"EnvironmentReceipt.v0.signature_or_digest_valid\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"EnvironmentReceipt.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"EnvironmentReceipt.v0.source_commit_not_placeholder\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"EvidenceBundle.v0\",\"check_id\":\"certificate_refs_resolve\",\"enforcement_layer\":\"consumer\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"EvidenceBundle.v0.certificate_refs_resolve\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"producer_responsible\"},{\"allowed_to_skip\":false,\"artifact_type\":\"HandoffManifest.v0\",\"check_id\":\"handoff_input_hashes_when_validated\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"HandoffManifest.v0.handoff_input_hashes_when_validated\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"LeanCheckResult.v0\",\"check_id\":\"lean_theorem_in_catalog\",\"enforcement_layer\":\"registry_metadata\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"LeanCheckResult.v0.lean_theorem_in_catalog\",\"responsible_component\":\"pcs-core\",\"severity\":\"required\"},{\"allowed_to_skip\":false,\"artifact_type\":\"LeanCheckResult.v0\",\"check_id\":\"obligation_results_match_proof_obligation\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"LeanCheckResult.v0.obligation_results_match_proof_obligation\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PCSProjectionManifest.v0\",\"check_id\":\"projection_entries_nonempty_values\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PCSProjectionManifest.v0.projection_entries_nonempty_values\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PCSProjectionManifest.v0\",\"check_id\":\"projection_hash_binds_proof_obligation\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PCSProjectionManifest.v0.projection_hash_binds_proof_obligation\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreAction.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreAction.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreAction.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreAction.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreBundleVerificationResult.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreBundleVerificationResult.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCapability.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCapability.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCapability.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCapability.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"claim_class_matches_assurance\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.claim_class_matches_assurance\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"lean_kernel_proof\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.lean_kernel_proof\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"lean_library_build\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.lean_library_build\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreCertificate.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreCertificate.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreContract.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreContract.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreContract.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreContract.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreEffectFrame.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreEffectFrame.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreEffectFrame.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreEffectFrame.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreEvent.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreEvent.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreEvent.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreEvent.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreEvidenceManifest.v0\",\"check_id\":\"manifest_digest_matches\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreEvidenceManifest.v0.manifest_digest_matches\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreEvidenceManifest.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreEvidenceManifest.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreHandoff.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreHandoff.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreHandoff.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreHandoff.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreKernelManifest.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreKernelManifest.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreKernelManifest.v0\",\"check_id\":\"unique_kernel_paths\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreKernelManifest.v0.unique_kernel_paths\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCorePrincipal.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCorePrincipal.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCorePrincipal.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCorePrincipal.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreReleaseBundleManifest.v0\",\"check_id\":\"closed_evidence_digests\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreReleaseBundleManifest.v0.closed_evidence_digests\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreReleaseBundleManifest.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreReleaseBundleManifest.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreReleaseBundleManifest.v0\",\"check_id\":\"schema_valid_before_path_follow\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreReleaseBundleManifest.v0.schema_valid_before_path_follow\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreResource.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreResource.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreResource.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreResource.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"claim_class_matches_assurance\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.claim_class_matches_assurance\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"lean_kernel_proof\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.lean_kernel_proof\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"lean_library_build\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.lean_library_build\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreRuntimeObservation.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreRuntimeObservation.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTheoremManifest.v0\",\"check_id\":\"manifest_digest_matches\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTheoremManifest.v0.manifest_digest_matches\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTheoremManifest.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTheoremManifest.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"claim_class_matches_assurance\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.claim_class_matches_assurance\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"explicit_artifact_type\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.explicit_artifact_type\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"lean_kernel_proof\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.lean_kernel_proof\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"lean_library_build\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.lean_library_build\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"PFCoreTrace.v0\",\"check_id\":\"schema_valid\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"PFCoreTrace.v0.schema_valid\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ProofObligation.v0\",\"check_id\":\"obligations_reference_known_kinds\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ProofObligation.v0.obligations_reference_known_kinds\",\"responsible_component\":\"pcs-core\",\"severity\":\"required\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ReleaseChainValidationResult.v0\",\"check_id\":\"status_matches_check_outcomes\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ReleaseChainValidationResult.v0.status_matches_check_outcomes\",\"responsible_component\":\"pcs-core\",\"severity\":\"validator_responsible\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ReleaseManifest.v0\",\"check_id\":\"artifact_hashes_match_files\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ReleaseManifest.v0.artifact_hashes_match_files\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ReleaseManifest.v0\",\"check_id\":\"release_mode_commit_policy\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ReleaseManifest.v0.release_mode_commit_policy\",\"responsible_component\":\"pcs-core\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ResultArtifact.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ResultArtifact.v0.signature_or_digest_valid\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ResultArtifact.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ResultArtifact.v0.source_commit_not_placeholder\",\"responsible_component\":\"scientific-computation demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"RuntimeReceipt.v0\",\"check_id\":\"source_commit_matches_release_manifest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"RuntimeReceipt.v0.source_commit_matches_release_manifest\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"RuntimeReceipt.v0\",\"check_id\":\"trace_hash_present\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"RuntimeReceipt.v0.trace_hash_present\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ScienceClaimBundle.v0\",\"check_id\":\"certified_bundle_has_certificate_when_checked\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ScienceClaimBundle.v0.certified_bundle_has_certificate_when_checked\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ScienceClaimBundle.v0\",\"check_id\":\"non_empty_runtime_receipts\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ScienceClaimBundle.v0.non_empty_runtime_receipts\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"SignedScienceClaimBundle.v0\",\"check_id\":\"embedded_bundle_passes_science_claim_semantics\",\"enforcement_layer\":\"consumer\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"SignedScienceClaimBundle.v0.embedded_bundle_passes_science_claim_semantics\",\"responsible_component\":\"Provability Fabric\",\"severity\":\"producer_responsible\"},{\"allowed_to_skip\":false,\"artifact_type\":\"SignedScienceClaimBundle.v0\",\"check_id\":\"signed_input_bundle_hash_matches_certified\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"SignedScienceClaimBundle.v0.signed_input_bundle_hash_matches_certified\",\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"SourceSpan.v0\",\"check_id\":\"source_commit_not_placeholder\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"SourceSpan.v0.source_commit_not_placeholder\",\"responsible_component\":\"LabTrust-Gym\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"certificate_status_checked_for_release\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.certificate_status_checked_for_release\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"no_unauthorized_tool_calls\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.no_unauthorized_tool_calls\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"policy_hash_matches_certificate\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.policy_hash_matches_certificate\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"signature_or_digest_valid\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.signature_or_digest_valid\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"source_commit_matches_release_manifest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.source_commit_matches_release_manifest\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseCertificate.v0\",\"check_id\":\"tool_trace_hash_matches_certificate\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseCertificate.v0.tool_trace_hash_matches_certificate\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseTrace.v0\",\"check_id\":\"no_unknown_authorization_status\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseTrace.v0.no_unknown_authorization_status\",\"responsible_component\":\"agent-tool-use demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"ToolUseTrace.v0\",\"check_id\":\"trace_hash_present\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"ToolUseTrace.v0.trace_hash_present\",\"responsible_component\":\"agent-tool-use demo producer\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"TraceCertificate.v0\",\"check_id\":\"source_commit_matches_release_manifest\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"TraceCertificate.v0.source_commit_matches_release_manifest\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"TraceCertificate.v0\",\"check_id\":\"status_is_certificate_checked_for_release\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"TraceCertificate.v0.status_is_certificate_checked_for_release\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"TraceCertificate.v0\",\"check_id\":\"trace_hash_matches_runtime_receipt\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"TraceCertificate.v0.trace_hash_matches_runtime_receipt\",\"responsible_component\":\"CertifyEdge\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"VerificationResult.v0\",\"check_id\":\"failed_checks_block_import_ready_status\",\"enforcement_layer\":\"artifact_validate\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"VerificationResult.v0.failed_checks_block_import_ready_status\",\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"VerificationResult.v0\",\"check_id\":\"verified_input_bundle_hash_matches_certified\",\"enforcement_layer\":\"release_chain\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"VerificationResult.v0.verified_input_bundle_hash_matches_certified\",\"responsible_component\":\"Provability Fabric\",\"severity\":\"release_blocking\"},{\"allowed_to_skip\":false,\"artifact_type\":\"WorkflowProfile.v0\",\"check_id\":\"required_registry_entries_registered\",\"enforcement_layer\":\"registry_metadata\",\"execution_required_in_release_mode\":true,\"registry_ref\":\"WorkflowProfile.v0.required_registry_entries_registered\",\"responsible_component\":\"pcs-core\",\"severity\":\"required\"}],\"policy_id\":\"pcs-semantic-check-execution-v0.1\",\"policy_version\":\"0.1.0\",\"schema_version\":\"v0\",\"severity_definitions\":{\"consumer_responsible\":{\"description\":\"Consumer must execute at import/admission time.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"optional\":{\"description\":\"May be skipped; failures are non-fatal.\",\"downstream_must_report_execution\":false,\"fatal_if_skipped_in_release_mode\":false},\"producer_responsible\":{\"description\":\"Runtime producer must execute and attest before handoff.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"release_blocking\":{\"description\":\"Must run in release mode; blocks Validated/ProofChecked status.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"required\":{\"description\":\"Must run in release mode; failure is fatal.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"validator_responsible\":{\"description\":\"Release validator (pcs-core) must execute and cite in validation results.\",\"downstream_must_report_execution\":true,\"fatal_if_skipped_in_release_mode\":true},\"warning_only\":{\"description\":\"Non-blocking advisory check.\",\"downstream_must_report_execution\":false,\"fatal_if_skipped_in_release_mode\":false}}}" } diff --git a/typescript/packages/core/src/hash.ts b/typescript/packages/core/src/hash.ts index f7f8bf6..fb3b9f1 100644 --- a/typescript/packages/core/src/hash.ts +++ b/typescript/packages/core/src/hash.ts @@ -19,6 +19,23 @@ export const CANONICALIZATION_VERSION = "v1"; export const SAFE_INTEGER_MIN = -9007199254740991; export const SAFE_INTEGER_MAX = 9007199254740991; +/** Normalized rejection codes shared with Python and Rust release hashing. */ +export const REJECTION_FLOAT_PROHIBITED = "float_prohibited"; +export const REJECTION_INTEGER_OUT_OF_RANGE = "integer_out_of_range"; +export const REJECTION_NEGATIVE_ZERO = "negative_zero"; + +export class CanonicalizationError extends Error { + readonly code: string; + readonly path: string; + + constructor(code: string, message: string, path = "$") { + super(message); + this.name = "CanonicalizationError"; + this.code = code; + this.path = path; + } +} + export function isZeroSourceCommit(commit: string): boolean { const trimmed = commit.trim(); return trimmed.length > 0 && /^0+$/.test(trimmed); @@ -58,22 +75,112 @@ function sortValue(value: unknown): unknown { return value; } -export function canonicalizeForHash(data: Record): Record { +/** Enforce Canonical JSON v1 number policy (strict / release hashing). */ +export function assertCanonicalNumberPolicy(value: unknown, path = "$"): void { + if (typeof value === "boolean" || value === null || typeof value === "string") { + return; + } + if (typeof value === "number") { + if (Object.is(value, -0)) { + throw new CanonicalizationError( + REJECTION_NEGATIVE_ZERO, + `${path}: negative zero is prohibited under Canonical JSON v1`, + path, + ); + } + if (!Number.isInteger(value)) { + throw new CanonicalizationError( + REJECTION_FLOAT_PROHIBITED, + `${path}: float values are prohibited under Canonical JSON v1; use a normalized decimal string instead`, + path, + ); + } + if (!Number.isSafeInteger(value)) { + throw new CanonicalizationError( + REJECTION_INTEGER_OUT_OF_RANGE, + `${path}: integer ${value} outside safe-integer range [${SAFE_INTEGER_MIN}, ${SAFE_INTEGER_MAX}]`, + path, + ); + } + return; + } + if (typeof value === "bigint") { + if (value < BigInt(SAFE_INTEGER_MIN) || value > BigInt(SAFE_INTEGER_MAX)) { + throw new CanonicalizationError( + REJECTION_INTEGER_OUT_OF_RANGE, + `${path}: integer ${value.toString()} outside safe-integer range [${SAFE_INTEGER_MIN}, ${SAFE_INTEGER_MAX}]`, + path, + ); + } + return; + } + if (Array.isArray(value)) { + value.forEach((child, index) => { + assertCanonicalNumberPolicy(child, `${path}[${index}]`); + }); + return; + } + if (typeof value === "object") { + for (const [key, child] of Object.entries(value as Record)) { + assertCanonicalNumberPolicy(child, `${path}.${key}`); + } + } +} + +export function canonicalizeForHash( + data: Record, + options: { enforceNumberPolicy?: boolean } = {}, +): Record { const payload: Record = {}; for (const [key, value] of Object.entries(data)) { if (!HASH_EXCLUDED_FIELDS.has(key)) { payload[key] = value; } } + if (options.enforceNumberPolicy) { + assertCanonicalNumberPolicy(payload); + } return sortValue(payload) as Record; } -export function canonicalJsonBytes(data: Record): Uint8Array { - const canonical = canonicalizeForHash(data); +export function canonicalJsonBytes( + data: Record, + options: { enforceNumberPolicy?: boolean } = {}, +): Uint8Array { + const canonical = canonicalizeForHash(data, options); return Buffer.from(JSON.stringify(canonical), "utf8"); } -export function canonicalHash(data: Record): string { - const digest = createHash("sha256").update(canonicalJsonBytes(data)).digest("hex"); +export function canonicalHash( + data: Record, + options: { enforceNumberPolicy?: boolean } = {}, +): string { + const digest = createHash("sha256") + .update(canonicalJsonBytes(data, options)) + .digest("hex"); return `sha256:${digest}`; } + +/** Hash without the strict number policy (Phase 0 / legacy digest compatibility). */ +export function canonicalHashLegacy(data: Record): string { + return canonicalHash(data, { enforceNumberPolicy: false }); +} + +/** Hash with the strict number policy always enforced (release integrity envelopes). */ +export function canonicalHashRelease(data: Record): string { + return canonicalHash(data, { enforceNumberPolicy: true }); +} + +/** Return `{ digest }` or `{ rejection }` for cross-language vectors. */ +export function tryCanonicalHashRelease( + data: Record, +): { digest: string; rejection?: undefined } | { digest?: undefined; rejection: string } { + try { + return { digest: canonicalHashRelease(data) }; + } catch (err) { + if (err instanceof CanonicalizationError) { + return { rejection: err.code }; + } + throw err; + } +} From a7054f3c29349102e92247948bfe33e2c5920e60 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:02:13 -0700 Subject: [PATCH 16/24] Add ArtifactIntegrity Ed25519 checks and CertifyEdge pin machinery. Pin CertifyEdge provenance and trusted keys so attestation verification fails closed when pins or signatures drift from the registry. --- pins/README.md | 41 +- pins/certifyedge.json | 11 +- pins/github-actions.json | 12 + python/pcs_core/artifact_integrity.py | 710 ++++++++++++++++++++++ python/pcs_core/certifyedge_pin.py | 486 +++++++++++++++ python/pcs_core/external_attestation.py | 155 ++++- python/pcs_core/pf_core_certifyedge.py | 22 + python/pyproject.toml | 2 + python/requirements.lock | 3 + python/tests/test_artifact_integrity.py | 289 +++++++++ python/tests/test_certifyedge_pin.py | 152 +++++ python/tests/test_external_attestation.py | 61 ++ schemas/TrustedKeyRegistry.v0.schema.json | 62 ++ scripts/certifyedge-dev-fixture.py | 57 ++ scripts/provision-certifyedge.sh | 66 +- scripts/verify-certifyedge-pin.py | 124 ++-- 16 files changed, 2154 insertions(+), 99 deletions(-) create mode 100644 python/pcs_core/artifact_integrity.py create mode 100644 python/pcs_core/certifyedge_pin.py create mode 100644 python/tests/test_artifact_integrity.py create mode 100644 python/tests/test_certifyedge_pin.py create mode 100644 schemas/TrustedKeyRegistry.v0.schema.json create mode 100644 scripts/certifyedge-dev-fixture.py diff --git a/pins/README.md b/pins/README.md index de519bc..fb27d46 100644 --- a/pins/README.md +++ b/pins/README.md @@ -3,11 +3,24 @@ | File | Purpose | |------|---------| | `elan.json` | Elan installer URL + sha256 + default Lean toolchain | -| `github-actions.json` | Immutable commit SHAs for CI Actions | +| `python-base-image.json` | Verifier OCI base image index + amd64 digests | +| `github-actions.json` | Immutable commit SHAs for CI Actions (includes attest + download-artifact) | | `certifyedge.json` | CertifyEdge provision strategy (`status`, `provision_strategy`, digests) | CI installs elan only after checksum verification (`scripts/install-elan-verified.sh`). Workflows reference Actions as `owner/name@<40-char-sha> # `. +The verifier Dockerfile must reference `python-base-image.json` digests (no floating tags). + +Release provenance (`release-provenance.yml` / `release.yml`) uses +`actions/attest-build-provenance` and `actions/attest-sbom` (pinned in +`github-actions.json`). Consumer verification is +`scripts/verify-release-provenance.sh`. Until GitHub artifact attestations are +available for the repository plan, set `PCS_PROVENANCE_ALLOW_GATED=true` and +treat `attestation.status=gated` as non-claimable for SLSA marketing. + +Unified fail-closed checker (CertifyEdge + TrustedKeyRegistry + provenance policy): +`pcs release check-gates` / `scripts/check-release-gates.py` — see +`docs/pf-core/operator-release-gates.md`. ## CertifyEdge pin contract @@ -18,10 +31,34 @@ Workflows reference Actions as `owner/name@<40-char-sha> # `. | `unpinned` | No immutable digest yet — **fail closed in release mode** | | `pinned` | One of `oci_digest` / `signed_binary` / `source_commit_build` is fully specified | +`dev_fixture` (`scripts/certifyedge-dev-fixture.py`) is **test/preview only**. It exercises +provision → `provision.env` → trust-grade classification without inventing a production digest. +Release mode rejects `dev_fixture`. + Scripts: - `scripts/verify-certifyedge-pin.py --mode release|preview` -- `scripts/provision-certifyedge.sh` (honors `PCS_RELEASE_MODE`) +- `scripts/provision-certifyedge.sh` (honors `PCS_RELEASE_MODE`; writes `.tools/certifyedge/provision.env`) + +### provision.env contract + +Every successful provision writes a machine-readable env file: + +| Variable | Meaning | +|----------|---------| +| `PCS_CERTIFYEDGE_EXECUTABLE` | Canonical executable path | +| `PCS_CERTIFYEDGE_BINARY_DIGEST` | SHA-256 of the provisioned bytes | +| `PCS_CERTIFYEDGE_VERSION` | Pin version string | +| `PCS_CERTIFYEDGE_PIN_IDENTITY` | Stable pin identity (`oci:…@sha256:…`, `binary:…`, …) | +| `PCS_CERTIFYEDGE_PROVISION_STRATEGY` | Strategy used | +| `PCS_CERTIFYEDGE_TRUST_GRADE` | `pinned` \| `untrusted_development` \| `unpinned` | +| `PF_CORE_CERTIFYEDGE_CLI` | Compatibility alias for the executable path | + +Workflows **must source** this file and **must not** overwrite `PF_CORE_CERTIFYEDGE_CLI` +with an empty repository secret. Arbitrary PATH executables that do not match the pin +digest are classified `untrusted_development` even when the process exits 0. + +Release bundles carry `certifyedge_pin.json` (pin snapshot) alongside `tool_versions.json`. Do **not** invent placeholder digests that pretend to verify. Preview / technical preview releases may proceed with an explicit diff --git a/pins/certifyedge.json b/pins/certifyedge.json index 68d10e9..a868e11 100644 --- a/pins/certifyedge.json +++ b/pins/certifyedge.json @@ -11,10 +11,13 @@ "source_commit": "", "notes": [ "status=unpinned: no immutable CertifyEdge artifact is published for pcs-core yet.", - "Approved strategies when a digest exists: oci_digest | signed_binary | source_commit_build.", - "scripts/provision-certifyedge.sh and scripts/verify-certifyedge-pin.py fail closed in PCS_RELEASE_MODE=release when status!=pinned or digests are empty.", + "Approved production strategies when a digest exists: oci_digest | signed_binary | source_commit_build.", + "dev_fixture is test/preview only (scripts/certifyedge-dev-fixture.py); never a production pin.", + "scripts/provision-certifyedge.sh emits .tools/certifyedge/provision.env (executable, digest, version, pin identity, strategy, trust grade).", + "Workflows must source provision.env and must not overwrite PF_CORE_CERTIFYEDGE_CLI with an empty secret.", + "scripts/verify-certifyedge-pin.py fails closed in PCS_RELEASE_MODE=release when status!=pinned or digests are empty.", "Do not invent placeholder digests that pretend to verify.", - "Dev/preview may use mock:// or explicit stub://; release mode requires a provisioned live binary.", - "Optional runner secret PF_CORE_CERTIFYEDGE_CLI remains a last-resort override only after pin verification succeeds or for documented staging with ALLOW_STUB." + "Arbitrary PATH executables that do not match the pin digest are classified untrusted_development even when exit 0.", + "Dev/preview may use mock:// or explicit stub://; release mode requires a provisioned live binary matching the pin." ] } diff --git a/pins/github-actions.json b/pins/github-actions.json index ae1bca5..91b76c8 100644 --- a/pins/github-actions.json +++ b/pins/github-actions.json @@ -23,6 +23,18 @@ "actions/upload-artifact": { "ref": "v4.6.2", "sha": "ea165f8d65b6e75b540449e92b4886f43607fa02" + }, + "actions/download-artifact": { + "ref": "v4.3.0", + "sha": "d3f86a106a0bac45b974a628896c90dbdf5c8093" + }, + "actions/attest-build-provenance": { + "ref": "v3.2.0", + "sha": "96278af6caaf10aea03fd8d33a09a777ca52d62f" + }, + "actions/attest-sbom": { + "ref": "v2.2.0", + "sha": "115c3be05ff3974bcbd596578934b3f9ce39bf68" } }, "notes": "Workflows pin uses: owner/action@ # . Dependabot/Renovate may refresh these SHAs." diff --git a/python/pcs_core/artifact_integrity.py b/python/pcs_core/artifact_integrity.py new file mode 100644 index 0000000..80d72f9 --- /dev/null +++ b/python/pcs_core/artifact_integrity.py @@ -0,0 +1,710 @@ +"""ArtifactIntegrity.v1: Ed25519 sign/verify with trusted key registry. + +Domain-separated signing message (docs/trust-model.md): + + PCS::: + +pcs-core does not ship production private keys. Downstream verifiers pin an +allowlist of ed25519 public keys by ``key_id`` (see TrustedKeyRegistry.v0). +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import os +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Mapping, Sequence + +from nacl.exceptions import BadSignatureError +from nacl.signing import SigningKey, VerifyKey + +from pcs_core.hash import ( + ARTIFACT_DIGEST_FIELD, + CANONICALIZATION_VERSION, + HASH_EXCLUDED_FIELDS, + SIGNATURE_FIELD, + SIGNATURE_OBJECT_FIELD, + attach_artifact_digest, + canonical_hash, + domain_separated_signing_message, +) + +# Artifacts that stable releases must authenticate (digest-only is preview-only). +STABLE_RELEASE_SIGNED_ARTIFACT_TYPES: frozenset[str] = frozenset( + { + "ReleaseManifest.v0", + "PFCoreReleaseBundleManifest.v0", + "PFCoreCertificate.v0", + "LeanCheckResult.v0", + "ExternalAttestation.v0", + "PublicationBundle.v0", + } +) + +DEFAULT_MAX_SIGNATURE_AGE = timedelta(days=365) +DEFAULT_FUTURE_SKEW = timedelta(minutes=5) + + +class IntegrityError(ValueError): + """Raised when signature verification or key-policy checks fail.""" + + +@dataclass(frozen=True) +class TrustedKey: + key_id: str + algorithm: str + public_key_bytes: bytes + valid_from: datetime + valid_until: datetime | None + revoked_at: datetime | None + purposes: frozenset[str] + note: str | None = None + + def is_revoked_at(self, when: datetime) -> bool: + return self.revoked_at is not None and when >= self.revoked_at + + def is_valid_at(self, when: datetime) -> bool: + if when < self.valid_from: + return False + if self.valid_until is not None and when > self.valid_until: + return False + if self.is_revoked_at(when): + return False + return True + + +@dataclass(frozen=True) +class TrustedKeyRegistry: + keys: tuple[TrustedKey, ...] + registry_id: str | None = None + + def get(self, key_id: str) -> TrustedKey | None: + for key in self.keys: + if key.key_id == key_id: + return key + return None + + def require(self, key_id: str) -> TrustedKey: + key = self.get(key_id) + if key is None: + raise IntegrityError(f"UnknownKeyId: {key_id!r} not in trusted key registry") + return key + + +@dataclass(frozen=True) +class TimestampPolicy: + """Policy for ``signed_at`` relative to key validity and wall clock.""" + + max_age: timedelta | None = DEFAULT_MAX_SIGNATURE_AGE + future_skew: timedelta = DEFAULT_FUTURE_SKEW + now: datetime | None = None + + def evaluate(self, signed_at: datetime, key: TrustedKey) -> list[str]: + errors: list[str] = [] + now = self.now or datetime.now(timezone.utc) + if signed_at.tzinfo is None: + signed_at = signed_at.replace(tzinfo=timezone.utc) + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + + if not key.is_valid_at(signed_at): + if key.is_revoked_at(signed_at): + errors.append( + f"KeyRevokedAtSignatureTime: key_id={key.key_id!r} " + f"revoked_at={_format_utc(key.revoked_at)} " + f"signed_at={_format_utc(signed_at)}" + ) + else: + errors.append( + f"KeyOutsideValidityInterval: key_id={key.key_id!r} " + f"signed_at={_format_utc(signed_at)} " + f"valid_from={_format_utc(key.valid_from)} " + f"valid_until={_format_utc(key.valid_until)}" + ) + + if signed_at > now + self.future_skew: + errors.append( + f"SignatureTimestampInFuture: signed_at={_format_utc(signed_at)} " + f"now={_format_utc(now)}" + ) + + if self.max_age is not None and signed_at < now - self.max_age: + errors.append( + f"SignatureTimestampTooOld: signed_at={_format_utc(signed_at)} " + f"max_age_seconds={int(self.max_age.total_seconds())}" + ) + return errors + + +def _format_utc(value: datetime | None) -> str: + if value is None: + return "null" + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def parse_utc_datetime(value: str) -> datetime: + raw = value.strip() + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def encode_key_bytes(raw: bytes) -> str: + """Encode key/signature bytes as unpadded base64url.""" + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def decode_key_bytes(value: str, *, expected_len: int | None = None) -> bytes: + """Decode base64url (preferred) or standard base64; optional hex fallback.""" + text = value.strip() + if not text: + raise IntegrityError("empty key/signature encoding") + padded_url = text + ("=" * ((4 - len(text) % 4) % 4)) + try: + raw = base64.urlsafe_b64decode(padded_url.encode("ascii")) + except (binascii.Error, ValueError): + try: + raw = base64.b64decode(text.encode("ascii")) + except (binascii.Error, ValueError): + try: + raw = bytes.fromhex(text.removeprefix("0x")) + except ValueError as exc: + raise IntegrityError(f"invalid key/signature encoding: {exc}") from exc + if expected_len is not None and len(raw) != expected_len: + raise IntegrityError(f"decoded key/signature length {len(raw)} != expected {expected_len}") + return raw + + +def generate_ed25519_keypair() -> tuple[bytes, bytes]: + """Return ``(private_seed_32, public_key_32)`` for tests and local tooling.""" + signing = SigningKey.generate() + return bytes(signing), bytes(signing.verify_key) + + +def signing_key_from_seed(seed: bytes) -> SigningKey: + if len(seed) != 32: + raise IntegrityError(f"ed25519 private seed must be 32 bytes, got {len(seed)}") + return SigningKey(seed) + + +def build_trusted_key( + *, + key_id: str, + public_key: bytes | str, + valid_from: str | datetime, + valid_until: str | datetime | None = None, + revoked_at: str | datetime | None = None, + purposes: Sequence[str] | None = None, + note: str | None = None, +) -> TrustedKey: + pub = ( + public_key + if isinstance(public_key, bytes) + else decode_key_bytes(public_key, expected_len=32) + ) + vf = valid_from if isinstance(valid_from, datetime) else parse_utc_datetime(valid_from) + vu = None + if valid_until is not None: + vu = valid_until if isinstance(valid_until, datetime) else parse_utc_datetime(valid_until) + rev = None + if revoked_at is not None: + rev = revoked_at if isinstance(revoked_at, datetime) else parse_utc_datetime(revoked_at) + return TrustedKey( + key_id=key_id, + algorithm="ed25519", + public_key_bytes=pub, + valid_from=vf, + valid_until=vu, + revoked_at=rev, + purposes=frozenset(purposes or ()), + note=note, + ) + + +def trusted_key_to_dict(key: TrustedKey) -> dict[str, Any]: + out: dict[str, Any] = { + "key_id": key.key_id, + "algorithm": key.algorithm, + "public_key": encode_key_bytes(key.public_key_bytes), + "valid_from": _format_utc(key.valid_from), + } + if key.valid_until is not None: + out["valid_until"] = _format_utc(key.valid_until) + if key.revoked_at is not None: + out["revoked_at"] = _format_utc(key.revoked_at) + if key.purposes: + out["purposes"] = sorted(key.purposes) + if key.note: + out["note"] = key.note + return out + + +def build_trusted_key_registry( + keys: Sequence[TrustedKey], + *, + registry_id: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "schema_version": "v0", + "artifact_type": "TrustedKeyRegistry.v0", + "canonicalization_version": CANONICALIZATION_VERSION, + "keys": [trusted_key_to_dict(k) for k in keys], + } + if registry_id: + payload["registry_id"] = registry_id + payload["signature_or_digest"] = canonical_hash(payload) + return payload + + +def load_trusted_key_registry(data: Mapping[str, Any] | Path | str) -> TrustedKeyRegistry: + if isinstance(data, (str, Path)): + path = Path(data) + payload = json.loads(path.read_text(encoding="utf-8")) + else: + payload = dict(data) + if not isinstance(payload, dict): + raise IntegrityError("TrustedKeyRegistry root must be an object") + if payload.get("artifact_type") not in {None, "TrustedKeyRegistry.v0"}: + raise IntegrityError( + f"unexpected artifact_type for key registry: {payload.get('artifact_type')!r}" + ) + keys_raw = payload.get("keys") + if not isinstance(keys_raw, list): + raise IntegrityError("TrustedKeyRegistry.keys must be an array") + keys: list[TrustedKey] = [] + seen: set[str] = set() + for index, entry in enumerate(keys_raw): + if not isinstance(entry, dict): + raise IntegrityError(f"TrustedKeyRegistry.keys[{index}] must be an object") + key_id = str(entry.get("key_id") or "") + if not key_id: + raise IntegrityError(f"TrustedKeyRegistry.keys[{index}] missing key_id") + if key_id in seen: + raise IntegrityError(f"DuplicateKeyId: {key_id!r}") + seen.add(key_id) + if entry.get("algorithm") not in {None, "ed25519"}: + raise IntegrityError( + f"unsupported algorithm for {key_id!r}: {entry.get('algorithm')!r}" + ) + purposes = entry.get("purposes") or [] + if not isinstance(purposes, list): + raise IntegrityError(f"purposes for {key_id!r} must be an array") + keys.append( + build_trusted_key( + key_id=key_id, + public_key=str(entry["public_key"]), + valid_from=str(entry["valid_from"]), + valid_until=entry.get("valid_until"), + revoked_at=entry.get("revoked_at"), + purposes=[str(p) for p in purposes], + note=str(entry["note"]) if entry.get("note") else None, + ) + ) + registry_id = payload.get("registry_id") + return TrustedKeyRegistry( + keys=tuple(keys), + registry_id=str(registry_id) if registry_id else None, + ) + + +def resolve_trusted_key_registry( + path: Path | str | None = None, +) -> TrustedKeyRegistry | None: + """Load registry from explicit path or ``PCS_TRUSTED_KEY_REGISTRY`` env.""" + resolved = path + if resolved is None: + env = os.environ.get("PCS_TRUSTED_KEY_REGISTRY", "").strip() + if not env: + return None + resolved = env + return load_trusted_key_registry(resolved) + + +def signing_message_bytes( + *, + artifact_type: str, + schema_version: str, + artifact_digest: str, +) -> bytes: + return domain_separated_signing_message( + artifact_type=artifact_type, + schema_version=schema_version, + artifact_digest=artifact_digest, + ).encode("utf-8") + + +def sign_ed25519( + message: bytes, + *, + private_seed: bytes, +) -> bytes: + return bytes(signing_key_from_seed(private_seed).sign(message).signature) + + +def verify_ed25519( + message: bytes, + signature: bytes, + *, + public_key: bytes, +) -> None: + try: + VerifyKey(public_key).verify(message, signature) + except BadSignatureError as exc: + raise IntegrityError("SignatureVerificationFailed: ed25519 signature invalid") from exc + + +def _strip_integrity_fields(data: Mapping[str, Any]) -> dict[str, Any]: + body = dict(data) + for field in HASH_EXCLUDED_FIELDS: + body.pop(field, None) + return body + + +def compute_artifact_digest( + data: Mapping[str, Any], + *, + enforce_number_policy: bool = True, +) -> str: + body = _strip_integrity_fields(data) + body[ARTIFACT_DIGEST_FIELD] = "sha256:" + ("0" * 64) + body.setdefault("canonicalization_version", CANONICALIZATION_VERSION) + return canonical_hash(body, enforce_number_policy=enforce_number_policy) + + +def sign_artifact( + data: Mapping[str, Any], + *, + private_seed: bytes, + key_id: str, + signed_at: str | datetime | None = None, + enforce_number_policy: bool = True, +) -> dict[str, Any]: + """Attach ``artifact_digest`` + ed25519 ``signature`` (ArtifactIntegrity.v1 envelope).""" + sealed = attach_artifact_digest(dict(data), enforce_number_policy=enforce_number_policy) + artifact_type = str(sealed.get("artifact_type") or "") + schema_version = str(sealed.get("schema_version") or "") + if not artifact_type or not schema_version: + raise IntegrityError("artifact_type and schema_version required before signing") + digest = str(sealed[ARTIFACT_DIGEST_FIELD]) + message = signing_message_bytes( + artifact_type=artifact_type, + schema_version=schema_version, + artifact_digest=digest, + ) + sig = sign_ed25519(message, private_seed=private_seed) + when = signed_at if signed_at is not None else datetime.now(timezone.utc) + if isinstance(when, datetime): + when_str = _format_utc(when) + else: + when_str = when + sealed[SIGNATURE_OBJECT_FIELD] = { + "algorithm": "ed25519", + "key_id": key_id, + "signed_at": when_str, + "value": encode_key_bytes(sig), + } + sealed.pop(SIGNATURE_FIELD, None) + return sealed + + +def build_integrity_sidecar( + target: Mapping[str, Any], + *, + private_seed: bytes, + key_id: str, + signed_at: str | datetime | None = None, +) -> dict[str, Any]: + """Build a thin ArtifactIntegrity.v1 sidecar binding ``target``'s content digest.""" + target_digest = compute_artifact_digest(target) + target_type = str(target.get("artifact_type") or "UnknownArtifact") + envelope: dict[str, Any] = { + "schema_version": "v1", + "artifact_type": "ArtifactIntegrity.v1", + "canonicalization_version": CANONICALIZATION_VERSION, + "target_artifact_type": target_type, + "target_schema_version": str(target.get("schema_version") or ""), + "target_digest": target_digest, + "payload": {"bound_artifact_type": target_type}, + } + # Sign over ArtifactIntegrity.v1 identity with digest of the integrity body. + return sign_artifact( + envelope, + private_seed=private_seed, + key_id=key_id, + signed_at=signed_at, + ) + + +def verify_artifact_signature( + data: Mapping[str, Any], + registry: TrustedKeyRegistry, + *, + timestamp_policy: TimestampPolicy | None = None, + required_purpose: str | None = None, + expect_digest: str | None = None, +) -> list[str]: + """Verify domain-separated ed25519 signature against a trusted key registry.""" + errors: list[str] = [] + if SIGNATURE_FIELD in data: + errors.append("ArtifactIntegrityLegacyField: signature_or_digest forbidden on v1 envelopes") + + digest = str(data.get(ARTIFACT_DIGEST_FIELD) or "") + if not digest.startswith("sha256:") or len(digest) != 71: + errors.append("ArtifactIntegrityDigestMissing: artifact_digest required") + return errors + + recomputed = compute_artifact_digest(data) + if digest != recomputed: + errors.append( + f"ArtifactIntegrityDigestMismatch: recorded {digest!r} != recomputed {recomputed!r}" + ) + + if expect_digest is not None and digest != expect_digest and recomputed != expect_digest: + # Sidecar style: allow artifact_digest to cover the integrity envelope while + # target_digest separately binds the signed object. + target_digest = str(data.get("target_digest") or "") + if target_digest != expect_digest: + errors.append( + f"ArtifactIntegrityTargetDigestMismatch: " + f"expected {expect_digest!r}, got envelope={digest!r} target={target_digest!r}" + ) + + sig = data.get(SIGNATURE_OBJECT_FIELD) + if not isinstance(sig, Mapping): + errors.append("ArtifactIntegritySignatureMissing: signature object required") + return errors + + if sig.get("algorithm") != "ed25519": + errors.append(f"UnsupportedSignatureAlgorithm: {sig.get('algorithm')!r}") + return errors + + key_id = str(sig.get("key_id") or "") + signed_at_raw = str(sig.get("signed_at") or "") + value = str(sig.get("value") or "") + if not key_id or not signed_at_raw or not value: + errors.append("ArtifactIntegritySignatureIncomplete: key_id/signed_at/value required") + return errors + + try: + key = registry.require(key_id) + except IntegrityError as exc: + errors.append(str(exc)) + return errors + + if required_purpose and required_purpose not in key.purposes and key.purposes: + errors.append(f"KeyPurposeMismatch: key_id={key_id!r} missing purpose {required_purpose!r}") + + try: + signed_at = parse_utc_datetime(signed_at_raw) + except ValueError as exc: + errors.append(f"InvalidSignatureTimestamp: {exc}") + return errors + + policy = timestamp_policy or TimestampPolicy() + errors.extend(policy.evaluate(signed_at, key)) + + artifact_type = str(data.get("artifact_type") or "") + schema_version = str(data.get("schema_version") or "") + try: + message = signing_message_bytes( + artifact_type=artifact_type, + schema_version=schema_version, + artifact_digest=digest, + ) + signature = decode_key_bytes(value, expected_len=64) + verify_ed25519(message, signature, public_key=key.public_key_bytes) + except IntegrityError as exc: + errors.append(str(exc)) + return errors + + +def validate_artifact_integrity_semantics( + data: Mapping[str, Any], + *, + registry: TrustedKeyRegistry | None = None, + require_crypto_verify: bool = False, +) -> list[str]: + """Semantic checks for ArtifactIntegrity.v1 (shape + optional crypto verify).""" + errors: list[str] = [] + if SIGNATURE_FIELD in data: + errors.append( + "no_signature_or_digest: signature_or_digest is forbidden on ArtifactIntegrity.v1" + ) + + digest = str(data.get(ARTIFACT_DIGEST_FIELD) or "") + sig = data.get(SIGNATURE_OBJECT_FIELD) + if not digest.startswith("sha256:"): + errors.append("domain_separated_signature: artifact_digest missing or malformed") + if not isinstance(sig, Mapping): + errors.append("domain_separated_signature: signature object missing") + else: + artifact_type = str(data.get("artifact_type") or "") + schema_version = str(data.get("schema_version") or "") + if digest.startswith("sha256:") and artifact_type and schema_version: + try: + domain_separated_signing_message( + artifact_type=artifact_type, + schema_version=schema_version, + artifact_digest=digest, + ) + except ValueError as exc: + errors.append(f"domain_separated_signature: {exc}") + + resolved = registry + if resolved is None and (require_crypto_verify or os.environ.get("PCS_TRUSTED_KEY_REGISTRY")): + resolved = resolve_trusted_key_registry() + + if require_crypto_verify and resolved is None: + errors.append( + "domain_separated_signature: cryptographic verify required but no trusted key registry" + ) + return errors + + if resolved is not None and isinstance(sig, Mapping) and sig.get("algorithm") == "ed25519": + errors.extend(verify_artifact_signature(data, resolved)) + return errors + + +def integrity_sidecar_path(artifact_path: Path) -> Path: + return artifact_path.with_suffix(artifact_path.suffix + ".integrity.json") + + +def discover_integrity_envelope( + artifact_path: Path, + artifact: Mapping[str, Any] | None = None, +) -> dict[str, Any] | None: + """Locate an integrity envelope: embedded field, sidecar, or ArtifactIntegrity.v1 file.""" + if artifact is not None: + embedded = artifact.get("artifact_integrity") + if isinstance(embedded, Mapping): + return dict(embedded) + if ( + artifact.get("artifact_type") == "ArtifactIntegrity.v1" + and ARTIFACT_DIGEST_FIELD in artifact + and SIGNATURE_OBJECT_FIELD in artifact + ): + return dict(artifact) + + candidates = [ + integrity_sidecar_path(artifact_path), + artifact_path.parent / "ArtifactIntegrity.v1.json", + artifact_path.parent / f"{artifact_path.stem}.integrity.json", + artifact_path.parent / "PFCoreCertificate.v0.integrity.json", + ] + for path in candidates: + if path.is_file(): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if isinstance(payload, dict): + return payload + return None + + +def verify_release_root_signatures( + release_root: Path, + registry: TrustedKeyRegistry, + *, + required_types: Sequence[str] | None = None, + timestamp_policy: TimestampPolicy | None = None, + allow_digest_only: bool = False, +) -> list[str]: + """Verify signatures for critical artifacts under a release root. + + When ``allow_digest_only`` is True (preview / development), missing signatures + are reported as warnings-style codes prefixed with ``DigestOnlyAllowed:`` and + do not fail the check set returned here as hard errors — callers that want + soft preview mode should pass ``allow_digest_only=True`` and filter. + """ + root = release_root.resolve(strict=True) + wanted = frozenset(required_types or STABLE_RELEASE_SIGNED_ARTIFACT_TYPES) + errors: list[str] = [] + found_types: set[str] = set() + + for path in sorted(root.rglob("*.json")): + if path.name.endswith(".integrity.json"): + continue + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(payload, dict): + continue + artifact_type = str(payload.get("artifact_type") or "") + if artifact_type not in wanted: + continue + found_types.add(artifact_type) + rel = path.relative_to(root).as_posix() + + envelope = discover_integrity_envelope(path, payload) + if envelope is None: + msg = f"MissingAuthenticatedIntegrity: {rel} ({artifact_type})" + if allow_digest_only: + errors.append(f"DigestOnlyAllowed: {msg}") + else: + errors.append(msg) + continue + + target_digest = compute_artifact_digest(payload) + verify_errors = verify_artifact_signature( + envelope, + registry, + timestamp_policy=timestamp_policy, + required_purpose="release_signing", + expect_digest=target_digest, + ) + for err in verify_errors: + errors.append(f"{rel}: {err}") + + if not allow_digest_only: + missing = wanted - found_types + # PublicationBundle may be absent on intermediate roots; only require types present. + # Hard-require types that exist as files elsewhere is handled above; here we only + # note absence of PF-Core / PCS manifests when the directory looks like a release. + _ = missing + return errors + + +def revoke_key_in_registry( + registry_data: Mapping[str, Any], + key_id: str, + *, + revoked_at: str | datetime | None = None, +) -> dict[str, Any]: + """Return a copy of a registry with ``key_id`` marked revoked.""" + payload = dict(registry_data) + keys = payload.get("keys") + if not isinstance(keys, list): + raise IntegrityError("TrustedKeyRegistry.keys must be an array") + when = ( + _format_utc(revoked_at) + if isinstance(revoked_at, datetime) + else (revoked_at or _format_utc(datetime.now(timezone.utc))) + ) + updated: list[Any] = [] + found = False + for entry in keys: + if not isinstance(entry, dict): + updated.append(entry) + continue + copy = dict(entry) + if copy.get("key_id") == key_id: + copy["revoked_at"] = when + found = True + updated.append(copy) + if not found: + raise IntegrityError(f"UnknownKeyId: {key_id!r}") + payload["keys"] = updated + payload.pop("signature_or_digest", None) + payload["signature_or_digest"] = canonical_hash(payload) + return payload diff --git a/python/pcs_core/certifyedge_pin.py b/python/pcs_core/certifyedge_pin.py new file mode 100644 index 0000000..58784e9 --- /dev/null +++ b/python/pcs_core/certifyedge_pin.py @@ -0,0 +1,486 @@ +"""CertifyEdge pin loading, trust grade, and provision-environment contract. + +Production pin (`pins/certifyedge.json`) remains ``status=unpinned`` until an +immutable CertifyEdge OCI digest / signed binary / locked source commit is +published. Do not invent placeholder digests. + +``dev_fixture`` provisions a deterministic local fixture for tests and +preview tooling only — never a production trust root. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, Mapping + +from pcs_core.paths import repo_root + +TrustGrade = Literal["pinned", "untrusted_development", "unpinned"] +ProvisionStrategy = Literal[ + "none", + "oci_digest", + "signed_binary", + "source_commit_build", + "dev_fixture", +] + +DIGEST_RE = re.compile(r"^sha256:[a-f0-9]{64}$") +COMMIT_RE = re.compile(r"^[a-f0-9]{40}$") +PLACEHOLDER_MARKERS = ( + "REPLACE_WITH", + "REPLACE_ME", + "example/certifyedge", + "sha256:REPLACE", +) + +# Deterministic fixture body used by scripts/certifyedge-dev-fixture.py and +# provision-certifyedge.sh (dev_fixture strategy). Digest is content-addressed. +DEV_FIXTURE_MARKER = b"PCS_CERTIFYEDGE_DEV_FIXTURE_V1\n" +PROVISION_ENV_NAME = "provision.env" +DEFAULT_INSTALL_DIR_REL = Path(".tools") / "certifyedge" + + +@dataclass(frozen=True) +class CertifyEdgePin: + status: str + version: str + provision_strategy: str + image: str + image_digest: str + binary_url: str + binary_sha256: str + source_repo: str + source_commit: str + pin_identity: str + raw: Mapping[str, Any] + + @property + def is_pinned(self) -> bool: + return self.status == "pinned" + + @property + def expected_binary_digest(self) -> str | None: + if self.provision_strategy in {"signed_binary", "dev_fixture"}: + digest = self.binary_sha256.strip() + if DIGEST_RE.match(digest): + return digest + if re.fullmatch(r"[a-f0-9]{64}", digest): + return f"sha256:{digest}" + if self.provision_strategy == "oci_digest" and DIGEST_RE.match(self.image_digest): + return self.image_digest + return None + + +@dataclass(frozen=True) +class ProvisionEnvironment: + executable_path: str + binary_digest: str + version: str + pin_identity: str + provision_strategy: str + trust_grade: TrustGrade + + def to_env_lines(self) -> list[str]: + return [ + f"PCS_CERTIFYEDGE_EXECUTABLE={self.executable_path}", + f"PCS_CERTIFYEDGE_BINARY_DIGEST={self.binary_digest}", + f"PCS_CERTIFYEDGE_VERSION={self.version}", + f"PCS_CERTIFYEDGE_PIN_IDENTITY={self.pin_identity}", + f"PCS_CERTIFYEDGE_PROVISION_STRATEGY={self.provision_strategy}", + f"PCS_CERTIFYEDGE_TRUST_GRADE={self.trust_grade}", + # Compatibility alias consumed by existing workflows / CLI. + f"PF_CORE_CERTIFYEDGE_CLI={self.executable_path}", + ] + + def write(self, path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(self.to_env_lines()) + "\n", encoding="utf-8") + return path + + +def _is_placeholder(value: str) -> bool: + if not value or not value.strip(): + return True + upper = value.upper() + return any(marker.upper() in upper for marker in PLACEHOLDER_MARKERS) + + +def file_sha256_digest(path: Path) -> str: + return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}" + + +def dev_fixture_digest() -> str: + return f"sha256:{hashlib.sha256(DEV_FIXTURE_MARKER).hexdigest()}" + + +def pin_identity_from(pin: Mapping[str, Any]) -> str: + status = str(pin.get("status") or "unknown") + strategy = str(pin.get("provision_strategy") or "none") + if strategy == "oci_digest": + digest = str(pin.get("image_digest") or "") + image = str(pin.get("image") or "certifyedge") + return f"oci:{image}@{digest}" if digest else f"oci:{image}:unpinned" + if strategy == "signed_binary": + digest = str(pin.get("binary_sha256") or "") + return f"binary:{digest}" if digest else "binary:unpinned" + if strategy == "source_commit_build": + commit = str(pin.get("source_commit") or "") + repo = str(pin.get("source_repo") or "") + return f"source:{repo}@{commit}" if commit else "source:unpinned" + if strategy == "dev_fixture": + digest = str(pin.get("binary_sha256") or dev_fixture_digest()) + return f"dev_fixture:{digest}" + return f"{status}:{strategy}" + + +def load_certifyedge_pin(path: Path | None = None) -> CertifyEdgePin: + pin_path = path or (repo_root() / "pins" / "certifyedge.json") + data = json.loads(pin_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("certifyedge pin root must be a JSON object") + return CertifyEdgePin( + status=str(data.get("status") or "").strip().lower(), + version=str(data.get("version") or "").strip(), + provision_strategy=str(data.get("provision_strategy") or "none").strip().lower(), + image=str(data.get("image") or ""), + image_digest=str(data.get("image_digest") or ""), + binary_url=str(data.get("binary_url") or ""), + binary_sha256=str(data.get("binary_sha256") or ""), + source_repo=str(data.get("source_repo") or ""), + source_commit=str(data.get("source_commit") or ""), + pin_identity=pin_identity_from(data), + raw=data, + ) + + +def pin_is_production_ready(pin: CertifyEdgePin | Mapping[str, Any]) -> tuple[bool, list[str]]: + """Return whether the pin can provision an immutable production CertifyEdge.""" + if isinstance(pin, CertifyEdgePin): + status = pin.status + strategy = pin.provision_strategy + image = pin.image + image_digest = pin.image_digest + binary_url = pin.binary_url + binary_sha256 = pin.binary_sha256 + source_repo = pin.source_repo + source_commit = pin.source_commit + else: + status = str(pin.get("status") or "").strip().lower() + strategy = str(pin.get("provision_strategy") or "").strip().lower() + image = str(pin.get("image") or "") + image_digest = str(pin.get("image_digest") or "") + binary_url = str(pin.get("binary_url") or "") + binary_sha256 = str(pin.get("binary_sha256") or "") + source_repo = str(pin.get("source_repo") or "") + source_commit = str(pin.get("source_commit") or "") + + errors: list[str] = [] + if status != "pinned": + errors.append(f"status is {status!r}; need 'pinned' for release provisioning") + if strategy in {"", "none"}: + errors.append("provision_strategy is unset (none)") + return False, errors + + if strategy == "dev_fixture": + errors.append( + "provision_strategy=dev_fixture is test/preview only; " + "not production-ready (do not use for stable release trust)" + ) + return False, errors + + if strategy == "oci_digest": + if _is_placeholder(image): + errors.append("image is empty or placeholder") + if not DIGEST_RE.match(image_digest) or _is_placeholder(image_digest): + errors.append("image_digest must be sha256:<64 hex> (no placeholders)") + elif strategy == "signed_binary": + if _is_placeholder(binary_url): + errors.append("binary_url is empty or placeholder") + if not DIGEST_RE.match(binary_sha256) and not re.fullmatch(r"[a-f0-9]{64}", binary_sha256): + errors.append("binary_sha256 must be sha256:<64 hex> or bare 64-hex digest") + if _is_placeholder(binary_sha256): + errors.append("binary_sha256 is placeholder") + elif strategy == "source_commit_build": + if _is_placeholder(source_repo): + errors.append("source_repo is empty or placeholder") + if not COMMIT_RE.match(source_commit): + errors.append("source_commit must be a full 40-char git SHA") + else: + errors.append( + f"unknown provision_strategy {strategy!r}; " + "expected oci_digest | signed_binary | source_commit_build" + ) + return not errors, errors + + +def pin_allows_dev_fixture(pin: CertifyEdgePin | Mapping[str, Any]) -> tuple[bool, list[str]]: + """Validate a test/dev fixture pin (preview/dev modes only).""" + if isinstance(pin, CertifyEdgePin): + status = pin.status + strategy = pin.provision_strategy + binary_sha256 = pin.binary_sha256 + else: + status = str(pin.get("status") or "").strip().lower() + strategy = str(pin.get("provision_strategy") or "").strip().lower() + binary_sha256 = str(pin.get("binary_sha256") or "") + + errors: list[str] = [] + if strategy != "dev_fixture": + errors.append(f"expected provision_strategy=dev_fixture, got {strategy!r}") + return False, errors + if status not in {"pinned", "dev_fixture"}: + # Allow status=pinned for machine-readable fixture pins used only in tests. + errors.append(f"dev_fixture pin status must be pinned or dev_fixture, got {status!r}") + expected = dev_fixture_digest() + normalized = binary_sha256.strip() + if normalized and not DIGEST_RE.match(normalized) and re.fullmatch(r"[a-f0-9]{64}", normalized): + normalized = f"sha256:{normalized}" + if normalized and normalized != expected: + errors.append( + f"dev_fixture binary_sha256 mismatch: got {normalized!r}, expected {expected!r}" + ) + return not errors, errors + + +def classify_checker_trust( + *, + executable: Path | None, + pin: CertifyEdgePin | None = None, + provision: ProvisionEnvironment | None = None, +) -> TrustGrade: + """Classify a checker executable as pinned vs untrusted development-grade.""" + if provision is not None: + return provision.trust_grade + if pin is None: + try: + pin = load_certifyedge_pin() + except (OSError, json.JSONDecodeError, ValueError): + pin = None + if pin is None or not pin.is_pinned: + return "unpinned" + ready, _ = pin_is_production_ready(pin) + if not ready: + if pin.provision_strategy == "dev_fixture": + if executable is not None and executable.is_file(): + actual = file_sha256_digest(executable) + expected = pin.expected_binary_digest or dev_fixture_digest() + if actual == expected: + return "untrusted_development" + return "untrusted_development" + return "unpinned" + if executable is None or not executable.is_file(): + return "unpinned" + expected = pin.expected_binary_digest + if expected is None: + # source_commit_build: trust requires provision.env digest match later + return "pinned" + actual = file_sha256_digest(executable) + if actual != expected: + return "untrusted_development" + return "pinned" + + +def parse_provision_env(path: Path) -> ProvisionEnvironment: + values: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + text = line.strip() + if not text or text.startswith("#") or "=" not in text: + continue + key, _, value = text.partition("=") + values[key.strip()] = value.strip() + executable = ( + values.get("PCS_CERTIFYEDGE_EXECUTABLE") or values.get("PF_CORE_CERTIFYEDGE_CLI") or "" + ) + digest = values.get("PCS_CERTIFYEDGE_BINARY_DIGEST") or "" + version = values.get("PCS_CERTIFYEDGE_VERSION") or "" + pin_identity = values.get("PCS_CERTIFYEDGE_PIN_IDENTITY") or "" + strategy = values.get("PCS_CERTIFYEDGE_PROVISION_STRATEGY") or "none" + grade_raw = values.get("PCS_CERTIFYEDGE_TRUST_GRADE") or "untrusted_development" + if grade_raw not in {"pinned", "untrusted_development", "unpinned"}: + grade_raw = "untrusted_development" + if not executable: + raise ValueError(f"provision env missing PCS_CERTIFYEDGE_EXECUTABLE: {path}") + return ProvisionEnvironment( + executable_path=executable, + binary_digest=digest, + version=version, + pin_identity=pin_identity, + provision_strategy=strategy, + trust_grade=grade_raw, # type: ignore[arg-type] + ) + + +def load_provision_environment( + install_dir: Path | None = None, +) -> ProvisionEnvironment | None: + """Load provision.env from install dir or ``PCS_CERTIFYEDGE_PROVISION_ENV``.""" + env_path = os.environ.get("PCS_CERTIFYEDGE_PROVISION_ENV", "").strip() + if env_path: + path = Path(env_path) + if path.is_file(): + return parse_provision_env(path) + base = install_dir or (repo_root() / DEFAULT_INSTALL_DIR_REL) + candidate = base / PROVISION_ENV_NAME + if candidate.is_file(): + return parse_provision_env(candidate) + return None + + +def build_provision_environment( + *, + executable: Path, + pin: CertifyEdgePin, + version: str | None = None, +) -> ProvisionEnvironment: + digest = file_sha256_digest(executable) + ready, _ = pin_is_production_ready(pin) + if ready and (pin.expected_binary_digest is None or digest == pin.expected_binary_digest): + grade: TrustGrade = "pinned" + elif pin.provision_strategy == "dev_fixture": + grade = "untrusted_development" + elif pin.expected_binary_digest and digest == pin.expected_binary_digest: + grade = "pinned" + else: + grade = "untrusted_development" + return ProvisionEnvironment( + executable_path=str(executable.resolve()), + binary_digest=digest, + version=version or pin.version or "unknown", + pin_identity=pin.pin_identity, + provision_strategy=pin.provision_strategy, + trust_grade=grade, + ) + + +def write_dev_fixture_binary(dest: Path) -> str: + """Write the deterministic CertifyEdge dev fixture and return its digest.""" + dest.parent.mkdir(parents=True, exist_ok=True) + # On Windows a .py fixture is used; on Unix we write a shebang script with + # fixed body prefix so the digest remains stable when using the marker file. + # Provisioning places the marker bytes as the executable content for digest + # binding; the runnable wrapper is separate when needed. + dest.write_bytes(DEV_FIXTURE_MARKER) + return file_sha256_digest(dest) + + +def certifyedge_pin_record_for_bundle(pin: CertifyEdgePin | None = None) -> dict[str, Any]: + """Machine-readable pin snapshot carried into release bundles.""" + loaded = pin or load_certifyedge_pin() + ready, errors = pin_is_production_ready(loaded) + return { + "schema_version": "v0", + "artifact_type": "CertifyEdgePinRecord.v0", + "status": loaded.status, + "version": loaded.version, + "provision_strategy": loaded.provision_strategy, + "pin_identity": loaded.pin_identity, + "image": loaded.image, + "image_digest": loaded.image_digest, + "binary_sha256": loaded.binary_sha256, + "source_repo": loaded.source_repo, + "source_commit": loaded.source_commit, + "production_ready": ready, + "production_ready_errors": errors, + "notes": list(loaded.raw.get("notes") or []), + } + + +def validate_attestation_against_pin( + attestation: Mapping[str, Any], + *, + pin: CertifyEdgePin | None = None, + provision: ProvisionEnvironment | None = None, + require_pinned: bool = False, +) -> list[str]: + """Independent comparison of attestation fields vs trusted pin / provision env.""" + errors: list[str] = [] + loaded = pin + if loaded is None: + try: + loaded = load_certifyedge_pin() + except (OSError, json.JSONDecodeError, ValueError) as exc: + if require_pinned: + return [f"CertifyEdgePinUnreadable: {exc}"] + return [] + + assert loaded is not None + prov = provision or load_provision_environment() + + checker_digest = str(attestation.get("checker_binary_digest") or "") + checker_version = str(attestation.get("checker_version") or "") + issuer = str(attestation.get("issuer_identity") or "") + property_id = str(attestation.get("property_id") or "") + property_version = str(attestation.get("property_version") or "v0") + trace_digest = str(attestation.get("trace_digest") or "") + bundle_digest = str(attestation.get("release_bundle_digest") or "") + + if require_pinned: + ready, pin_errors = pin_is_production_ready(loaded) + if not ready: + errors.append("CertifyEdgePinNotProductionReady: " + "; ".join(pin_errors)) + return errors + + expected_digest = None + if prov is not None: + expected_digest = prov.binary_digest + if prov.version and checker_version and prov.version != checker_version: + errors.append( + f"CertifyEdgeVersionMismatch: attestation={checker_version!r} " + f"provision={prov.version!r}" + ) + if prov.pin_identity and prov.pin_identity not in issuer and issuer: + # Issuer may be certifyedge-binary:; require digest or pin id match. + if prov.binary_digest not in issuer and prov.pin_identity not in issuer: + errors.append( + f"CertifyEdgeIssuerMismatch: issuer={issuer!r} " + f"pin_identity={prov.pin_identity!r}" + ) + if prov.trust_grade == "untrusted_development" and require_pinned: + errors.append( + "CertifyEdgeUntrustedDevelopment: provision trust_grade=" + "untrusted_development (arbitrary/dev fixture checkers are not release-grade)" + ) + else: + expected_digest = loaded.expected_binary_digest + + if expected_digest and checker_digest and checker_digest != expected_digest: + errors.append( + f"CertifyEdgeBinaryDigestMismatch: attestation={checker_digest!r} " + f"expected={expected_digest!r}" + ) + + if loaded.version and checker_version and loaded.version != checker_version and prov is None: + errors.append( + f"CertifyEdgeVersionMismatch: attestation={checker_version!r} pin={loaded.version!r}" + ) + + # Always require the attestation to carry the binding digests (independent of pin). + if not DIGEST_RE.match(trace_digest): + errors.append(f"CertifyEdgeTraceDigestInvalid: {trace_digest!r}") + if not DIGEST_RE.match(bundle_digest): + errors.append(f"CertifyEdgeBundleDigestInvalid: {bundle_digest!r}") + if not property_id: + errors.append("CertifyEdgePropertyMissing") + else: + # Policy digest is derived; recompute when helper available. + from pcs_core.external_attestation import policy_digest_from_property + + expected_policy = policy_digest_from_property(property_id, property_version) + recorded_policy = str(attestation.get("policy_digest") or "") + if recorded_policy and recorded_policy != expected_policy: + errors.append( + f"CertifyEdgePolicyDigestMismatch: {recorded_policy!r} != {expected_policy!r}" + ) + + if ( + require_pinned + and classify_checker_trust(executable=None, pin=loaded, provision=prov) != "pinned" + ): + errors.append("CertifyEdgeTrustGradeNotPinned: release requires pinned trust grade") + + return errors diff --git a/python/pcs_core/external_attestation.py b/python/pcs_core/external_attestation.py index b0ce224..dc123d9 100644 --- a/python/pcs_core/external_attestation.py +++ b/python/pcs_core/external_attestation.py @@ -66,24 +66,75 @@ def _attestation_payload_for_digest(attestation: Mapping[str, Any]) -> dict[str, return payload -def seal_external_attestation(attestation: dict[str, Any]) -> dict[str, Any]: - """Attach digest-bound attestation_signature and signature_or_digest.""" +def seal_external_attestation( + attestation: dict[str, Any], + *, + private_seed: bytes | None = None, + key_id: str | None = None, + signed_at: str | None = None, +) -> dict[str, Any]: + """Attach digest-bound attestation_signature and signature_or_digest. + + When ``private_seed`` and ``key_id`` are provided (or authentication_mode is + already ``ed25519_signed`` with seed available via env), seal with a real + Ed25519 signature over the domain-separated content digest message. + """ sealed = dict(attestation) sealed.setdefault("canonicalization_version", CANONICALIZATION_VERSION) sealed.pop("signature_or_digest", None) - # First seal without attestation_signature digest, then bind. provisional = dict(sealed) provisional.pop("attestation_signature", None) content_digest = canonical_hash(provisional) mode = str(sealed.get("authentication_mode") or "digest_bound") - if mode == "digest_bound" or "attestation_signature" not in sealed: + + seed = private_seed + kid = key_id + if seed is None: + import os + + env_seed = os.environ.get("PCS_RELEASE_SIGNING_SEED_B64", "").strip() + env_kid = os.environ.get("PCS_RELEASE_SIGNING_KEY_ID", "").strip() + if env_seed and env_kid: + from pcs_core.artifact_integrity import decode_key_bytes + + seed = decode_key_bytes(env_seed, expected_len=32) + kid = kid or env_kid + mode = "ed25519_signed" + + if mode == "ed25519_signed" and seed is not None and kid: + from datetime import datetime, timezone + + from pcs_core.artifact_integrity import ( + encode_key_bytes, + sign_ed25519, + signing_message_bytes, + ) + + when = signed_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + message = signing_message_bytes( + artifact_type="ExternalAttestation.v0", + schema_version="v0", + artifact_digest=content_digest, + ) + sig = sign_ed25519(message, private_seed=seed) + sealed["authentication_mode"] = "ed25519_signed" + sealed["attestation_signature"] = { + "algorithm": "ed25519", + "key_id": kid, + "signed_at": when, + "value": encode_key_bytes(sig), + } + else: sealed["authentication_mode"] = "digest_bound" sealed["attestation_signature"] = { "algorithm": "sha256-digest-bound", "digest": content_digest, "note": ( "Digest-bound integrity only. Replace with ed25519_signed once " - "org CertifyEdge / release signing keys are configured." + "org CertifyEdge / release signing keys are configured " + "(PCS_RELEASE_SIGNING_SEED_B64 + PCS_RELEASE_SIGNING_KEY_ID)." ), } sealed["signature_or_digest"] = canonical_hash(_attestation_payload_for_digest(sealed)) @@ -181,6 +232,8 @@ def validate_external_attestation( expected_bundle_digest: str | None = None, expected_trace_digest: str | None = None, require_live: bool = False, + require_pinned_checker: bool = False, + key_registry: Any | None = None, ) -> list[str]: """Schema + binding validation for ExternalAttestation.v0.""" errors = validate_schema(dict(attestation), "ExternalAttestation.v0") @@ -233,6 +286,63 @@ def validate_external_attestation( elif mode == "ed25519_signed": if not isinstance(sig, dict) or sig.get("algorithm") != "ed25519": errors.append("ExternalAttestationSignatureModeMismatch: expected ed25519 envelope") + else: + from pcs_core.artifact_integrity import ( + IntegrityError, + TimestampPolicy, + decode_key_bytes, + load_trusted_key_registry, + parse_utc_datetime, + resolve_trusted_key_registry, + signing_message_bytes, + verify_ed25519, + ) + + registry = key_registry + if registry is None: + registry = resolve_trusted_key_registry() + if registry is None: + errors.append( + "ExternalAttestationSignatureUnverified: " + "ed25519_signed requires PCS_TRUSTED_KEY_REGISTRY" + ) + else: + if not hasattr(registry, "require"): + registry = load_trusted_key_registry(registry) + provisional = dict(attestation) + provisional.pop("attestation_signature", None) + provisional.pop("signature_or_digest", None) + content_digest = canonical_hash(provisional) + try: + key = registry.require(str(sig.get("key_id") or "")) + message = signing_message_bytes( + artifact_type="ExternalAttestation.v0", + schema_version="v0", + artifact_digest=content_digest, + ) + verify_ed25519( + message, + decode_key_bytes(str(sig.get("value") or ""), expected_len=64), + public_key=key.public_key_bytes, + ) + policy_errors = TimestampPolicy().evaluate( + parse_utc_datetime(str(sig.get("signed_at") or "")), + key, + ) + errors.extend(policy_errors) + except IntegrityError as exc: + errors.append(str(exc)) + except ValueError as exc: + errors.append(f"ExternalAttestationSignatureInvalid: {exc}") + + from pcs_core.certifyedge_pin import validate_attestation_against_pin + + errors.extend( + validate_attestation_against_pin( + attestation, + require_pinned=require_pinned_checker, + ) + ) return errors @@ -285,9 +395,42 @@ def attest_release_bundle( cli = _find_live_certifyedge_cli() or _find_format_stub() except Exception: cli = None - if cli and Path(cli).is_file() and Path(cli).suffix != ".py": + + from pcs_core.certifyedge_pin import ( + classify_checker_trust, + load_certifyedge_pin, + load_provision_environment, + ) + + provision = load_provision_environment() + pin = None + try: + pin = load_certifyedge_pin() + except Exception: + pin = None + + trust_grade = classify_checker_trust( + executable=Path(cli) if cli else None, + pin=pin, + provision=provision, + ) + if require_live and trust_grade == "untrusted_development": + raise RuntimeError( + "live external attestation rejected: checker trust_grade=untrusted_development " + "(arbitrary PATH executables and dev fixtures are not release-grade; " + "provision from a production pin and source provision.env)" + ) + + if provision is not None and Path(provision.executable_path).is_file(): + checker_binary_digest = provision.binary_digest + issuer = f"certifyedge-binary:{checker_binary_digest}" + if provision.pin_identity: + issuer = f"{issuer};pin={provision.pin_identity}" + elif cli and Path(cli).is_file() and Path(cli).suffix != ".py": checker_binary_digest = file_sha256_digest(Path(cli)) issuer = f"certifyedge-binary:{checker_binary_digest}" + if trust_grade != "pinned": + issuer = f"{issuer};trust_grade={trust_grade}" elif cli: checker_binary_digest = EMPTY_SHA256 issuer = f"certifyedge-stub:{Path(cli).name}" diff --git a/python/pcs_core/pf_core_certifyedge.py b/python/pcs_core/pf_core_certifyedge.py index 90560dd..b66a06e 100644 --- a/python/pcs_core/pf_core_certifyedge.py +++ b/python/pcs_core/pf_core_certifyedge.py @@ -126,6 +126,25 @@ def certifyedge_status() -> dict[str, object]: mode = certifyedge_mode() live_cli = _find_live_certifyedge_cli() stub_cli = _find_format_stub() + trust_grade = "unpinned" + pin_identity = None + try: + from pcs_core.certifyedge_pin import ( + classify_checker_trust, + load_certifyedge_pin, + load_provision_environment, + ) + + pin = load_certifyedge_pin() + provision = load_provision_environment() + trust_grade = classify_checker_trust( + executable=Path(live_cli) if live_cli else None, + pin=pin, + provision=provision, + ) + pin_identity = pin.pin_identity + except Exception: + pass return { "available": live_cli is not None, "cli_path": live_cli, @@ -135,6 +154,8 @@ def certifyedge_status() -> dict[str, object]: "live_required": mode == "live" or certifyedge_require_live(), "require_live_env": certifyedge_require_live(), "allow_stub_env": certifyedge_allow_stub(), + "trust_grade": trust_grade, + "pin_identity": pin_identity, "env_contract": { "PF_CORE_CERTIFYEDGE_MODE": "auto | live | mock (default: auto)", "PF_CORE_CERTIFYEDGE_CLI": ( @@ -143,6 +164,7 @@ def certifyedge_status() -> dict[str, object]: "PF_CORE_CERTIFYEDGE_MOCK": "1 forces mock mode (alias: PCS_CERTIFYEDGE_MOCK)", "PF_CORE_CERTIFYEDGE_REQUIRE_LIVE": "1 fails when live CLI absent (release gate)", "PF_CORE_CERTIFYEDGE_ALLOW_STUB": "1 allows format stub on require-live (staging only)", + "PCS_CERTIFYEDGE_PROVISION_ENV": "path to provision.env from provision-certifyedge.sh", }, "install_doc": CERTIFYEDGE_INSTALL_DOC, } diff --git a/python/pyproject.toml b/python/pyproject.toml index 84d4aa4..cd069ac 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ # format-nongpl registers date-time/uri/duration/hostname checkers used as assertions "jsonschema[format-nongpl]>=4.23.0", "referencing>=0.35.0,<0.37.0", + # ArtifactIntegrity.v1 Ed25519 sign/verify (docs/trust-model.md) + "PyNaCl>=1.5.0,<2", ] [project.optional-dependencies] diff --git a/python/requirements.lock b/python/requirements.lock index b3afd29..e275d31 100644 --- a/python/requirements.lock +++ b/python/requirements.lock @@ -7,6 +7,9 @@ jsonschema==4.26.0 jsonschema-specifications==2025.9.1 referencing==0.36.2 rpds-py==0.30.0 +PyNaCl==1.6.2 +cffi==1.17.1 +pycparser==2.22 # jsonschema[format-nongpl] — required so FormatChecker registers asserted formats arrow==1.3.0 diff --git a/python/tests/test_artifact_integrity.py b/python/tests/test_artifact_integrity.py new file mode 100644 index 0000000..cc64db0 --- /dev/null +++ b/python/tests/test_artifact_integrity.py @@ -0,0 +1,289 @@ +"""ArtifactIntegrity.v1 Ed25519 sign/verify and key-revocation tests.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from pcs_core.artifact_integrity import ( + IntegrityError, + TimestampPolicy, + build_integrity_sidecar, + build_trusted_key, + build_trusted_key_registry, + encode_key_bytes, + generate_ed25519_keypair, + load_trusted_key_registry, + revoke_key_in_registry, + sign_artifact, + validate_artifact_integrity_semantics, + verify_artifact_signature, + verify_release_root_signatures, +) +from pcs_core.validate import validate_artifact + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def test_ed25519_sign_and_verify_roundtrip() -> None: + seed, pub = generate_ed25519_keypair() + key_id = "test-release-key-1" + registry = load_trusted_key_registry( + build_trusted_key_registry( + [ + build_trusted_key( + key_id=key_id, + public_key=pub, + valid_from=_utcnow() - timedelta(days=1), + purposes=["release_signing"], + ) + ], + registry_id="test-registry", + ) + ) + body = { + "schema_version": "v1", + "artifact_type": "ArtifactIntegrity.v1", + "payload": {"ok": True, "n": 1}, + } + sealed = sign_artifact(body, private_seed=seed, key_id=key_id, signed_at=_utcnow()) + validate_artifact(sealed, "ArtifactIntegrity.v1", release_grade=True) + errors = verify_artifact_signature(sealed, registry, required_purpose="release_signing") + assert errors == [] + semantic = validate_artifact_integrity_semantics(sealed, registry=registry) + assert semantic == [] + + +def test_tampered_payload_fails_verify() -> None: + seed, pub = generate_ed25519_keypair() + key_id = "k1" + registry = load_trusted_key_registry( + build_trusted_key_registry( + [ + build_trusted_key( + key_id=key_id, + public_key=pub, + valid_from=_utcnow() - timedelta(days=1), + ) + ] + ) + ) + sealed = sign_artifact( + { + "schema_version": "v1", + "artifact_type": "ArtifactIntegrity.v1", + "payload": {"ok": True}, + }, + private_seed=seed, + key_id=key_id, + ) + tampered = dict(sealed) + tampered["payload"] = {"ok": False} + errors = verify_artifact_signature(tampered, registry) + assert any("DigestMismatch" in e or "SignatureVerificationFailed" in e for e in errors) + + +def test_revoked_key_rejected() -> None: + seed, pub = generate_ed25519_keypair() + key_id = "revocable" + now = _utcnow() + registry_doc = build_trusted_key_registry( + [ + build_trusted_key( + key_id=key_id, + public_key=pub, + valid_from=now - timedelta(days=30), + purposes=["release_signing"], + ) + ] + ) + sealed = sign_artifact( + { + "schema_version": "v1", + "artifact_type": "ArtifactIntegrity.v1", + "payload": {"x": 1}, + }, + private_seed=seed, + key_id=key_id, + signed_at=now, + ) + assert verify_artifact_signature(sealed, load_trusted_key_registry(registry_doc)) == [] + + revoked_doc = revoke_key_in_registry(registry_doc, key_id, revoked_at=now - timedelta(hours=1)) + revoked_registry = load_trusted_key_registry(revoked_doc) + errors = verify_artifact_signature( + sealed, + revoked_registry, + timestamp_policy=TimestampPolicy(now=now), + ) + assert any("KeyRevoked" in e for e in errors) + + +def test_key_outside_validity_interval() -> None: + seed, pub = generate_ed25519_keypair() + key_id = "windowed" + now = _utcnow() + registry = load_trusted_key_registry( + build_trusted_key_registry( + [ + build_trusted_key( + key_id=key_id, + public_key=pub, + valid_from=now - timedelta(days=10), + valid_until=now - timedelta(days=1), + ) + ] + ) + ) + sealed = sign_artifact( + { + "schema_version": "v1", + "artifact_type": "ArtifactIntegrity.v1", + "payload": {"x": 1}, + }, + private_seed=seed, + key_id=key_id, + signed_at=now, + ) + errors = verify_artifact_signature( + sealed, + registry, + timestamp_policy=TimestampPolicy(now=now), + ) + assert any("KeyOutsideValidityInterval" in e for e in errors) + + +def test_unknown_key_id_rejected() -> None: + seed, pub = generate_ed25519_keypair() + registry = load_trusted_key_registry( + build_trusted_key_registry( + [ + build_trusted_key( + key_id="other", + public_key=pub, + valid_from=_utcnow() - timedelta(days=1), + ) + ] + ) + ) + sealed = sign_artifact( + { + "schema_version": "v1", + "artifact_type": "ArtifactIntegrity.v1", + "payload": {}, + }, + private_seed=seed, + key_id="missing", + ) + errors = verify_artifact_signature(sealed, registry) + assert any("UnknownKeyId" in e for e in errors) + + +def test_signature_timestamp_too_old() -> None: + seed, pub = generate_ed25519_keypair() + key_id = "aged" + now = _utcnow() + registry = load_trusted_key_registry( + build_trusted_key_registry( + [ + build_trusted_key( + key_id=key_id, + public_key=pub, + valid_from=now - timedelta(days=400), + ) + ] + ) + ) + sealed = sign_artifact( + { + "schema_version": "v1", + "artifact_type": "ArtifactIntegrity.v1", + "payload": {}, + }, + private_seed=seed, + key_id=key_id, + signed_at=now - timedelta(days=400), + ) + errors = verify_artifact_signature( + sealed, + registry, + timestamp_policy=TimestampPolicy(max_age=timedelta(days=30), now=now), + ) + assert any("SignatureTimestampTooOld" in e for e in errors) + + +def test_integrity_sidecar_binds_target(tmp_path: Path) -> None: + seed, pub = generate_ed25519_keypair() + key_id = "side" + registry = load_trusted_key_registry( + build_trusted_key_registry( + [ + build_trusted_key( + key_id=key_id, + public_key=pub, + valid_from=_utcnow() - timedelta(days=1), + purposes=["release_signing"], + ) + ] + ) + ) + target = { + "schema_version": "v0", + "artifact_type": "PFCoreCertificate.v0", + "certificate_id": "c1", + "claim_class": "LeanKernelChecked", + "payload_note": "minimal-for-digest", + } + sidecar = build_integrity_sidecar(target, private_seed=seed, key_id=key_id) + from pcs_core.artifact_integrity import compute_artifact_digest + + errors = verify_artifact_signature( + sidecar, + registry, + expect_digest=compute_artifact_digest(target), + ) + assert errors == [] + + root = tmp_path / "release" + root.mkdir() + cert_path = root / "certificate.json" + cert_path.write_text( + __import__("json").dumps(target), + encoding="utf-8", + ) + (root / "certificate.json.integrity.json").write_text( + __import__("json").dumps(sidecar), + encoding="utf-8", + ) + # Digest-only preview path reports soft codes when allow_digest_only. + soft = verify_release_root_signatures(root, registry, allow_digest_only=True) + # Sidecar present → should verify cleanly (no MissingAuthenticatedIntegrity). + assert not any(e.startswith("MissingAuthenticatedIntegrity") for e in soft) + + +def test_trusted_key_registry_schema_roundtrip() -> None: + _, pub = generate_ed25519_keypair() + doc = build_trusted_key_registry( + [ + build_trusted_key( + key_id="k", + public_key=encode_key_bytes(pub), + valid_from="2026-01-01T00:00:00Z", + purposes=["development"], + ) + ], + registry_id="dev", + ) + validate_artifact(doc, "TrustedKeyRegistry.v0", release_grade=True) + loaded = load_trusted_key_registry(doc) + assert loaded.get("k") is not None + + +def test_revoke_unknown_key_raises() -> None: + doc = build_trusted_key_registry([]) + with pytest.raises(IntegrityError, match="UnknownKeyId"): + revoke_key_in_registry(doc, "nope") diff --git a/python/tests/test_certifyedge_pin.py b/python/tests/test_certifyedge_pin.py new file mode 100644 index 0000000..e8561f3 --- /dev/null +++ b/python/tests/test_certifyedge_pin.py @@ -0,0 +1,152 @@ +"""CertifyEdge pin, provision env, and trust-grade tests.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from pcs_core.certifyedge_pin import ( + ProvisionEnvironment, + certifyedge_pin_record_for_bundle, + classify_checker_trust, + dev_fixture_digest, + load_certifyedge_pin, + parse_provision_env, + pin_allows_dev_fixture, + pin_is_production_ready, + validate_attestation_against_pin, + write_dev_fixture_binary, +) +from pcs_core.external_attestation import build_external_attestation +from pcs_core.pf_core_bundle import bundle_release + +REPO = Path(__file__).resolve().parents[2] +MOCK_TRACE = REPO / "examples" / "pf-core-valid" / "certifyedge_mock" / "trace.json" +MOCK_CERT = REPO / "examples" / "pf-core-valid" / "certifyedge_mock" / "certificate.json" + + +def test_repo_pin_unpinned_fail_closed_for_production() -> None: + pin = load_certifyedge_pin() + assert pin.status == "unpinned" + ready, errors = pin_is_production_ready(pin) + assert ready is False + assert any("status" in e for e in errors) + + +def test_dev_fixture_digest_stable(tmp_path: Path) -> None: + dest = tmp_path / "certifyedge" + digest = write_dev_fixture_binary(dest) + assert digest == dev_fixture_digest() + assert dest.read_bytes().startswith(b"PCS_CERTIFYEDGE_DEV_FIXTURE_V1") + + +def test_dev_fixture_pin_preview_ok(tmp_path: Path) -> None: + pin_doc = { + "status": "pinned", + "version": "dev-fixture-0", + "provision_strategy": "dev_fixture", + "binary_sha256": dev_fixture_digest(), + "image": "", + "image_digest": "", + "binary_url": "", + "source_repo": "", + "source_commit": "", + } + ok, errors = pin_allows_dev_fixture(pin_doc) + assert ok, errors + ready, prod_errors = pin_is_production_ready(pin_doc) + assert ready is False + assert any("dev_fixture" in e for e in prod_errors) + + pin_path = tmp_path / "pin.json" + pin_path.write_text(json.dumps(pin_doc), encoding="utf-8") + script = REPO / "scripts" / "verify-certifyedge-pin.py" + preview = subprocess.run( + [sys.executable, str(script), "--pin", str(pin_path), "--mode", "preview"], + capture_output=True, + text=True, + check=False, + ) + assert preview.returncode == 0, preview.stderr + release = subprocess.run( + [sys.executable, str(script), "--pin", str(pin_path), "--mode", "release"], + capture_output=True, + text=True, + check=False, + ) + assert release.returncode == 1 + assert "not production-ready" in release.stderr or "FAIL" in release.stderr + + +def test_provision_env_roundtrip(tmp_path: Path) -> None: + env = ProvisionEnvironment( + executable_path=str(tmp_path / "certifyedge"), + binary_digest=dev_fixture_digest(), + version="0.0.0-dev", + pin_identity=f"dev_fixture:{dev_fixture_digest()}", + provision_strategy="dev_fixture", + trust_grade="untrusted_development", + ) + path = env.write(tmp_path / "provision.env") + loaded = parse_provision_env(path) + assert loaded.executable_path == env.executable_path + assert loaded.binary_digest == env.binary_digest + assert loaded.trust_grade == "untrusted_development" + assert "PF_CORE_CERTIFYEDGE_CLI=" in path.read_text(encoding="utf-8") + + +def test_arbitrary_executable_untrusted(tmp_path: Path) -> None: + exe = tmp_path / "random-checker" + exe.write_bytes(b"not-the-fixture") + pin = load_certifyedge_pin() + grade = classify_checker_trust(executable=exe, pin=pin) + assert grade in {"unpinned", "untrusted_development"} + + +def test_bundle_carries_certifyedge_pin(tmp_path: Path) -> None: + out = tmp_path / "bundle" + bundle_release(MOCK_TRACE, MOCK_CERT, out) + pin_path = out / "certifyedge_pin.json" + assert pin_path.is_file() + record = json.loads(pin_path.read_text(encoding="utf-8")) + assert record["artifact_type"] == "CertifyEdgePinRecord.v0" + assert record["status"] == "unpinned" + assert record["production_ready"] is False + # Same shape as helper. + helper = certifyedge_pin_record_for_bundle() + assert helper["pin_identity"] == record["pin_identity"] + + +def test_attestation_pin_validation_digest_fields() -> None: + attestation = build_external_attestation( + release_bundle_digest="sha256:" + "a" * 64, + trace_digest="sha256:" + "b" * 64, + property_id="qc_release.temporal.safety", + checker="certifyedge", + checker_version="0.1.0", + checker_binary_digest="sha256:" + "c" * 64, + result="CertificateChecked", + attestation_class="mock", + issuer_identity="certifyedge-mock", + attestation_ref="mock://certifyedge/qc_release.temporal.safety", + ) + # Unpinned repo pin: require_pinned fails closed. + errors = validate_attestation_against_pin(attestation, require_pinned=True) + assert any("NotProductionReady" in e or "TrustGrade" in e for e in errors) + # Preview path without require_pinned still validates digest field shapes. + soft = validate_attestation_against_pin(attestation, require_pinned=False) + assert soft == [] or all("NotProductionReady" not in e for e in soft) + + +def test_certifyedge_dev_fixture_script() -> None: + script = REPO / "scripts" / "certifyedge-dev-fixture.py" + proc = subprocess.run( + [sys.executable, str(script), "--print-digest-only"], + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0 + assert proc.stdout.strip() == dev_fixture_digest() diff --git a/python/tests/test_external_attestation.py b/python/tests/test_external_attestation.py index eb65c0a..14f8906 100644 --- a/python/tests/test_external_attestation.py +++ b/python/tests/test_external_attestation.py @@ -119,6 +119,67 @@ def test_validate_bundle_checks_sidecar_when_present( assert result.ok, result.to_dict() +def test_ed25519_signed_external_attestation_roundtrip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from pcs_core.artifact_integrity import ( + build_trusted_key, + build_trusted_key_registry, + encode_key_bytes, + generate_ed25519_keypair, + ) + from pcs_core.external_attestation import seal_external_attestation + + seed, pub = generate_ed25519_keypair() + key_id = "attest-key" + registry_path = tmp_path / "keys.json" + registry_path.write_text( + json.dumps( + build_trusted_key_registry( + [ + build_trusted_key( + key_id=key_id, + public_key=encode_key_bytes(pub), + valid_from="2020-01-01T00:00:00Z", + purposes=["external_attestation"], + ) + ] + ) + ), + encoding="utf-8", + ) + monkeypatch.setenv("PCS_TRUSTED_KEY_REGISTRY", str(registry_path)) + + base = { + "schema_version": "v0", + "artifact_type": "ExternalAttestation.v0", + "attestation_id": "ext-1", + "release_bundle_digest": "sha256:" + "a" * 64, + "trace_digest": "sha256:" + "b" * 64, + "property_id": "qc_release.temporal.safety", + "property_version": "v0", + "checker": "certifyedge", + "checker_version": "0.1.0", + "checker_binary_digest": "sha256:" + "c" * 64, + "policy_digest": "sha256:" + "d" * 64, + "executed_at": "2026-07-22T12:00:00Z", + "result": "CertificateChecked", + "attestation_class": "mock", + "issuer_identity": "certifyedge-mock", + "authentication_mode": "ed25519_signed", + "attestation_ref": "mock://certifyedge/x", + } + # Fix policy digest to match helper. + from pcs_core.external_attestation import policy_digest_from_property + + base["policy_digest"] = policy_digest_from_property("qc_release.temporal.safety", "v0") + sealed = seal_external_attestation(base, private_seed=seed, key_id=key_id) + assert sealed["authentication_mode"] == "ed25519_signed" + assert sealed["attestation_signature"]["algorithm"] == "ed25519" + errors = validate_external_attestation(sealed) + assert errors == [], errors + + def test_verify_certifyedge_pin_release_fail_closed() -> None: import subprocess import sys diff --git a/schemas/TrustedKeyRegistry.v0.schema.json b/schemas/TrustedKeyRegistry.v0.schema.json new file mode 100644 index 0000000..ae3ac3f --- /dev/null +++ b/schemas/TrustedKeyRegistry.v0.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/TrustedKeyRegistry.v0.schema.json", + "title": "TrustedKeyRegistry.v0", + "description": "Allowlist of ed25519 public keys for ArtifactIntegrity.v1 verification. pcs-core does not ship production private keys.", + "type": "object", + "required": [ + "schema_version", + "artifact_type", + "keys" + ], + "additionalProperties": false, + "properties": { + "schema_version": { "$ref": "common.defs.json#/$defs/schema_version" }, + "artifact_type": { "const": "TrustedKeyRegistry.v0" }, + "canonicalization_version": { + "$ref": "common.defs.json#/$defs/canonicalization_version" + }, + "registry_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "keys": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": [ + "key_id", + "algorithm", + "public_key", + "valid_from" + ], + "additionalProperties": false, + "properties": { + "key_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "algorithm": { "const": "ed25519" }, + "public_key": { + "type": "string", + "minLength": 1, + "description": "Base64url (no padding) or standard base64 encoding of the 32-byte ed25519 public key." + }, + "valid_from": { "$ref": "common.defs.json#/$defs/iso8601_datetime" }, + "valid_until": { "$ref": "common.defs.json#/$defs/iso8601_datetime" }, + "revoked_at": { "$ref": "common.defs.json#/$defs/iso8601_datetime" }, + "purposes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "release_signing", + "external_attestation", + "publication_bundle", + "development" + ] + }, + "uniqueItems": true + }, + "note": { "type": "string" } + } + } + }, + "signature_or_digest": { "$ref": "common.defs.json#/$defs/hex_digest" } + } +} diff --git a/scripts/certifyedge-dev-fixture.py b/scripts/certifyedge-dev-fixture.py new file mode 100644 index 0000000..4e48ffb --- /dev/null +++ b/scripts/certifyedge-dev-fixture.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Deterministic CertifyEdge development fixture (NOT production). + +Writes a content-addressed fixture binary whose SHA-256 is stable across +platforms. Used only with provision_strategy=dev_fixture for tests/preview. +""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +from pathlib import Path + +# Keep in sync with pcs_core.certifyedge_pin.DEV_FIXTURE_MARKER +DEV_FIXTURE_MARKER = b"PCS_CERTIFYEDGE_DEV_FIXTURE_V1\n" + + +def fixture_digest() -> str: + return f"sha256:{hashlib.sha256(DEV_FIXTURE_MARKER).hexdigest()}" + + +def write_fixture(dest: Path) -> str: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(DEV_FIXTURE_MARKER) + return f"sha256:{hashlib.sha256(dest.read_bytes()).hexdigest()}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--out", + type=Path, + default=None, + help="Destination path for the fixture binary bytes", + ) + parser.add_argument( + "--print-digest-only", + action="store_true", + help="Print the expected digest and exit without writing", + ) + args = parser.parse_args(argv) + if args.print_digest_only: + print(fixture_digest()) + return 0 + if args.out is None: + parser.error("--out is required unless --print-digest-only is set") + digest = write_fixture(args.out) + print(digest) + if digest != fixture_digest(): + print("FAIL: fixture digest drift", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/provision-certifyedge.sh b/scripts/provision-certifyedge.sh index c9f0b83..351a65f 100644 --- a/scripts/provision-certifyedge.sh +++ b/scripts/provision-certifyedge.sh @@ -1,12 +1,19 @@ #!/usr/bin/env bash # Provision CertifyEdge from pins/certifyedge.json (immutable strategies only). -# Fail-closed in PCS_RELEASE_MODE=release when the pin is unset. +# Fail-closed in PCS_RELEASE_MODE=release when the pin is unset / unpinned. +# +# Always emits a machine-readable environment file when provisioning succeeds: +# ${OUT_DIR}/provision.env +# Fields: executable path, binary digest, version, pin identity, strategy, trust grade. +# Workflows MUST source this file and must NOT overwrite PF_CORE_CERTIFYEDGE_CLI +# with an empty repository secret. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PIN_FILE="${PCS_CERTIFYEDGE_PIN:-${ROOT}/pins/certifyedge.json}" MODE="${PCS_RELEASE_MODE:-preview}" OUT_DIR="${PCS_CERTIFYEDGE_INSTALL_DIR:-${ROOT}/.tools/certifyedge}" +ENV_FILE="${PCS_CERTIFYEDGE_PROVISION_ENV:-${OUT_DIR}/provision.env}" export PCS_RELEASE_MODE="${MODE}" @@ -14,8 +21,28 @@ python3 "${ROOT}/scripts/verify-certifyedge-pin.py" --pin "${PIN_FILE}" --mode " STATUS="$(python3 -c "import json,pathlib; print(json.loads(pathlib.Path(r'${PIN_FILE}').read_text(encoding='utf-8')).get('status',''))")" STRATEGY="$(python3 -c "import json,pathlib; print(json.loads(pathlib.Path(r'${PIN_FILE}').read_text(encoding='utf-8')).get('provision_strategy','none'))")" +VERSION="$(python3 -c "import json,pathlib; print(json.loads(pathlib.Path(r'${PIN_FILE}').read_text(encoding='utf-8')).get('version','') or 'unknown')")" -if [[ "${STATUS}" != "pinned" || "${STRATEGY}" == "none" || -z "${STRATEGY}" ]]; then +write_provision_env() { + local exe="$1" + local digest="$2" + local trust_grade="$3" + local pin_identity + pin_identity="$(python3 -c "import json,pathlib,sys; sys.path.insert(0, r'${ROOT}/python'); from pcs_core.certifyedge_pin import load_certifyedge_pin, pin_identity_from; p=json.loads(pathlib.Path(r'${PIN_FILE}').read_text(encoding='utf-8')); print(pin_identity_from(p))")" + mkdir -p "$(dirname "${ENV_FILE}")" + cat > "${ENV_FILE}" <&2 exit 1 @@ -24,6 +51,12 @@ if [[ "${STATUS}" != "pinned" || "${STRATEGY}" == "none" || -z "${STRATEGY}" ]]; exit 0 fi +# Release mode rejects non-production strategies (including dev_fixture). +if [[ "${MODE}" == "release" && "${STRATEGY}" == "dev_fixture" ]]; then + echo "FAIL: provision_strategy=dev_fixture is not allowed in release mode" >&2 + exit 1 +fi + mkdir -p "${OUT_DIR}" case "${STRATEGY}" in @@ -37,7 +70,6 @@ case "${STRATEGY}" in fi echo "Pulling ${REF}" docker pull "${REF}" - # Wrapper invokes the pinned image; digest is part of the image reference. WRAPPER="${OUT_DIR}/certifyedge" cat > "${WRAPPER}" <&2 exit 1 @@ -92,9 +129,28 @@ PY DEST="${OUT_DIR}/certifyedge" cp "${BIN}" "${DEST}" chmod +x "${DEST}" + BIN_DIGEST="$(python3 -c "import hashlib,pathlib; print('sha256:'+hashlib.sha256(pathlib.Path(r'${DEST}').read_bytes()).hexdigest())")" + write_provision_env "${DEST}" "${BIN_DIGEST}" "pinned" echo "OK provisioned CertifyEdge from ${COMMIT} at ${DEST}" echo "${DEST}" ;; + dev_fixture) + # Test/preview only — deterministic content-addressed fixture (not production). + DEST="${OUT_DIR}/certifyedge" + DIGEST="$(python3 "${ROOT}/scripts/certifyedge-dev-fixture.py" --out "${DEST}")" + EXPECTED="$(python3 "${ROOT}/scripts/certifyedge-dev-fixture.py" --print-digest-only)" + if [[ "${DIGEST}" != "${EXPECTED}" ]]; then + echo "FAIL: dev fixture digest mismatch: ${DIGEST} != ${EXPECTED}" >&2 + exit 1 + fi + # Also install a runnable stub wrapper for CLI smoke (separate from digest pin). + RUNNER="${OUT_DIR}/certifyedge-run" + cp "${ROOT}/scripts/certifyedge-stub.py" "${RUNNER}" + chmod +x "${DEST}" "${RUNNER}" || true + write_provision_env "${DEST}" "${DIGEST}" "untrusted_development" + echo "OK provisioned CertifyEdge DEV FIXTURE at ${DEST} (trust_grade=untrusted_development)" + echo "${DEST}" + ;; *) echo "FAIL: unsupported provision_strategy=${STRATEGY}" >&2 exit 1 diff --git a/scripts/verify-certifyedge-pin.py b/scripts/verify-certifyedge-pin.py index 5010b12..254b79a 100644 --- a/scripts/verify-certifyedge-pin.py +++ b/scripts/verify-certifyedge-pin.py @@ -11,77 +11,20 @@ import argparse import json -import re import sys from pathlib import Path -PLACEHOLDER_MARKERS = ( - "REPLACE_WITH", - "REPLACE_ME", - "example/certifyedge", - "sha256:REPLACE", -) +# Allow running from a checkout without PYTHONPATH when pcs-core is not installed. +_REPO = Path(__file__).resolve().parents[1] +_PY = _REPO / "python" +if _PY.is_dir() and str(_PY) not in sys.path: + sys.path.insert(0, str(_PY)) -DIGEST_RE = re.compile(r"^sha256:[a-f0-9]{64}$") -COMMIT_RE = re.compile(r"^[a-f0-9]{40}$") - - -def _is_placeholder(value: str) -> bool: - if not value or not value.strip(): - return True - upper = value.upper() - return any(marker.upper() in upper for marker in PLACEHOLDER_MARKERS) - - -def load_pin(path: Path) -> dict: - data = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise ValueError("pin root must be a JSON object") - return data - - -def pin_is_production_ready(pin: dict) -> tuple[bool, list[str]]: - """Return whether the pin can provision an immutable CertifyEdge binary.""" - errors: list[str] = [] - status = str(pin.get("status") or "").strip().lower() - strategy = str(pin.get("provision_strategy") or "").strip().lower() - - if status != "pinned": - errors.append(f"status is {status!r}; need 'pinned' for release provisioning") - if strategy in {"", "none"}: - errors.append("provision_strategy is unset (none)") - return False, errors - - if strategy == "oci_digest": - image = str(pin.get("image") or "") - digest = str(pin.get("image_digest") or "") - if _is_placeholder(image): - errors.append("image is empty or placeholder") - if not DIGEST_RE.match(digest) or _is_placeholder(digest): - errors.append("image_digest must be sha256:<64 hex> (no placeholders)") - elif strategy == "signed_binary": - url = str(pin.get("binary_url") or "") - digest = str(pin.get("binary_sha256") or "") - if _is_placeholder(url): - errors.append("binary_url is empty or placeholder") - if not DIGEST_RE.match(digest) and not re.fullmatch(r"[a-f0-9]{64}", digest): - errors.append("binary_sha256 must be sha256:<64 hex> or bare 64-hex digest") - if _is_placeholder(digest): - errors.append("binary_sha256 is placeholder") - elif strategy == "source_commit_build": - repo = str(pin.get("source_repo") or "") - commit = str(pin.get("source_commit") or "") - if _is_placeholder(repo): - errors.append("source_repo is empty or placeholder") - if not COMMIT_RE.match(commit): - errors.append("source_commit must be a full 40-char git SHA") - else: - errors.append( - f"unknown provision_strategy {strategy!r}; " - "expected oci_digest | signed_binary | source_commit_build" - ) - - return not errors, errors +from pcs_core.certifyedge_pin import ( # noqa: E402 + load_certifyedge_pin, + pin_allows_dev_fixture, + pin_is_production_ready, +) def main(argv: list[str] | None = None) -> int: @@ -96,28 +39,27 @@ def main(argv: list[str] | None = None) -> int: "--mode", choices=("release", "preview", "dev"), default="preview", - help="release fails closed when pin unset; preview/dev allow unpinned", + help="release fails closed when pin unset; preview/dev allow unpinned or dev_fixture", ) args = parser.parse_args(argv) pin_path = args.pin if pin_path is None: - root = Path(__file__).resolve().parents[1] - pin_path = root / "pins" / "certifyedge.json" + pin_path = _REPO / "pins" / "certifyedge.json" if not pin_path.is_file(): print(f"FAIL: CertifyEdge pin missing: {pin_path}", file=sys.stderr) return 1 try: - pin = load_pin(pin_path) + pin = load_certifyedge_pin(pin_path) except (OSError, json.JSONDecodeError, ValueError) as exc: print(f"FAIL: cannot read CertifyEdge pin: {exc}", file=sys.stderr) return 1 ready, errors = pin_is_production_ready(pin) - status = str(pin.get("status") or "unknown") - strategy = str(pin.get("provision_strategy") or "none") + status = pin.status + strategy = pin.provision_strategy if args.mode == "release": if not ready: @@ -125,23 +67,41 @@ def main(argv: list[str] | None = None) -> int: for err in errors: print(f" - {err}", file=sys.stderr) print( - "Set pins/certifyedge.json status=pinned with a real immutable digest, " - "or publish a technical preview under PCS_RELEASE_MODE=preview.", + "Set pins/certifyedge.json status=pinned with a real immutable digest " + "(oci_digest | signed_binary | source_commit_build). " + "Do not invent placeholder digests. " + "dev_fixture is test/preview only. " + "Or publish a technical preview under PCS_RELEASE_MODE=preview.", file=sys.stderr, ) return 1 print(f"OK CertifyEdge pin ready (strategy={strategy}, status={status})") return 0 + # preview / dev if ready: print(f"OK CertifyEdge pin ready (strategy={strategy}, status={status})") - else: - print( - f"OK CertifyEdge pin unpinned for {args.mode} mode " - f"(strategy={strategy}, status={status}); live attestation not provisionable" - ) - for err in errors: - print(f" note: {err}") + return 0 + + if strategy == "dev_fixture": + ok, fixture_errors = pin_allows_dev_fixture(pin) + if ok: + print( + f"OK CertifyEdge DEV FIXTURE pin acceptable for {args.mode} " + f"(strategy={strategy}, status={status}); trust_grade=untrusted_development" + ) + return 0 + print("FAIL: invalid CertifyEdge dev_fixture pin:", file=sys.stderr) + for err in fixture_errors: + print(f" - {err}", file=sys.stderr) + return 1 + + print( + f"OK CertifyEdge pin unpinned for {args.mode} mode " + f"(strategy={strategy}, status={status}); live attestation not provisionable" + ) + for err in errors: + print(f" note: {err}") return 0 From db43da76be3de97e8b7342acdb0a3219d3faabce Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:02:20 -0700 Subject: [PATCH 17/24] Add release provenance binding and SLSA attestation scripts. Wire provenance subjects through a ReleaseProvenanceBinding schema so attestation finalize/verify steps share one subject digest contract. --- .github/workflows/release-provenance.yml | 267 +++++++++++--- .../tests/test_release_provenance_binding.py | 168 +++++++++ .../ReleaseProvenanceBinding.v0.schema.json | 207 +++++++++++ scripts/build-release-provenance.sh | 328 ++++++++++++++++++ scripts/finalize-provenance-attestation.sh | 115 ++++++ scripts/verify-release-provenance.sh | 237 +++++++++++++ 6 files changed, 1277 insertions(+), 45 deletions(-) create mode 100644 python/tests/test_release_provenance_binding.py create mode 100644 schemas/ReleaseProvenanceBinding.v0.schema.json create mode 100644 scripts/build-release-provenance.sh create mode 100644 scripts/finalize-provenance-attestation.sh create mode 100644 scripts/verify-release-provenance.sh diff --git a/.github/workflows/release-provenance.yml b/.github/workflows/release-provenance.yml index 62903cd..1ac73bb 100644 --- a/.github/workflows/release-provenance.yml +++ b/.github/workflows/release-provenance.yml @@ -1,72 +1,249 @@ name: Release provenance -# SLSA / SBOM scaffolding for version tags. Full SLSA Build L3 generators require -# org trust setup; this workflow emits SBOM + a provenance statement stub. +# PR15 / B8: GitHub artifact attestations (SLSA provenance + SBOM) for release +# subjects. Binds source commit, workflow/builder identity, lockfiles, verifier +# image digest, wheel digests, SBOM digest, and PF-Core bundle root when present. +# +# Fail-closed honesty: when attestations cannot be created (private repo without +# GHEC, missing permissions, OIDC), status=gated with an explicit notice. Tag / +# require-signed runs fail unless vars.PCS_PROVENANCE_ALLOW_GATED=true. +# +# Mandatory CI matrix: PR CI runs digest-binding + consumer verify in ci.yml +# `provenance-verification` (signed attestations remain org-gated). This workflow +# is the standalone produce + clean-consumer path for tags / workflow_dispatch. on: push: tags: - "v*" workflow_dispatch: + inputs: + require_signed: + description: "Fail if signed GitHub attestations cannot be created/verified" + required: true + default: false + type: boolean + allow_gated: + description: "Permit gated (unsigned) digest binding for this run" + required: true + default: true + type: boolean + build_pf_core_bundle: + description: "Install elan and assemble a PF-Core release bundle for root digest" + required: true + default: true + type: boolean permissions: - contents: write + contents: read id-token: write attestations: write + actions: read jobs: - provenance-scaffold: + produce-and-attest: runs-on: ubuntu-latest + outputs: + attestation_status: ${{ steps.finalize.outputs.status }} + artifact_name: pcs-core-release-provenance steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - - name: Generate SBOM scaffold + + - name: Resolve require-signed policy + id: policy run: | - bash scripts/generate-sbom.sh dist/sbom - test -f dist/sbom/pcs-core.cdx.json - - name: Write provenance statement stub + REQUIRE=false + if [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/v* ]]; then + REQUIRE=true + fi + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.require_signed }}" = "true" ]; then + REQUIRE=true + fi + if [ "${{ vars.PCS_PROVENANCE_ALLOW_GATED }}" = "true" ] || [ "${{ inputs.allow_gated }}" = "true" ]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.require_signed }}" != "true" ]; then + REQUIRE=false + fi + if [ "${{ vars.PCS_PROVENANCE_ALLOW_GATED }}" = "true" ]; then + echo "NOTE: vars.PCS_PROVENANCE_ALLOW_GATED=true (org still enabling attestations)" + REQUIRE=false + fi + fi + echo "require_signed=${REQUIRE}" >> "$GITHUB_OUTPUT" + echo "PCS_PROVENANCE_REQUIRE_SIGNED=$([ "${REQUIRE}" = "true" ] && echo 1 || echo 0)" >> "$GITHUB_ENV" + echo "Resolved require_signed=${REQUIRE}" + + - name: Install Python package (for schema validate + optional bundle) + run: | + cd python + pip install -c requirements.lock -e ".[dev]" + pip install build + + - name: Optional PF-Core release bundle (bundle root digest) + if: ${{ github.event_name == 'push' || inputs.build_pf_core_bundle == true }} run: | - mkdir -p dist/provenance + set -euo pipefail + bash scripts/install-elan-verified.sh + export PATH="$HOME/.elan/bin:$PATH" + cd lean + lake build PFCore + cd ../python + pcs pf-core lean-check \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --out /tmp/pfcore-provenance-cert.json \ + --result-out /tmp/pfcore-provenance-lean-check.json + mkdir -p ../dist/release-bundle + pcs pf-core bundle-release \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --cert /tmp/pfcore-provenance-cert.json \ + --lean-check-result /tmp/pfcore-provenance-lean-check.json \ + --out ../dist/release-bundle + pcs pf-core validate-bundle ../dist/release-bundle + echo "PCS_PROVENANCE_BUNDLE_DIR=${GITHUB_WORKSPACE}/dist/release-bundle" >> "$GITHUB_ENV" + + - name: Build provenance subjects + binding + run: | + bash scripts/build-release-provenance.sh dist/provenance + test -f dist/provenance/ReleaseProvenanceBinding.v0.json + test -f dist/provenance/subjects-attest.sha256 python3 - <<'PY' - import json, os, pathlib, datetime - root = pathlib.Path("dist/provenance") - version = pathlib.Path("VERSION").read_text(encoding="utf-8").strip() - statement = { - "_type": "https://in-toto.io/Statement/v1", - "subject": [{"name": "pcs-core", "digest": {"gitCommit": os.environ.get("GITHUB_SHA", "")}}], - "predicateType": "https://slsa.dev/provenance/v1", - "predicate": { - "buildDefinition": { - "buildType": "https://github.com/SentinelOps-CI/pcs-core/docs/security-governance.md#slsa-scaffold", - "externalParameters": { - "ref": os.environ.get("GITHUB_REF", ""), - "version": version, - }, - }, - "runDetails": { - "builder": {"id": "https://github.com/SentinelOps-CI/pcs-core/.github/workflows/release-provenance.yml"}, - "metadata": { - "invocationId": os.environ.get("GITHUB_RUN_ID", ""), - "startedOn": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z", - }, - }, - "notes": [ - "Scaffold only: replace with official SLSA generator when org permissions allow.", - "Pair with SBOM at dist/sbom/pcs-core.cdx.json and OCI digests when published.", - ], - }, - } - out = root / "provenance.slsa.json" - out.write_text(json.dumps(statement, indent=2) + "\n", encoding="utf-8") - print(f"OK wrote {out}") + import json + from pathlib import Path + from pcs_core.validate import validate_artifact + binding = json.loads(Path("dist/provenance/ReleaseProvenanceBinding.v0.json").read_text(encoding="utf-8")) + # pending attestation is schema-valid + validate_artifact(binding, "ReleaseProvenanceBinding.v0", release_grade=False) + print("OK binding schema (pending)") PY - - name: Upload SBOM and provenance artifacts + + - name: Attest build provenance (immutable subjects) + id: attest_prov + continue-on-error: true + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-checksums: dist/provenance/subjects-attest.sha256 + + - name: Attest SBOM + id: attest_sbom + continue-on-error: true + uses: actions/attest-sbom@115c3be05ff3974bcbd596578934b3f9ce39bf68 # v2.2.0 + with: + subject-path: dist/provenance/sbom/pcs-core.cdx.json + sbom-path: dist/provenance/sbom/pcs-core.cdx.json + + - name: Finalize attestation status (signed or gated) + id: finalize + run: | + set -euo pipefail + PROV_OK="${{ steps.attest_prov.outcome }}" + SBOM_OK="${{ steps.attest_sbom.outcome }}" + IDS="${{ steps.attest_prov.outputs.attestation-id }}" + URLS="${{ steps.attest_prov.outputs.attestation-url }}" + if [ -n "${{ steps.attest_sbom.outputs.attestation-id }}" ]; then + if [ -n "${IDS}" ]; then IDS="${IDS},"; fi + IDS="${IDS}${{ steps.attest_sbom.outputs.attestation-id }}" + fi + if [ -n "${{ steps.attest_sbom.outputs.attestation-url }}" ]; then + if [ -n "${URLS}" ]; then URLS="${URLS},"; fi + URLS="${URLS}${{ steps.attest_sbom.outputs.attestation-url }}" + fi + + if [ "${PROV_OK}" = "success" ]; then + bash scripts/finalize-provenance-attestation.sh dist/provenance signed "" "${IDS}" "${URLS}" + else + REASON="actions/attest-build-provenance failed (outcome=${PROV_OK}; sbom_outcome=${SBOM_OK}). " + REASON+="Common causes: private repository without GitHub Enterprise Cloud, " + REASON+="missing id-token/attestations permissions, or org policy blocking Sigstore OIDC." + bash scripts/finalize-provenance-attestation.sh dist/provenance gated "${REASON}" "" "" + fi + + STATUS="$(python3 -c "import json; print(json.load(open('dist/provenance/ReleaseProvenanceBinding.v0.json'))['attestation']['status'])")" + echo "status=${STATUS}" >> "$GITHUB_OUTPUT" + + if [ "${{ steps.policy.outputs.require_signed }}" = "true" ] && [ "${STATUS}" != "signed" ]; then + echo "FAIL: signed provenance required but status=${STATUS}" + echo "Set repository variable PCS_PROVENANCE_ALLOW_GATED=true only while org attestation setup is incomplete." + exit 1 + fi + + - name: Attest sealed binding + id: attest_binding + if: ${{ steps.finalize.outputs.status == 'signed' }} + continue-on-error: true + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: dist/provenance/ReleaseProvenanceBinding.v0.json + + - name: Upload provenance package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: pcs-core-sbom-provenance - path: | - dist/sbom/ - dist/provenance/ + name: pcs-core-release-provenance + path: dist/provenance retention-days: 90 + include-hidden-files: false + + consumer-verify: + name: Consumer provenance verify + needs: produce-and-attest + runs-on: ubuntu-latest + permissions: + contents: read + attestations: read + actions: read + steps: + - name: Checkout scripts only (verification helpers) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: | + scripts + python + schemas + catalog + pins + sparse-checkout-cone-mode: true + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install validator (schema check only) + run: | + cd python + pip install -c requirements.lock -e "." + + - name: Download provenance package (no producer tree reuse) + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: pcs-core-release-provenance + path: /tmp/pcs-provenance-dl + + - name: Verify provenance as clean consumer + env: + GH_TOKEN: ${{ github.token }} + PCS_PROVENANCE_REQUIRE_SIGNED: ${{ needs.produce-and-attest.outputs.attestation_status == 'signed' && '1' || '0' }} + run: | + # upload-artifact may nest under dist/provenance; normalize to a flat package dir. + if [ -f /tmp/pcs-provenance-dl/ReleaseProvenanceBinding.v0.json ]; then + PKG=/tmp/pcs-provenance-dl + elif [ -f /tmp/pcs-provenance-dl/provenance/ReleaseProvenanceBinding.v0.json ]; then + PKG=/tmp/pcs-provenance-dl/provenance + elif [ -f /tmp/pcs-provenance-dl/dist/provenance/ReleaseProvenanceBinding.v0.json ]; then + PKG=/tmp/pcs-provenance-dl/dist/provenance + else + echo "FAIL: could not locate ReleaseProvenanceBinding.v0.json in artifact" >&2 + find /tmp/pcs-provenance-dl -maxdepth 4 -type f | head -n 50 + exit 1 + fi + test ! -d "${GITHUB_WORKSPACE}/dist/provenance" || echo "NOTE: checkout may contain scripts only" + bash scripts/verify-release-provenance.sh "${PKG}" + test -f "${PKG}/consumer-verification-result.json" + python3 - < dict: + digest = "sha256:" + ("a" * 64) + commit = "b" * 40 + body = { + "schema_version": "v0", + "artifact_type": "ReleaseProvenanceBinding.v0", + "canonicalization_version": "v1", + "version": "0.0.0-test", + "source_commit": commit, + "source_ref": "refs/heads/main", + "workflow": { + "repository": "SentinelOps-CI/pcs-core", + "workflow_ref": "SentinelOps-CI/pcs-core/.github/workflows/release-provenance.yml@refs/heads/main", + "workflow_sha": commit, + "run_id": "1", + "run_attempt": "1", + "event_name": "workflow_dispatch", + "server_url": "https://github.com", + }, + "builder": { + "id": "https://github.com/SentinelOps-CI/pcs-core/actions/runs/1", + "runner_name": "test", + "runner_os": "Linux", + "runner_arch": "X64", + }, + "lockfiles": { + "python/requirements.lock": { + "path": "python/requirements.lock", + "sha256": digest, + }, + "rust/Cargo.lock": {"path": "rust/Cargo.lock", "sha256": digest}, + "typescript/package-lock.json": { + "path": "typescript/package-lock.json", + "sha256": digest, + }, + }, + "verifier_image": { + "pin_path": "pins/python-base-image.json", + "index_digest": digest, + "dockerfile_from": f"python@{digest}", + "pin_file_sha256": digest, + }, + "wheels": [ + { + "path": "wheels/pcs_core-0.0.0-py3-none-any.whl", + "filename": "pcs_core-0.0.0-py3-none-any.whl", + "sha256": digest, + } + ], + "sbom": { + "path": "sbom/pcs-core.cdx.json", + "sha256": digest, + "format": "scaffold-CycloneDX-JSON", + }, + "bundle": { + "status": "absent", + "absence_reason": "unit test fixture", + }, + "attestation": { + "status": "gated", + "predicate_type": "https://slsa.dev/provenance/v1", + "method": "none", + "attestation_ids": [], + "attestation_urls": [], + "gate_reason": "unit test", + }, + "subjects_checksums_path": "subjects.sha256", + } + sealed = {**body, **overrides} + canonical = json.dumps( + {k: v for k, v in sealed.items() if k != "signature_or_digest"}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + sealed["signature_or_digest"] = "sha256:" + hashlib.sha256( + canonical.encode("utf-8") + ).hexdigest() + return sealed + + +def test_release_provenance_binding_schema_accepts_gated() -> None: + validate_artifact(_minimal_binding(), "ReleaseProvenanceBinding.v0", release_grade=True) + + +def test_release_provenance_binding_rejects_fake_signed_without_method() -> None: + bad = _minimal_binding() + bad["attestation"]["status"] = "signed" + bad["attestation"]["method"] = "none" + # Still schema-valid (honesty enforced by finalize/verify scripts), but + # signature_or_digest must be recomputed for schema-only check. + sealed = {k: v for k, v in bad.items() if k != "signature_or_digest"} + canonical = json.dumps(sealed, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + bad["signature_or_digest"] = "sha256:" + hashlib.sha256( + canonical.encode("utf-8") + ).hexdigest() + validate_artifact(bad, "ReleaseProvenanceBinding.v0", release_grade=True) + + +def test_release_provenance_binding_rejects_bad_commit() -> None: + with pytest.raises(ValidationError): + validate_artifact( + _minimal_binding(source_commit="not-a-commit"), + "ReleaseProvenanceBinding.v0", + ) + + +@pytest.mark.skipif( + not (ROOT / "scripts" / "build-release-provenance.sh").is_file(), + reason="scripts missing", +) +@pytest.mark.skipif( + __import__("os").name == "nt", + reason="bash provenance scripts require a POSIX shell with native paths", +) +def test_build_and_verify_provenance_scripts_gated(tmp_path: Path) -> None: + """End-to-end local gated path (no GitHub Sigstore).""" + out = tmp_path / "provenance" + env = { + **dict(__import__("os").environ), + "PCS_PROVENANCE_BUILD_WHEELS": "1", + "PCS_PROVENANCE_BUILD_SBOM": "1", + } + # Avoid requiring a PF-Core bundle for this unit smoke. + env.pop("PCS_PROVENANCE_BUNDLE_DIR", None) + subprocess.run( + ["bash", str(ROOT / "scripts" / "build-release-provenance.sh"), str(out)], + check=True, + cwd=str(ROOT), + env=env, + ) + subprocess.run( + [ + "bash", + str(ROOT / "scripts" / "finalize-provenance-attestation.sh"), + str(out), + "gated", + "pytest local gated path", + ], + check=True, + cwd=str(ROOT), + ) + subprocess.run( + ["bash", str(ROOT / "scripts" / "verify-release-provenance.sh"), str(out)], + check=True, + cwd=str(ROOT), + env={**env, "PCS_PROVENANCE_REQUIRE_SIGNED": "0"}, + ) + binding = json.loads((out / "ReleaseProvenanceBinding.v0.json").read_text(encoding="utf-8")) + assert binding["attestation"]["status"] == "gated" + assert (out / "PROVENANCE_ATTESTATION_GATED.json").is_file() + validate_artifact(binding, "ReleaseProvenanceBinding.v0", release_grade=True) diff --git a/schemas/ReleaseProvenanceBinding.v0.schema.json b/schemas/ReleaseProvenanceBinding.v0.schema.json new file mode 100644 index 0000000..9a9b023 --- /dev/null +++ b/schemas/ReleaseProvenanceBinding.v0.schema.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/ReleaseProvenanceBinding.v0.schema.json", + "title": "ReleaseProvenanceBinding.v0", + "description": "Digest-bound release provenance subjects: source commit, workflow/builder identity, lockfiles, verifier image, wheels, SBOM, and PF-Core bundle root. attestation.status is signed when GitHub artifact attestations succeed; gated when org permissions or plan features block signing (fail-closed honesty).", + "type": "object", + "required": [ + "schema_version", + "artifact_type", + "version", + "source_commit", + "workflow", + "builder", + "lockfiles", + "verifier_image", + "wheels", + "sbom", + "bundle", + "attestation", + "subjects_checksums_path", + "signature_or_digest" + ], + "additionalProperties": false, + "properties": { + "schema_version": { "$ref": "common.defs.json#/$defs/schema_version" }, + "artifact_type": { "const": "ReleaseProvenanceBinding.v0" }, + "canonicalization_version": { + "$ref": "common.defs.json#/$defs/canonicalization_version" + }, + "version": { "type": "string", "minLength": 1, "maxLength": 64 }, + "source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "source_ref": { "type": "string", "minLength": 1, "maxLength": 512 }, + "workflow": { + "type": "object", + "required": [ + "repository", + "workflow_ref", + "workflow_sha", + "run_id", + "run_attempt", + "event_name" + ], + "additionalProperties": false, + "properties": { + "repository": { "type": "string", "minLength": 1, "maxLength": 256 }, + "workflow_ref": { "type": "string", "minLength": 1, "maxLength": 512 }, + "workflow_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "run_id": { "type": "string", "minLength": 1, "maxLength": 64 }, + "run_attempt": { "type": "string", "minLength": 1, "maxLength": 16 }, + "event_name": { "type": "string", "minLength": 1, "maxLength": 64 }, + "server_url": { "type": "string", "minLength": 1, "maxLength": 256 } + } + }, + "builder": { + "type": "object", + "required": ["id", "runner_name", "runner_os", "runner_arch"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "runner_name": { "type": "string", "minLength": 1, "maxLength": 256 }, + "runner_os": { "type": "string", "minLength": 1, "maxLength": 64 }, + "runner_arch": { "type": "string", "minLength": 1, "maxLength": 64 } + } + }, + "lockfiles": { + "type": "object", + "required": [ + "python/requirements.lock", + "rust/Cargo.lock", + "typescript/package-lock.json" + ], + "additionalProperties": { + "type": "object", + "required": ["sha256", "path"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "common.defs.json#/$defs/hex_digest" } + } + }, + "properties": { + "python/requirements.lock": { + "type": "object", + "required": ["sha256", "path"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "common.defs.json#/$defs/hex_digest" } + } + }, + "rust/Cargo.lock": { + "type": "object", + "required": ["sha256", "path"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "common.defs.json#/$defs/hex_digest" } + } + }, + "typescript/package-lock.json": { + "type": "object", + "required": ["sha256", "path"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "common.defs.json#/$defs/hex_digest" } + } + } + } + }, + "verifier_image": { + "type": "object", + "required": ["pin_path", "index_digest", "dockerfile_from"], + "additionalProperties": false, + "properties": { + "pin_path": { "const": "pins/python-base-image.json" }, + "index_digest": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "amd64_digest": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "dockerfile_from": { "type": "string", "minLength": 1, "maxLength": 512 }, + "pin_file_sha256": { "$ref": "common.defs.json#/$defs/hex_digest" } + } + }, + "wheels": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["path", "sha256", "filename"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "filename": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "common.defs.json#/$defs/hex_digest" } + } + } + }, + "sbom": { + "type": "object", + "required": ["path", "sha256", "format"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "format": { + "type": "string", + "enum": ["CycloneDX-JSON", "SPDX-JSON", "scaffold-CycloneDX-JSON"] + } + } + }, + "bundle": { + "type": "object", + "required": ["status"], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["present", "absent"] + }, + "path": { "type": "string", "minLength": 1 }, + "archive_path": { "type": "string", "minLength": 1 }, + "archive_sha256": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "manifest_digest": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "absence_reason": { "type": "string", "minLength": 1, "maxLength": 1024 } + } + }, + "attestation": { + "type": "object", + "required": ["status", "predicate_type"], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["signed", "gated", "pending"] + }, + "predicate_type": { + "type": "string", + "const": "https://slsa.dev/provenance/v1" + }, + "method": { + "type": "string", + "enum": [ + "actions/attest-build-provenance", + "actions/attest-sbom", + "none" + ] + }, + "attestation_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "attestation_urls": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "gate_reason": { "type": "string", "minLength": 1, "maxLength": 2048 } + } + }, + "subjects_checksums_path": { "type": "string", "minLength": 1 }, + "signature_or_digest": { "$ref": "common.defs.json#/$defs/hex_digest" } + } +} diff --git a/scripts/build-release-provenance.sh b/scripts/build-release-provenance.sh new file mode 100644 index 0000000..4e32090 --- /dev/null +++ b/scripts/build-release-provenance.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# Build release provenance subjects + ReleaseProvenanceBinding.v0. +# Binds: source commit, workflow/builder identity, lockfiles, verifier image +# digest, wheel digests, SBOM digest, and (when present) PF-Core bundle root. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="${1:-${ROOT}/dist/provenance}" +WHEEL_DIR="${PCS_PROVENANCE_WHEEL_DIR:-${ROOT}/dist/wheels}" +SBOM_DIR="${PCS_PROVENANCE_SBOM_DIR:-${ROOT}/dist/sbom}" +BUNDLE_DIR="${PCS_PROVENANCE_BUNDLE_DIR:-}" +BUILD_WHEELS="${PCS_PROVENANCE_BUILD_WHEELS:-1}" +BUILD_SBOM="${PCS_PROVENANCE_BUILD_SBOM:-1}" + +mkdir -p "${OUT_DIR}" "${WHEEL_DIR}" + +sha256_file() { + local path="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${path}" | awk '{print $1}' + else + python3 -c "import hashlib, pathlib, sys; print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())" "${path}" + fi +} + +VERSION="$(tr -d '\r\n' < "${ROOT}/VERSION")" +SOURCE_COMMIT="${GITHUB_SHA:-}" +if [ -z "${SOURCE_COMMIT}" ]; then + SOURCE_COMMIT="$(git -C "${ROOT}" rev-parse HEAD)" +fi +SOURCE_REF="${GITHUB_REF:-$(git -C "${ROOT}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo local)}" + +if [ "${BUILD_WHEELS}" = "1" ]; then + if ! python3 -c "import build" >/dev/null 2>&1; then + python3 -m pip install --upgrade build >/dev/null 2>&1 \ + || python3 -m pip install --upgrade --user build >/dev/null 2>&1 \ + || python3 -m pip install --upgrade --break-system-packages build >/dev/null + fi + rm -rf "${ROOT}/python/dist" + (cd "${ROOT}/python" && python3 -m build --wheel) + shopt -s nullglob + wheels=("${ROOT}/python/dist"/pcs_core-*.whl) + shopt -u nullglob + if [ "${#wheels[@]}" -eq 0 ]; then + echo "FAIL: no pcs_core wheel produced under python/dist" >&2 + exit 1 + fi + cp -f "${wheels[@]}" "${WHEEL_DIR}/" +fi + +shopt -s nullglob +WHEEL_FILES=("${WHEEL_DIR}"/pcs_core-*.whl) +shopt -u nullglob +if [ "${#WHEEL_FILES[@]}" -eq 0 ]; then + echo "FAIL: no wheels in ${WHEEL_DIR}" >&2 + exit 1 +fi + +if [ "${BUILD_SBOM}" = "1" ]; then + bash "${ROOT}/scripts/generate-sbom.sh" "${SBOM_DIR}" +fi +SBOM_PATH="${SBOM_DIR}/pcs-core.cdx.json" +test -f "${SBOM_PATH}" + +# Optional PF-Core release bundle (directory). Archive for subject attestation. +BUNDLE_STATUS="absent" +BUNDLE_PATH="" +BUNDLE_ARCHIVE="" +BUNDLE_ARCHIVE_SHA="" +BUNDLE_MANIFEST_DIGEST="" +BUNDLE_ABSENCE="Bundle directory not provided (set PCS_PROVENANCE_BUNDLE_DIR)." + +if [ -n "${BUNDLE_DIR}" ] && [ -d "${BUNDLE_DIR}" ]; then + BUNDLE_STATUS="present" + BUNDLE_PATH="${BUNDLE_DIR}" + BUNDLE_ABSENCE="" + BUNDLE_ARCHIVE="${OUT_DIR}/pf-core-release-bundle.tar.gz" + # Deterministic-ish archive: sorted paths, stable ownership metadata. + ( + cd "${BUNDLE_DIR}" + if tar --version 2>/dev/null | grep -qi gnu; then + tar --sort=name --owner=0 --group=0 --numeric-owner --mtime='UTC 1970-01-01' \ + -czf "${BUNDLE_ARCHIVE}" . + else + tar -czf "${BUNDLE_ARCHIVE}" . + fi + ) + BUNDLE_ARCHIVE_SHA="$(sha256_file "${BUNDLE_ARCHIVE}")" + if [ -f "${BUNDLE_DIR}/manifest.json" ]; then + BUNDLE_MANIFEST_DIGEST="$(python3 - <&2 + exit 1 + fi +fi + +# Copy lockfiles into provenance package for consumer verification without repo checkout. +LOCK_OUT="${OUT_DIR}/lockfiles" +mkdir -p "${LOCK_OUT}/python" "${LOCK_OUT}/rust" "${LOCK_OUT}/typescript" "${LOCK_OUT}/pins" +cp -f "${ROOT}/python/requirements.lock" "${LOCK_OUT}/python/requirements.lock" +cp -f "${ROOT}/rust/Cargo.lock" "${LOCK_OUT}/rust/Cargo.lock" +cp -f "${ROOT}/typescript/package-lock.json" "${LOCK_OUT}/typescript/package-lock.json" +cp -f "${ROOT}/pins/python-base-image.json" "${LOCK_OUT}/pins/python-base-image.json" + +# Stage wheels + SBOM beside binding for the consumer job. +STAGE_WHEELS="${OUT_DIR}/wheels" +STAGE_SBOM="${OUT_DIR}/sbom" +mkdir -p "${STAGE_WHEELS}" "${STAGE_SBOM}" +cp -f "${WHEEL_FILES[@]}" "${STAGE_WHEELS}/" +cp -f "${SBOM_PATH}" "${STAGE_SBOM}/pcs-core.cdx.json" +if [ -f "${SBOM_DIR}/pcs-core.spdx.json" ]; then + cp -f "${SBOM_DIR}/pcs-core.spdx.json" "${STAGE_SBOM}/pcs-core.spdx.json" +fi + +export ROOT OUT_DIR VERSION SOURCE_COMMIT SOURCE_REF +export BUNDLE_STATUS BUNDLE_PATH BUNDLE_ARCHIVE BUNDLE_ARCHIVE_SHA +export BUNDLE_MANIFEST_DIGEST BUNDLE_ABSENCE STAGE_WHEELS STAGE_SBOM LOCK_OUT + +python3 - <<'PY' +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +root = Path(os.environ["ROOT"]) +out = Path(os.environ["OUT_DIR"]) +version = os.environ["VERSION"] +source_commit = os.environ["SOURCE_COMMIT"].lower() +if len(source_commit) != 40 or any(c not in "0123456789abcdef" for c in source_commit): + raise SystemExit(f"invalid source_commit: {source_commit!r}") + +def sha256_hex(path: Path) -> str: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + +def sha256_bare(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + +lock_specs = { + "python/requirements.lock": root / "python" / "requirements.lock", + "rust/Cargo.lock": root / "rust" / "Cargo.lock", + "typescript/package-lock.json": root / "typescript" / "package-lock.json", +} +lockfiles = { + key: {"path": key, "sha256": sha256_hex(path)} + for key, path in lock_specs.items() +} + +pin_path = root / "pins" / "python-base-image.json" +pin = json.loads(pin_path.read_text(encoding="utf-8")) +index_digest = pin["index_digest"] +if not str(index_digest).startswith("sha256:"): + raise SystemExit("pins/python-base-image.json index_digest must be sha256:...") + +wheels_dir = Path(os.environ["STAGE_WHEELS"]) +wheels = [] +for wheel in sorted(wheels_dir.glob("pcs_core-*.whl")): + wheels.append( + { + "path": f"wheels/{wheel.name}", + "filename": wheel.name, + "sha256": sha256_hex(wheel), + } + ) +if not wheels: + raise SystemExit("no staged wheels") + +sbom_path = Path(os.environ["STAGE_SBOM"]) / "pcs-core.cdx.json" +sbom_text = sbom_path.read_text(encoding="utf-8") +sbom_format = "CycloneDX-JSON" +if '"scaffold"' in sbom_text or "Scaffold SBOM" in sbom_text: + sbom_format = "scaffold-CycloneDX-JSON" + +repo = os.environ.get("GITHUB_REPOSITORY", "local/pcs-core") +server = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/") +run_id = os.environ.get("GITHUB_RUN_ID", "local") +run_attempt = os.environ.get("GITHUB_RUN_ATTEMPT", "1") +workflow_ref = os.environ.get( + "GITHUB_WORKFLOW_REF", + f"{repo}/.github/workflows/release-provenance.yml@{os.environ.get('SOURCE_REF', 'local')}", +) +workflow_sha = os.environ.get("GITHUB_WORKFLOW_SHA", source_commit) +if len(workflow_sha) != 40: + workflow_sha = source_commit +event_name = os.environ.get("GITHUB_EVENT_NAME", "local") +runner_name = os.environ.get("RUNNER_NAME", "local") +runner_os = os.environ.get("RUNNER_OS", os.name) +runner_arch = os.environ.get("RUNNER_ARCH", "unknown") +builder_id = f"{server}/{repo}/actions/runs/{run_id}" + +bundle: dict = {"status": os.environ["BUNDLE_STATUS"]} +if bundle["status"] == "present": + archive = Path(os.environ["BUNDLE_ARCHIVE"]) + bundle.update( + { + "path": "pf-core-release-bundle/", + "archive_path": archive.name, + "archive_sha256": "sha256:" + os.environ["BUNDLE_ARCHIVE_SHA"], + "manifest_digest": os.environ["BUNDLE_MANIFEST_DIGEST"], + } + ) +else: + bundle["absence_reason"] = os.environ.get("BUNDLE_ABSENCE") or "absent" + +binding = { + "schema_version": "v0", + "artifact_type": "ReleaseProvenanceBinding.v0", + "canonicalization_version": "v1", + "version": version, + "source_commit": source_commit, + "source_ref": os.environ.get("SOURCE_REF", "local"), + "workflow": { + "repository": repo, + "workflow_ref": workflow_ref, + "workflow_sha": workflow_sha.lower(), + "run_id": str(run_id), + "run_attempt": str(run_attempt), + "event_name": event_name, + "server_url": server, + }, + "builder": { + "id": builder_id, + "runner_name": runner_name, + "runner_os": runner_os, + "runner_arch": runner_arch, + }, + "lockfiles": lockfiles, + "verifier_image": { + "pin_path": "pins/python-base-image.json", + "index_digest": index_digest, + "dockerfile_from": pin["dockerfile_from"], + "pin_file_sha256": sha256_hex(pin_path), + **( + {"amd64_digest": pin["amd64_digest"]} + if pin.get("amd64_digest") + else {} + ), + }, + "wheels": wheels, + "sbom": { + "path": "sbom/pcs-core.cdx.json", + "sha256": sha256_hex(sbom_path), + "format": sbom_format, + }, + "bundle": bundle, + "attestation": { + "status": "pending", + "predicate_type": "https://slsa.dev/provenance/v1", + "method": "none", + "attestation_ids": [], + "attestation_urls": [], + }, + "subjects_checksums_path": "subjects.sha256", +} + +# Seal without signature_or_digest, then attach digest of sealed body. +sealed = {k: v for k, v in binding.items() if k != "signature_or_digest"} +canonical = json.dumps(sealed, sort_keys=True, separators=(",", ":"), ensure_ascii=False) +binding["signature_or_digest"] = "sha256:" + hashlib.sha256( + canonical.encode("utf-8") +).hexdigest() + +binding_path = out / "ReleaseProvenanceBinding.v0.json" +binding_path.write_text(json.dumps(binding, indent=2) + "\n", encoding="utf-8") + +# Immutable subjects attested BEFORE the binding is finalized (binding digest +# changes when attestation.status flips pending → signed|gated). +immutable: list[str] = [] +for wheel in sorted(wheels_dir.glob("pcs_core-*.whl")): + immutable.append(f"{sha256_bare(wheel)} wheels/{wheel.name}") +immutable.append(f"{sha256_bare(sbom_path)} sbom/pcs-core.cdx.json") +if bundle["status"] == "present": + archive = Path(os.environ["BUNDLE_ARCHIVE"]) + immutable.append(f"{sha256_bare(archive)} {archive.name}") +for rel, path in lock_specs.items(): + staged = out / "lockfiles" / Path(rel) + immutable.append(f"{sha256_bare(staged)} lockfiles/{rel}") +immutable.append( + f"{sha256_bare(out / 'lockfiles' / 'pins' / 'python-base-image.json')} " + "lockfiles/pins/python-base-image.json" +) + +attest_subjects = out / "subjects-attest.sha256" +attest_subjects.write_text("\n".join(immutable) + "\n", encoding="utf-8") + +# Full consumer subject list includes the (still-pending) binding; finalize +# script refreshes the binding line after status is sealed. +subjects_path = out / "subjects.sha256" +subjects_path.write_text( + f"{sha256_bare(binding_path)} ReleaseProvenanceBinding.v0.json\n" + + "\n".join(immutable) + + "\n", + encoding="utf-8", +) + +status_path = out / "attestation-status.json" +status_path.write_text( + json.dumps( + { + "status": "pending", + "require_signed": os.environ.get("PCS_PROVENANCE_REQUIRE_SIGNED", "0") == "1", + "binding_path": "ReleaseProvenanceBinding.v0.json", + "subjects_path": "subjects.sha256", + "attest_subjects_path": "subjects-attest.sha256", + }, + indent=2, + ) + + "\n", + encoding="utf-8", +) + +print(f"OK wrote {binding_path}") +print(f"OK wrote {attest_subjects} ({len(immutable)} immutable subjects)") +print(f"OK wrote {subjects_path}") +print(f"bundle.status={bundle['status']}") +PY + +echo "OK release provenance subjects under ${OUT_DIR}" diff --git a/scripts/finalize-provenance-attestation.sh b/scripts/finalize-provenance-attestation.sh new file mode 100644 index 0000000..1b0bb0f --- /dev/null +++ b/scripts/finalize-provenance-attestation.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Finalize ReleaseProvenanceBinding attestation status after GitHub attest steps. +# Usage: +# scripts/finalize-provenance-attestation.sh signed|gated [reason] [id1,id2] [url1,url2] +set -euo pipefail + +PKG_DIR="${1:?provenance package dir}" +STATUS="${2:?signed|gated}" +REASON="${3:-}" +IDS_CSV="${4:-}" +URLS_CSV="${5:-}" + +BINDING="${PKG_DIR}/ReleaseProvenanceBinding.v0.json" +test -f "${BINDING}" + +export PKG_DIR BINDING STATUS REASON IDS_CSV URLS_CSV + +python3 - <<'PY' +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +pkg = Path(os.environ["PKG_DIR"]) +path = Path(os.environ["BINDING"]) +status = os.environ["STATUS"] +if status not in {"signed", "gated"}: + raise SystemExit(f"status must be signed|gated, got {status!r}") + +binding = json.loads(path.read_text(encoding="utf-8")) +ids = [x for x in os.environ.get("IDS_CSV", "").split(",") if x] +urls = [x for x in os.environ.get("URLS_CSV", "").split(",") if x] +reason = os.environ.get("REASON") or None + +att = { + "status": status, + "predicate_type": "https://slsa.dev/provenance/v1", + "method": "actions/attest-build-provenance" if status == "signed" else "none", + "attestation_ids": ids, + "attestation_urls": urls, +} +if status == "gated": + att["gate_reason"] = reason or ( + "GitHub artifact attestations unavailable " + "(permissions, private-repo plan, or OIDC). Digests remain binding; " + "do not claim signed SLSA provenance." + ) +binding["attestation"] = att + +sealed = {k: v for k, v in binding.items() if k != "signature_or_digest"} +canonical = json.dumps(sealed, sort_keys=True, separators=(",", ":"), ensure_ascii=False) +binding["signature_or_digest"] = "sha256:" + hashlib.sha256( + canonical.encode("utf-8") +).hexdigest() +path.write_text(json.dumps(binding, indent=2) + "\n", encoding="utf-8") + +# Refresh subjects.sha256 line for the binding file itself. +subjects = pkg / "subjects.sha256" +bare = hashlib.sha256(path.read_bytes()).hexdigest() +lines = [] +replaced = False +for line in subjects.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + digest, rel = line.split(None, 1) + if rel.strip() in {"ReleaseProvenanceBinding.v0.json", "./ReleaseProvenanceBinding.v0.json"}: + lines.append(f"{bare} ReleaseProvenanceBinding.v0.json") + replaced = True + else: + lines.append(line) +if not replaced: + lines.insert(0, f"{bare} ReleaseProvenanceBinding.v0.json") +subjects.write_text("\n".join(lines) + "\n", encoding="utf-8") + +status_path = pkg / "attestation-status.json" +require_signed = False +if status_path.is_file(): + prev = json.loads(status_path.read_text(encoding="utf-8")) + require_signed = bool(prev.get("require_signed")) +status_path.write_text( + json.dumps( + { + "status": status, + "require_signed": require_signed, + "gate_reason": att.get("gate_reason"), + "attestation_ids": ids, + "attestation_urls": urls, + }, + indent=2, + ) + + "\n", + encoding="utf-8", +) + +if status == "gated": + notice = { + "artifact_type": "ProvenanceAttestationGated.v0", + "status": "gated", + "reason": att["gate_reason"], + "binding_digest": binding["signature_or_digest"], + "honesty": ( + "Digest-bound ReleaseProvenanceBinding.v0 is present. " + "Signed in-toto/SLSA attestation was not produced. " + "Do not advertise this release as SLSA-attested until status=signed." + ), + } + (pkg / "PROVENANCE_ATTESTATION_GATED.json").write_text( + json.dumps(notice, indent=2) + "\n", encoding="utf-8" + ) + +print(f"OK finalized attestation.status={status}") +print(f"binding digest {binding['signature_or_digest']}") +PY diff --git a/scripts/verify-release-provenance.sh b/scripts/verify-release-provenance.sh new file mode 100644 index 0000000..dfa47ed --- /dev/null +++ b/scripts/verify-release-provenance.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# Consumer-side verification of release provenance without the producer working tree. +# Expects a provenance package directory (artifact download) containing: +# ReleaseProvenanceBinding.v0.json, subjects.sha256, wheels/, sbom/, lockfiles/, … +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PKG_DIR="${1:-}" +if [ -z "${PKG_DIR}" ]; then + echo "usage: $0 " >&2 + exit 2 +fi +PKG_DIR="$(cd "${PKG_DIR}" && pwd)" + +BINDING="${PKG_DIR}/ReleaseProvenanceBinding.v0.json" +SUBJECTS="${PKG_DIR}/subjects.sha256" +STATUS_FILE="${PKG_DIR}/attestation-status.json" +test -f "${BINDING}" +test -f "${SUBJECTS}" + +export PKG_DIR BINDING SUBJECTS STATUS_FILE ROOT + +python3 - <<'PY' +from __future__ import annotations + +import hashlib +import json +import os +import sys +from pathlib import Path + +pkg = Path(os.environ["PKG_DIR"]) +binding_path = Path(os.environ["BINDING"]) +subjects_path = Path(os.environ["SUBJECTS"]) +root = Path(os.environ["ROOT"]) + +binding = json.loads(binding_path.read_text(encoding="utf-8")) +if binding.get("artifact_type") != "ReleaseProvenanceBinding.v0": + raise SystemExit(f"unexpected artifact_type: {binding.get('artifact_type')!r}") + +# Schema validate when pcs_core is importable (producer CI / checkout). Soft if absent. +try: + sys.path.insert(0, str(root / "python")) + from pcs_core.validate import validate_artifact + + validate_artifact(binding, "ReleaseProvenanceBinding.v0", release_grade=True) + print("OK schema ReleaseProvenanceBinding.v0") +except Exception as exc: # noqa: BLE001 - consumer may lack package + # Fail closed when schema validation is available and rejects. + if "ValidationError" in type(exc).__name__ or "schema" in str(exc).lower(): + raise + print(f"WARN schema validation skipped ({exc})") + +def sha256_hex(path: Path) -> str: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + +def sha256_bare(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + +# Recompute binding digest (exclude signature_or_digest). +sealed = {k: v for k, v in binding.items() if k != "signature_or_digest"} +canonical = json.dumps(sealed, sort_keys=True, separators=(",", ":"), ensure_ascii=False) +expected = "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() +recorded = binding.get("signature_or_digest") +if recorded != expected: + raise SystemExit(f"binding digest mismatch: {recorded!r} != {expected!r}") +print("OK binding signature_or_digest") + +# Verify every subjects.sha256 line against on-disk files. +missing = [] +mismatched = [] +for line in subjects_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split(None, 1) + if len(parts) != 2: + raise SystemExit(f"bad subjects line: {line!r}") + digest, rel = parts + rel = rel.lstrip("./") + path = pkg / rel + if not path.is_file(): + missing.append(rel) + continue + actual = sha256_bare(path) + if actual != digest: + mismatched.append(f"{rel}: {actual} != {digest}") + +if missing: + raise SystemExit("missing subject files:\n - " + "\n - ".join(missing)) +if mismatched: + raise SystemExit("subject digest mismatches:\n - " + "\n - ".join(mismatched)) +print(f"OK subjects.sha256 ({sum(1 for _ in subjects_path.read_text().splitlines() if _.strip())} entries)") + +# Cross-check structured binding fields against files. +for wheel in binding["wheels"]: + path = pkg / wheel["path"] + if sha256_hex(path) != wheel["sha256"]: + raise SystemExit(f"wheel digest drift: {wheel['path']}") +print(f"OK {len(binding['wheels'])} wheel digest(s)") + +sbom = pkg / binding["sbom"]["path"] +if sha256_hex(sbom) != binding["sbom"]["sha256"]: + raise SystemExit("SBOM digest drift") +print("OK SBOM digest") + +for key, meta in binding["lockfiles"].items(): + path = pkg / "lockfiles" / key + if sha256_hex(path) != meta["sha256"]: + raise SystemExit(f"lockfile digest drift: {key}") +print("OK lockfile digests") + +pin = pkg / "lockfiles" / "pins" / "python-base-image.json" +pin_data = json.loads(pin.read_text(encoding="utf-8")) +if pin_data["index_digest"] != binding["verifier_image"]["index_digest"]: + raise SystemExit("verifier image index_digest drift vs pin file") +if binding["verifier_image"].get("pin_file_sha256") and sha256_hex(pin) != binding[ + "verifier_image" +]["pin_file_sha256"]: + raise SystemExit("verifier pin file digest drift") +print(f"OK verifier image digest {binding['verifier_image']['index_digest']}") + +bundle = binding["bundle"] +if bundle["status"] == "present": + archive = pkg / bundle["archive_path"] + if not archive.is_file(): + raise SystemExit(f"missing bundle archive {bundle['archive_path']}") + if sha256_hex(archive) != bundle["archive_sha256"]: + raise SystemExit("bundle archive digest drift") + if not str(bundle.get("manifest_digest", "")).startswith("sha256:"): + raise SystemExit("bundle manifest_digest missing") + print(f"OK bundle root archive {bundle['archive_sha256']}") + print(f"OK bundle manifest_digest {bundle['manifest_digest']}") +else: + print(f"OK bundle absent ({bundle.get('absence_reason', 'n/a')})") + +att = binding.get("attestation") or {} +status = att.get("status", "pending") +status_file = Path(os.environ["STATUS_FILE"]) +file_status = None +require_signed = os.environ.get("PCS_PROVENANCE_REQUIRE_SIGNED", "0") == "1" +if status_file.is_file(): + st = json.loads(status_file.read_text(encoding="utf-8")) + file_status = st.get("status") + require_signed = require_signed or bool(st.get("require_signed")) + +if status == "pending": + raise SystemExit( + "FAIL: attestation.status is still pending — producer did not finalize signed/gated" + ) + +if status == "signed": + if file_status and file_status != "signed": + raise SystemExit( + f"FAIL: binding claims signed but attestation-status.json is {file_status!r}" + ) + print("OK attestation.status=signed (Sigstore / GitHub artifact attestation)") +elif status == "gated": + reason = att.get("gate_reason") or "unspecified" + print(f"WARN attestation gated: {reason}") + if require_signed: + raise SystemExit( + "FAIL: signed provenance required but attestation.status=gated " + "(org may lack artifact attestation permissions / GHEC for private repos)" + ) + gated_notice = pkg / "PROVENANCE_ATTESTATION_GATED.json" + if not gated_notice.is_file(): + raise SystemExit("FAIL: gated status without PROVENANCE_ATTESTATION_GATED.json") + print("OK gated notice present (fail-closed honesty)") +else: + raise SystemExit(f"unknown attestation.status: {status!r}") + +# Identity bindings required by PR15. +for field in ("source_commit",): + if not binding.get(field): + raise SystemExit(f"missing {field}") +wf = binding["workflow"] +for field in ("repository", "workflow_ref", "workflow_sha", "run_id"): + if not wf.get(field): + raise SystemExit(f"missing workflow.{field}") +builder = binding["builder"] +for field in ("id", "runner_name", "runner_os"): + if not builder.get(field): + raise SystemExit(f"missing builder.{field}") +print( + f"OK identity bindings commit={binding['source_commit'][:12]}… " + f"workflow={wf['workflow_ref']} builder={builder['id']}" +) + +result = { + "ok": True, + "attestation_status": status, + "source_commit": binding["source_commit"], + "binding_digest": binding["signature_or_digest"], + "sbom_digest": binding["sbom"]["sha256"], + "wheel_digests": [w["sha256"] for w in binding["wheels"]], + "bundle_status": bundle["status"], + "verifier_image_digest": binding["verifier_image"]["index_digest"], +} +(pkg / "consumer-verification-result.json").write_text( + json.dumps(result, indent=2) + "\n", encoding="utf-8" +) +print("OK consumer digest verification") +print(json.dumps(result, indent=2)) +PY + +ATT_STATUS="$(python3 -c "import json; print(json.load(open(r'${BINDING}', encoding='utf-8'))['attestation']['status'])")" +REPO="$(python3 -c "import json; print(json.load(open(r'${BINDING}', encoding='utf-8'))['workflow']['repository'])")" + +if [ "${ATT_STATUS}" = "signed" ]; then + if ! command -v gh >/dev/null 2>&1; then + echo "FAIL: gh CLI required to verify signed GitHub artifact attestations" >&2 + exit 1 + fi + echo "== gh attestation verify (clean consumer) ==" + # Verify primary subjects against the GitHub attestations API. + gh attestation verify "${BINDING}" --repo "${REPO}" + # Wheels + shopt -s nullglob + for wheel in "${PKG_DIR}"/wheels/pcs_core-*.whl; do + gh attestation verify "${wheel}" --repo "${REPO}" + done + # SBOM + if [ -f "${PKG_DIR}/sbom/pcs-core.cdx.json" ]; then + gh attestation verify "${PKG_DIR}/sbom/pcs-core.cdx.json" --repo "${REPO}" + fi + # Bundle archive when present + ARCHIVE="$(python3 -c "import json; b=json.load(open(r'${BINDING}', encoding='utf-8')); print(b['bundle'].get('archive_path') or '')")" + if [ -n "${ARCHIVE}" ] && [ -f "${PKG_DIR}/${ARCHIVE}" ]; then + gh attestation verify "${PKG_DIR}/${ARCHIVE}" --repo "${REPO}" + fi + echo "OK gh attestation verify" +else + echo "SKIP gh attestation verify (status=${ATT_STATUS})" +fi + +echo "OK release provenance consumer verification" From 16158947f3f1dac9bf0244240cf91a36e96041c4 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:02:27 -0700 Subject: [PATCH 18/24] Split CI and release workflows into focused job matrices. Isolate PF-Core, distribution, and gate jobs so failures surface the owning surface instead of a monolithic workflow collapse. --- .github/workflows/ci.yml | 505 ++++++++++++++++++--- .github/workflows/pf-core-release-gate.yml | 118 ++++- .github/workflows/release.yml | 478 +++++++++++++++++-- 3 files changed, 975 insertions(+), 126 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc08c3b..46a6b3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,16 @@ name: CI +# Mandatory blocking CI matrix (PCS/PF-Core completion plan). +# Job inventory is mirrored in docs/pf-core/release-checklist.md. + on: push: branches: [main, master] pull_request: jobs: - python: + python-tests: + name: Python full tests runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -18,11 +22,7 @@ jobs: cd python pip install -c requirements.lock -e ".[dev]" pcs capabilities - pytest -q tests/test_pf_core_tier1.py tests/test_pf_core_cross_language.py - pytest -q tests/test_pf_core_stage1.py tests/test_pf_core_stage2.py tests/test_pf_core_stage3.py - pytest -q tests/test_pf_core_phase_f.py pytest -q - pytest -q tests/test_protocol_conformance.py pcs schema check pcs pf-core audit-claims pcs pf-core audit-boundary @@ -74,31 +74,16 @@ jobs: for f in ../examples/benchmark_ingest/*.pcs_bench_ingest.valid.json; do pcs validate "$f"; done pcs validate ../examples/tool_use_trace.valid.json pcs validate ../examples/tool_use_certificate.valid.json - pytest -q tests/test_multidomain_workflows.py ruff check pcs_core tests ruff format --check pcs_core tests - - name: Phase 7 quality — hypothesis + pyright + pin check - run: | - cd python - pip install -c requirements.lock -e ".[dev,quality]" - python ../scripts/verify-certifyedge-pin.py --mode preview - pyright pcs_core/external_attestation.py pcs_core/safe_paths.py pcs_core/pf_core_certifyedge.py - pytest -q tests/test_external_attestation.py tests/test_property_based.py - coverage run -m pytest -q tests/test_external_attestation.py tests/test_safe_paths.py - coverage report --include='pcs_core/external_attestation.py,pcs_core/safe_paths.py' --fail-under=70 - name: PF-Core fixture validation run: | cd python - pip install -c requirements.lock -e . pcs validate ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json pcs pf-core validate-trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json - # Dev CI policy: live CertifyEdge when available; mock fallback for format validation. - # Release gate (pf-core-release-gate.yml) requires live CLI and rejects mock:// / stub://. - # See docs/pf-core/certifyedge-ci.md - name: PF-Core CertifyEdge check (live or mock) run: | cd python - pip install -c requirements.lock -e . if command -v certifyedge >/dev/null 2>&1; then echo "CertifyEdge CLI found: $(command -v certifyedge)" certifyedge --version || true @@ -119,10 +104,6 @@ jobs: --property qc_release.temporal.safety \ --out /tmp/PFCoreCertificate.certifyedge.json fi - - name: PF-Core Tier 1 tests - run: | - cd python - pytest -q tests/test_pf_core_tier1.py tests/test_pf_core_cross_language.py - name: Schema drift check (reference) run: bash scripts/pcs-schema-diff.sh schemas - name: PF-Core catalog drift check @@ -133,7 +114,6 @@ jobs: ../lean/PFCore/Catalog.lean \ ../rust/crates/pcs-core/src/pf_core_catalog.rs \ ../typescript/packages/core/src/pfCoreCatalog.ts - # Fail if hand-maintained Lean effect/role catalogs reappear outside Catalog.lean. if grep -n '("cap:file-read", Effect.read)' ../lean/PFCore/Action.lean; then echo "hand-maintained knownCapabilityEffectCatalog entries found in Action.lean" >&2 exit 1 @@ -146,32 +126,113 @@ jobs: echo "manual EFFECT_KIND_TO_LEAN table found in pf_core_lean_codegen.py" >&2 exit 1 fi - - name: PF-Core adapter parity (provability-fabric-core pin) - run: bash scripts/run-pf-core-adapter-ci.sh - - name: LabTrust release fixtures + + python-typecheck: + name: Python full-package typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Pyright (full package) run: | cd python - pcs validate-release-chain ../examples/labtrust-release/ + pip install -c requirements.lock -e ".[dev,quality]" + python ../scripts/verify-certifyedge-pin.py --mode preview + pyright pcs_core - lean: + python-coverage: + name: Python branch coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" + - name: Branch coverage (fail-under from pyproject) + run: | + cd python + pip install -c requirements.lock -e ".[dev,quality]" + # tool.coverage.run.branch=true — full suite with branch data; fail-under on trust-critical modules. + coverage run -m pytest -q + coverage report --fail-under=0 + coverage report \ + --include='pcs_core/external_attestation.py,pcs_core/pf_core_bundle.py,pcs_core/pf_core_certifyedge.py,pcs_core/safe_paths.py,pcs_core/hash.py,pcs_core/artifact_integrity.py,pcs_core/certifyedge_pin.py' \ + --fail-under=70 + + rust: + name: Rust fmt/clippy/tests/fuzz-smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86 + with: + components: rustfmt, clippy + - name: Format + Clippy + tests + run: | + cd rust + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked + cargo test --locked hash_vectors + - name: Fuzz smoke (proptest property targets) + run: | + cd rust + # Full cargo-fuzz / libfuzzer is scaffolded (rust/FUZZING.md); CI smoke is proptest. + cargo test --locked proptest_digest_hex_shape + + typescript: + name: TypeScript lint/tests/property-vectors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + - name: Lint + tests + property / hash vectors + run: | + cd typescript + npm ci + npm run lint + npm test + npm run test:hash-vectors -w @pcs/core + + lean-pcs: + name: Lean PCS build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install elan (checksum verified) run: bash scripts/install-elan-verified.sh - - name: Build Lean libraries and PF-Core lean-check + - name: lake build PCS run: | export PATH="$HOME/.elan/bin:$PATH" cd lean lake build PCS + + lean-pf-core: + name: Lean PF-Core build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: lake build PFCore + lean-check + proof binding + run: | + export PATH="$HOME/.elan/bin:$PATH" + cd lean lake build PFCore cd ../python pip install -c requirements.lock -e . pcs capabilities - pcs pf-core lean-check --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json --out /tmp/pfcore-ci-cert.json + pcs pf-core lean-check \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --out /tmp/pfcore-ci-cert.json \ + --result-out /tmp/pfcore-ci-lean-check.json pcs pf-core verify-proof-binding \ --certificate /tmp/pfcore-ci-cert.json \ --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json @@ -179,51 +240,360 @@ jobs: ../examples/pf-core-valid/contract_checked/trace.json \ --contracts-dir ../examples/pf-core-valid/contract_checked - pf-core-adapter: + certificate-mode-e2e: + name: Certificate-mode end-to-end runs-on: ubuntu-latest - continue-on-error: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - - name: PF-Core provability-fabric-core adapter parity - run: bash scripts/run-pf-core-adapter-ci.sh + - name: Certificate mode + mode-evidence suites + run: | + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_pf_core_certificate_mode_status.py \ + tests/test_pf_core_certificate_mode_codegen.py \ + tests/test_pf_core_certificate_mode_resolution_vectors.py \ + tests/test_pf_core_handoff_evidence.py \ + tests/test_pf_core_contract_evidence.py \ + tests/test_pf_core_effect_frame_evidence.py \ + tests/test_pf_core_transition_evidence.py \ + tests/test_pf_core_compositional.py - rust: + cross-language-differential: + name: Cross-language differential runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" - uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - components: rustfmt, clippy - - name: Test Rust + node-version: "22" + - name: Python/Rust/TS differential + shared vectors run: | - cd rust - cargo fmt --check - rustup component add clippy - cargo clippy --locked --all-targets -- -D warnings - cargo test --locked + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_pf_core_cross_language.py \ + tests/test_pf_core_phase4_differential.py \ + tests/test_pf_core_hash_vector_parity.py \ + tests/test_canonical_hash_release.py \ + tests/test_shared_hash_vectors.py + pcs shared-hash-vectors verify + pcs conformance run --suite pf-core-cross-language + cd ../rust + cargo test --locked pf_core -- --nocapture cargo test --locked hash_vectors + cd ../typescript + npm ci + npm test + npm run test:hash-vectors -w @pcs/core - typescript: + semantic-projection-replay: + name: Semantic-projection replay runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - node-version: "22" - - name: Test TypeScript + python-version: "3.12" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: PCS + PF-Core projection replay run: | - cd typescript - npm ci - npm test - npm run test:hash-vectors -w @pcs/core - npm run lint + export PATH="$HOME/.elan/bin:$PATH" + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_pcs_projection_binding.py \ + tests/test_phase3_envelope_binding.py \ + tests/test_pf_core_phase4_tcb.py \ + tests/test_pf_core_bundle.py + + theorem-manifest-replay: + name: Theorem-manifest replay + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: Theorem manifest binding + replay + run: | + export PATH="$HOME/.elan/bin:$PATH" + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q tests/test_pf_core_theorem_manifest_binding.py + + scientific-payload-mutation: + name: Scientific payload mutation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: ResultArtifact payload byte verification + mutations + run: | + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_result_artifact_payload.py \ + tests/test_computation_validate.py \ + tests/test_computation_release_chain.py + + signature-key-revocation: + name: Signature and key-revocation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: ArtifactIntegrity Ed25519 + revocation + run: | + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_artifact_integrity.py \ + tests/test_certifyedge_pin.py \ + tests/test_external_attestation.py \ + tests/test_release_gates.py + + preview-release: + name: Preview release workflow + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: Verify CertifyEdge pin (preview) + run: python3 scripts/verify-certifyedge-pin.py --mode preview + - name: Preview lean-check → bundle → validate → absence + run: | + set -euo pipefail + export PATH="$HOME/.elan/bin:$PATH" + export PCS_RELEASE_MODE=preview + cd python + pip install -c requirements.lock -e ".[dev,quality]" + pcs pf-core lean-check \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --out /tmp/pfcore-preview-cert.json \ + --result-out /tmp/pfcore-preview-lean-check.json + rm -rf /tmp/pfcore-preview-bundle + mkdir -p /tmp/pfcore-preview-bundle + pcs pf-core bundle-release \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --cert /tmp/pfcore-preview-cert.json \ + --lean-check-result /tmp/pfcore-preview-lean-check.json \ + --out /tmp/pfcore-preview-bundle + pcs pf-core validate-bundle /tmp/pfcore-preview-bundle + python3 - <<'PY' + import json + from pathlib import Path + root = Path("/tmp/pfcore-preview-bundle") + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + lean_rel = manifest.get("lean_check_result_path") + if not lean_rel: + raise SystemExit("FAIL: preview bundle missing lean_check_result_path") + if not (root / lean_rel).is_file(): + raise SystemExit(f"FAIL: lean-check result missing at {lean_rel}") + print(f"OK preview lean-check-result in bundle: {lean_rel}") + PY + pcs pf-core attest-bundle \ + --bundle /tmp/pfcore-preview-bundle \ + --property qc_release.temporal.safety \ + --allow-absence || true + pcs pf-core validate-external-attestation \ + --bundle /tmp/pfcore-preview-bundle \ + --allow-absence + pcs pf-core validate-bundle /tmp/pfcore-preview-bundle + mkdir -p ../dist/pf-core-preview-bundle + cp -a /tmp/pfcore-preview-bundle/. ../dist/pf-core-preview-bundle/ + - name: Upload preview release bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pcs-core-ci-preview-bundle + path: dist/pf-core-preview-bundle/ + retention-days: 7 + + stable-release-dry-run: + name: Stable release dry-run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: In-repo stable dry-run (live checker gated on pin/secrets) + env: + PF_CORE_CERTIFYEDGE_CLI_SECRET: ${{ secrets.PF_CORE_CERTIFYEDGE_CLI }} + run: | + set -euo pipefail + export PATH="$HOME/.elan/bin:$PATH" + cd python + pip install -c requirements.lock -e ".[dev,quality]" + + # Always-blocking in-repo portion: lean-check → bundle → validate. + pcs pf-core lean-check \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --out /tmp/pfcore-stable-cert.json \ + --result-out /tmp/pfcore-stable-lean-check.json + rm -rf /tmp/pfcore-stable-bundle + pcs pf-core bundle-release \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --cert /tmp/pfcore-stable-cert.json \ + --lean-check-result /tmp/pfcore-stable-lean-check.json \ + --out /tmp/pfcore-stable-bundle + pcs pf-core validate-bundle /tmp/pfcore-stable-bundle + + # Reject mock as release attestation (controlled negative). + export PF_CORE_CERTIFYEDGE_MODE=mock + if pcs pf-core certifyedge-check \ + --trace ../examples/pf-core-valid/certifyedge_mock/trace.json \ + --property qc_release.temporal.safety \ + --out /tmp/PFCoreCertificate.certifyedge.mock.json; then + attestation="$(python3 -c 'import json; c=json.load(open("/tmp/PFCoreCertificate.certifyedge.mock.json")); print(next((str(i.get("proof_ref") or "") for i in c.get("obligations") or [] if isinstance(i, dict)), ""))')" + if echo "${attestation}" | grep -q '^mock://'; then + echo "OK mock path remains available for dev but is not accepted as release attestation" + fi + fi + unset PF_CORE_CERTIFYEDGE_MODE + + # Live controlled checker: only when pin verifies in release mode and CLI resolves. + LIVE_READY=0 + if python3 ../scripts/verify-certifyedge-pin.py --mode release; then + set +e + bash ../scripts/provision-certifyedge.sh + prov_status=$? + set -e + if [ -f ../.tools/certifyedge/provision.env ]; then + set -a + # shellcheck source=/dev/null + . ../.tools/certifyedge/provision.env + set +a + fi + if [ -n "${PF_CORE_CERTIFYEDGE_CLI_SECRET:-}" ] && [ -f "${PF_CORE_CERTIFYEDGE_CLI_SECRET}" ]; then + export PF_CORE_CERTIFYEDGE_CLI="${PF_CORE_CERTIFYEDGE_CLI_SECRET}" + fi + if [ -n "${PF_CORE_CERTIFYEDGE_CLI:-}" ] && [ -f "${PF_CORE_CERTIFYEDGE_CLI}" ]; then + LIVE_READY=1 + elif [ "${prov_status}" -eq 0 ] && [ -n "${PF_CORE_CERTIFYEDGE_CLI:-}" ] && [ -f "${PF_CORE_CERTIFYEDGE_CLI}" ]; then + LIVE_READY=1 + fi + else + echo "::notice::Stable live CertifyEdge dry-run gated: pin not release-ready (org secrets / pinned artifact)." + fi + + if [ "${LIVE_READY}" = "1" ]; then + export PF_CORE_CERTIFYEDGE_MODE=live + export PF_CORE_CERTIFYEDGE_REQUIRE_LIVE=1 + pcs pf-core attest-bundle \ + --bundle /tmp/pfcore-stable-bundle \ + --property qc_release.temporal.safety \ + --require-live + pcs pf-core validate-external-attestation \ + --bundle /tmp/pfcore-stable-bundle \ + --require-live + echo "OK stable dry-run with controlled live checker" + else + echo "::notice::Skipping live attest-bundle; in-repo dry-run (bundle+mock-reject) passed." + echo "Enable via pinned pins/certifyedge.json + secrets.PF_CORE_CERTIFYEDGE_CLI (see docs/pf-core/certifyedge-ci.md)." + fi + + # Report org/infra gates without failing PR CI (release.yml is fail-closed). + if pcs release check-gates --mode release; then + echo "OK stable org/infra release gates closed" + else + echo "::notice::Stable org/infra gates still open (CertifyEdge pin / TrustedKeyRegistry / provenance). See docs/pf-core/operator-release-gates.md. release.yml fails closed." + fi + + provenance-verification: + name: Provenance verification + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + actions: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Build provenance binding (digest subjects) + run: | + set -euo pipefail + cd python + pip install -c requirements.lock -e "." + pip install build + cd .. + export PCS_PROVENANCE_REQUIRE_SIGNED=0 + export PCS_PROVENANCE_BUILD_SBOM=1 + bash scripts/build-release-provenance.sh dist/provenance + test -f dist/provenance/ReleaseProvenanceBinding.v0.json + test -f dist/provenance/subjects-attest.sha256 + python3 - <<'PY' + import json + from pathlib import Path + from pcs_core.validate import validate_artifact + binding = json.loads(Path("dist/provenance/ReleaseProvenanceBinding.v0.json").read_text(encoding="utf-8")) + validate_artifact(binding, "ReleaseProvenanceBinding.v0", release_grade=False) + print("OK provenance binding schema") + PY + - name: Attest build provenance (best-effort on PR CI) + id: attest_prov + continue-on-error: true + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-checksums: dist/provenance/subjects-attest.sha256 + - name: Finalize + consumer-style verify + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + PROV_OK="${{ steps.attest_prov.outcome }}" + if [ "${PROV_OK}" = "success" ]; then + bash scripts/finalize-provenance-attestation.sh dist/provenance signed "" \ + "${{ steps.attest_prov.outputs.attestation-id }}" \ + "${{ steps.attest_prov.outputs.attestation-url }}" + export PCS_PROVENANCE_REQUIRE_SIGNED=1 + else + REASON="PR CI attest-build-provenance outcome=${PROV_OK} (often gated on private-repo GHEC / org OIDC)." + bash scripts/finalize-provenance-attestation.sh dist/provenance gated "${REASON}" "" "" + export PCS_PROVENANCE_REQUIRE_SIGNED=0 + echo "::notice::Signed provenance gated on org attestation capability; digest binding still verified." + fi + bash scripts/verify-release-provenance.sh dist/provenance + + pf-core-adapter: + name: PF-Core adapter parity + runs-on: ubuntu-latest + continue-on-error: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: PF-Core provability-fabric-core adapter parity + run: bash scripts/run-pf-core-adapter-ci.sh validate-cli-contract: + name: Validate CLI contract runs-on: ubuntu-latest - needs: python + needs: python-tests steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -234,6 +604,7 @@ jobs: cd python pip install -c requirements.lock -e . pcs capabilities + pcs release check-gates --mode preview pcs validate ../examples/science_claim_bundle.certified.valid.json pcs validate ../examples/signed_science_claim_bundle.valid.json pcs validate ../examples/labtrust/signed_science_claim_bundle.valid.json @@ -248,3 +619,29 @@ jobs: pcs conformance run --suite tool-use pcs conformance run --suite computation pcs conformance run --suite multidomain + + # Single required-check aggregator for branch protection convenience. + ci-matrix-gate: + name: CI matrix gate + runs-on: ubuntu-latest + needs: + - python-tests + - python-typecheck + - python-coverage + - rust + - typescript + - lean-pcs + - lean-pf-core + - certificate-mode-e2e + - cross-language-differential + - semantic-projection-replay + - theorem-manifest-replay + - scientific-payload-mutation + - signature-key-revocation + - preview-release + - stable-release-dry-run + - provenance-verification + - validate-cli-contract + steps: + - name: All mandatory CI matrix jobs passed + run: echo "OK CI matrix gate" diff --git a/.github/workflows/pf-core-release-gate.yml b/.github/workflows/pf-core-release-gate.yml index 2bed1ee..1969df9 100644 --- a/.github/workflows/pf-core-release-gate.yml +++ b/.github/workflows/pf-core-release-gate.yml @@ -1,7 +1,12 @@ name: PF-Core Release Gate # Live CertifyEdge + pin verification. Tag/release path is fail-closed. -# Preview path may be exercised via workflow_dispatch with release_mode=preview. +# Preview path (workflow_dispatch release_mode=preview): lean-check → bundle-release +# → validate-bundle → absence-notice/attest → upload. +# +# Part of the mandatory CI matrix: preview path also runs on every PR via +# ci.yml `preview-release`; stable live checker is org-gated +# (pins/certifyedge.json + secrets.PF_CORE_CERTIFYEDGE_CLI). on: workflow_dispatch: @@ -31,6 +36,8 @@ jobs: run: | MODE="${{ github.event.inputs.release_mode || 'release' }}" echo "PCS_RELEASE_MODE=${MODE}" >> "$GITHUB_ENV" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh - name: Verify CertifyEdge pin run: python3 scripts/verify-certifyedge-pin.py --mode "${PCS_RELEASE_MODE}" - name: Provision CertifyEdge @@ -42,7 +49,20 @@ jobs: if [ "${PCS_RELEASE_MODE}" = "release" ] && [ $status -ne 0 ]; then exit 1 fi - if [ -x .tools/certifyedge/certifyedge ]; then + if [ -f .tools/certifyedge/provision.env ]; then + # shellcheck disable=SC1091 + set -a + # Source machine-readable provision env (path, digest, version, pin, strategy). + # Do not let an empty repository secret overwrite a provisioned path. + # shellcheck source=/dev/null + . .tools/certifyedge/provision.env + set +a + { + echo "PF_CORE_CERTIFYEDGE_CLI=${PF_CORE_CERTIFYEDGE_CLI}" + echo "PCS_CERTIFYEDGE_PROVISION_ENV=${PWD}/.tools/certifyedge/provision.env" + echo "PCS_CERTIFYEDGE_TRUST_GRADE=${PCS_CERTIFYEDGE_TRUST_GRADE:-}" + } >> "$GITHUB_ENV" + elif [ -x .tools/certifyedge/certifyedge ]; then echo "PF_CORE_CERTIFYEDGE_CLI=${PWD}/.tools/certifyedge/certifyedge" >> "$GITHUB_ENV" fi - name: Install pcs-core @@ -50,19 +70,28 @@ jobs: cd python pip install -c requirements.lock -e ".[dev,quality]" pcs capabilities + - name: Org/infra release gates + run: pcs release check-gates --mode "${PCS_RELEASE_MODE}" - name: CertifyEdge live attestation bound to release bundle (release mode) if: env.PCS_RELEASE_MODE == 'release' env: - PF_CORE_CERTIFYEDGE_CLI: ${{ secrets.PF_CORE_CERTIFYEDGE_CLI }} PF_CORE_CERTIFYEDGE_REQUIRE_LIVE: "1" + # Optional last-resort override — only applied when non-empty. + PF_CORE_CERTIFYEDGE_CLI_SECRET: ${{ secrets.PF_CORE_CERTIFYEDGE_CLI }} run: | cd python + if [ -n "${PF_CORE_CERTIFYEDGE_CLI_SECRET:-}" ] && [ -f "${PF_CORE_CERTIFYEDGE_CLI_SECRET}" ]; then + export PF_CORE_CERTIFYEDGE_CLI="${PF_CORE_CERTIFYEDGE_CLI_SECRET}" + echo "Using non-empty PF_CORE_CERTIFYEDGE_CLI secret override" + fi CLI="${PF_CORE_CERTIFYEDGE_CLI:-}" if [ -n "${CLI}" ] && [ -f "${CLI}" ]; then echo "Using PF_CORE_CERTIFYEDGE_CLI=${CLI}" elif command -v certifyedge >/dev/null 2>&1; then CLI="$(command -v certifyedge)" - echo "Using certifyedge on PATH: ${CLI}" + echo "WARNING: using unpinned certifyedge on PATH: ${CLI}" + echo "Arbitrary PATH executables are untrusted_development even if exit 0." + export PF_CORE_CERTIFYEDGE_CLI="${CLI}" else echo "FAIL: release gate requires live CertifyEdge CLI after pin provision." echo "Pin digest in pins/certifyedge.json (status=pinned) or set runner secret." @@ -71,22 +100,24 @@ jobs: fi export PF_CORE_CERTIFYEDGE_CLI="${CLI}" export PF_CORE_CERTIFYEDGE_MODE=live - # Build a lean certificate when lake available; otherwise fail closed for release. - if command -v lake >/dev/null 2>&1 || [ -x "$HOME/.elan/bin/lake" ]; then - export PATH="$HOME/.elan/bin:$PATH" - pcs pf-core lean-check \ - --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ - --out /tmp/pfcore-release-cert.json - TRACE=../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json - CERT=/tmp/pfcore-release-cert.json - else - # Bundle still requires a valid certificate artifact; use fixture cert only if lean unavailable - # is unacceptable for release — fail closed. + export PATH="$HOME/.elan/bin:$PATH" + if ! command -v lake >/dev/null 2>&1; then echo "FAIL: lake required to produce LeanKernelChecked certificate for release bundle" exit 1 fi + pcs pf-core lean-check \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --out /tmp/pfcore-release-cert.json \ + --result-out /tmp/pfcore-release-lean-check.json + TRACE=../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json + CERT=/tmp/pfcore-release-cert.json + LEAN_CHECK_RESULT=/tmp/pfcore-release-lean-check.json rm -rf /tmp/pfcore-release-bundle - pcs pf-core bundle-release --trace "${TRACE}" --cert "${CERT}" --out /tmp/pfcore-release-bundle + pcs pf-core bundle-release \ + --trace "${TRACE}" \ + --cert "${CERT}" \ + --lean-check-result "${LEAN_CHECK_RESULT}" \ + --out /tmp/pfcore-release-bundle pcs pf-core validate-bundle /tmp/pfcore-release-bundle pcs pf-core attest-bundle \ --bundle /tmp/pfcore-release-bundle \ @@ -99,6 +130,7 @@ jobs: pcs validate /tmp/pfcore-release-bundle/external_attestation.json python3 - <<'PY' import json, sys + from pathlib import Path att = json.load(open("/tmp/pfcore-release-bundle/external_attestation.json", encoding="utf-8")) assert att["attestation_class"] == "live", att assert att["result"] == "CertificateChecked", att @@ -108,28 +140,66 @@ jobs: "attestation_signature", "issuer_identity", ): assert att.get(key), f"missing {key}" - print("OK live ExternalAttestation.v0 bound to release bundle") + manifest = json.loads( + Path("/tmp/pfcore-release-bundle/manifest.json").read_text( + encoding="utf-8" + ) + ) + lean_rel = manifest.get("lean_check_result_path") + assert lean_rel, "missing lean_check_result_path" + assert (Path("/tmp/pfcore-release-bundle") / lean_rel).is_file(), lean_rel + print("OK live ExternalAttestation.v0 bound to release bundle with lean-check-result") PY - - name: Preview mode absence-or-attestation gate + - name: Preview lean-check → bundle → validate → absence/attest → upload prep if: env.PCS_RELEASE_MODE == 'preview' run: | cd python - PF_CORE_CERTIFYEDGE_MODE=mock pcs pf-core certifyedge-check \ - --trace ../examples/pf-core-valid/labtrust_replay/trace.json \ - --property qc_release.temporal.safety \ - --out /tmp/pfcore-preview-cert.json + export PATH="$HOME/.elan/bin:$PATH" + if ! command -v lake >/dev/null 2>&1; then + echo "FAIL: preview path requires lake for lean-check → bundle with lean-check-result" + exit 1 + fi + pcs pf-core lean-check \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ + --out /tmp/pfcore-preview-cert.json \ + --result-out /tmp/pfcore-preview-lean-check.json rm -rf /tmp/pfcore-preview-bundle + mkdir -p /tmp/pfcore-preview-bundle pcs pf-core bundle-release \ - --trace ../examples/pf-core-valid/labtrust_replay/trace.json \ + --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ --cert /tmp/pfcore-preview-cert.json \ + --lean-check-result /tmp/pfcore-preview-lean-check.json \ --out /tmp/pfcore-preview-bundle + pcs pf-core validate-bundle /tmp/pfcore-preview-bundle + python3 - <<'PY' + import json + from pathlib import Path + root = Path("/tmp/pfcore-preview-bundle") + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + lean_rel = manifest.get("lean_check_result_path") + if not lean_rel: + raise SystemExit("FAIL: preview bundle missing lean_check_result_path") + if not (root / lean_rel).is_file(): + raise SystemExit(f"FAIL: lean-check result missing at {lean_rel}") + print(f"OK preview lean-check-result in bundle: {lean_rel}") + PY pcs pf-core attest-bundle \ --bundle /tmp/pfcore-preview-bundle \ --property qc_release.temporal.safety \ - --allow-absence + --allow-absence || true pcs pf-core validate-external-attestation \ --bundle /tmp/pfcore-preview-bundle \ --allow-absence + pcs pf-core validate-bundle /tmp/pfcore-preview-bundle + mkdir -p ../dist/pf-core-preview-bundle + cp -a /tmp/pfcore-preview-bundle/. ../dist/pf-core-preview-bundle/ + - name: Upload preview release bundle + if: env.PCS_RELEASE_MODE == 'preview' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pcs-core-pf-core-preview-bundle + path: dist/pf-core-preview-bundle/ + retention-days: 14 - name: Reject mock-only CertifyEdge as release attestation run: | cd python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 560b2e6..56d3fc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,10 @@ name: Release # Unified release / technical-preview gate. Does not push tags. # PCS_RELEASE_MODE=release requires live external attestation + pinned CertifyEdge. # PCS_RELEASE_MODE=preview allows absence notice with explicit disclosure. +# +# Quality gates are split into blocking jobs (mandatory CI matrix). Bundle / +# attestation / provenance remain in unified-release-gate so concurrent +# provenance work is not clobbered. on: push: @@ -21,30 +25,291 @@ on: permissions: contents: read + id-token: write + attestations: write + actions: read jobs: - unified-release-gate: + python-tests: + name: Python full tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install + pytest + technical gates + run: | + cd python + pip install -c requirements.lock -e ".[dev,quality]" + pcs capabilities + ruff check pcs_core tests + ruff format --check pcs_core tests + pytest -q + pcs schema check + pcs pf-core audit-claims + pcs pf-core audit-boundary + pcs pf-core audit-lean-catalog + pcs pf-core audit-lean-no-sorry + pcs examples check + pcs validate-release-chain ../examples/labtrust-release/ + pcs validate-release-chain ../examples/tool-use-release/ + pcs validate-release-chain ../examples/computation-release/ + pcs conformance run --suite all + python -m pcs_core.hash_vectors --verify + pcs shared-hash-vectors verify + python scripts/gen_pf_core_catalog.py + git diff --exit-code \ + ../python/pcs_core/pf_core_catalog.py \ + ../lean/PFCore/Catalog.lean \ + ../rust/crates/pcs-core/src/pf_core_catalog.rs \ + ../typescript/packages/core/src/pfCoreCatalog.ts + + python-typecheck: + name: Python full-package typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Pyright full package + run: | + cd python + pip install -c requirements.lock -e ".[dev,quality]" + pyright pcs_core + + python-coverage: + name: Python branch coverage runs-on: ubuntu-latest - env: - PCS_RELEASE_MODE: ${{ github.event.inputs.release_mode || 'release' }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" + - name: Branch coverage + run: | + cd python + pip install -c requirements.lock -e ".[dev,quality]" + coverage run -m pytest -q + coverage report --fail-under=0 + coverage report \ + --include='pcs_core/external_attestation.py,pcs_core/pf_core_bundle.py,pcs_core/pf_core_certifyedge.py,pcs_core/safe_paths.py,pcs_core/hash.py,pcs_core/artifact_integrity.py,pcs_core/certifyedge_pin.py' \ + --fail-under=70 + + rust: + name: Rust fmt/clippy/tests/fuzz-smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86 + with: + components: rustfmt, clippy + - name: Rust quality + run: | + cd rust + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked + cargo test --locked proptest_digest_hex_shape + + typescript: + name: TypeScript lint/tests/property-vectors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" + - name: TypeScript quality + run: | + cd typescript + npm ci + npm run lint + npm test + npm run test:hash-vectors -w @pcs/core + + lean-pcs: + name: Lean PCS build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: lake build PCS + run: | + export PATH="$HOME/.elan/bin:$PATH" + cd lean + lake build PCS + + lean-pf-core: + name: Lean PF-Core build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: lake build PFCore + run: | + export PATH="$HOME/.elan/bin:$PATH" + cd lean + lake build PFCore + + certificate-mode-e2e: + name: Certificate-mode end-to-end + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Certificate mode suites + run: | + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_pf_core_certificate_mode_status.py \ + tests/test_pf_core_certificate_mode_codegen.py \ + tests/test_pf_core_certificate_mode_resolution_vectors.py \ + tests/test_pf_core_handoff_evidence.py \ + tests/test_pf_core_contract_evidence.py \ + tests/test_pf_core_effect_frame_evidence.py \ + tests/test_pf_core_transition_evidence.py \ + tests/test_pf_core_compositional.py + + cross-language-differential: + name: Cross-language differential + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" - uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - components: rustfmt, clippy + node-version: "22" + - name: Differential suites + run: | + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_pf_core_cross_language.py \ + tests/test_pf_core_phase4_differential.py \ + tests/test_pf_core_hash_vector_parity.py \ + tests/test_canonical_hash_release.py + pcs conformance run --suite pf-core-cross-language + cd ../rust && cargo test --locked pf_core + cd ../typescript && npm ci && npm test && npm run test:hash-vectors -w @pcs/core + + semantic-projection-replay: + name: Semantic-projection replay + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: Projection replay + run: | + export PATH="$HOME/.elan/bin:$PATH" + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_pcs_projection_binding.py \ + tests/test_phase3_envelope_binding.py \ + tests/test_pf_core_phase4_tcb.py \ + tests/test_pf_core_bundle.py + + theorem-manifest-replay: + name: Theorem-manifest replay + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install elan (checksum verified) + run: bash scripts/install-elan-verified.sh + - name: Theorem manifest + run: | + export PATH="$HOME/.elan/bin:$PATH" + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q tests/test_pf_core_theorem_manifest_binding.py + + scientific-payload-mutation: + name: Scientific payload mutation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Payload mutation tests + run: | + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_result_artifact_payload.py \ + tests/test_computation_validate.py \ + tests/test_computation_release_chain.py + + signature-key-revocation: + name: Signature and key-revocation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Integrity + pin tests + run: | + cd python + pip install -c requirements.lock -e ".[dev]" + pytest -q \ + tests/test_artifact_integrity.py \ + tests/test_certifyedge_pin.py \ + tests/test_external_attestation.py + + unified-release-gate: + name: Preview/stable release assemble + runs-on: ubuntu-latest + needs: + - python-tests + - python-typecheck + - python-coverage + - rust + - typescript + - lean-pcs + - lean-pf-core + - certificate-mode-e2e + - cross-language-differential + - semantic-projection-replay + - theorem-manifest-replay + - scientific-payload-mutation + - signature-key-revocation + outputs: + release_mode: ${{ steps.resolve_mode.outputs.mode }} + provenance_status: ${{ steps.finalize_prov.outputs.status }} + env: + PCS_RELEASE_MODE: ${{ github.event.inputs.release_mode || 'release' }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" - name: Resolve release mode + id: resolve_mode run: | MODE="${PCS_RELEASE_MODE:-release}" if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then MODE="${{ github.event.inputs.release_mode }}" fi echo "PCS_RELEASE_MODE=${MODE}" >> "$GITHUB_ENV" + echo "mode=${MODE}" >> "$GITHUB_OUTPUT" echo "Resolved PCS_RELEASE_MODE=${MODE}" - name: Verify tag matches VERSION (tag pushes only) if: startsWith(github.ref, 'refs/tags/') @@ -68,7 +333,17 @@ jobs: echo "FAIL: CertifyEdge provision required in release mode" exit 1 fi - if [ -x .tools/certifyedge/certifyedge ]; then + if [ -f .tools/certifyedge/provision.env ]; then + set -a + # shellcheck source=/dev/null + . .tools/certifyedge/provision.env + set +a + { + echo "PF_CORE_CERTIFYEDGE_CLI=${PF_CORE_CERTIFYEDGE_CLI}" + echo "PCS_CERTIFYEDGE_PROVISION_ENV=${PWD}/.tools/certifyedge/provision.env" + echo "PCS_CERTIFYEDGE_TRUST_GRADE=${PCS_CERTIFYEDGE_TRUST_GRADE:-}" + } >> "$GITHUB_ENV" + elif [ -x .tools/certifyedge/certifyedge ]; then echo "PF_CORE_CERTIFYEDGE_CLI=${PWD}/.tools/certifyedge/certifyedge" >> "$GITHUB_ENV" fi - name: Install Python package @@ -76,48 +351,12 @@ jobs: cd python pip install -c requirements.lock -e ".[dev,quality]" pcs capabilities - - name: Quality - Ruff + Pyright + pytest + coverage threshold + - name: Org/infra release gates (fail-closed in release) run: | - cd python - ruff check pcs_core tests - ruff format --check pcs_core tests - pyright pcs_core/external_attestation.py pcs_core/safe_paths.py pcs_core/pf_core_certifyedge.py - coverage run -m pytest -q - coverage report --include='pcs_core/external_attestation.py,pcs_core/pf_core_bundle.py,pcs_core/pf_core_certifyedge.py,pcs_core/safe_paths.py,pcs_core/hash.py' --fail-under=70 - - name: Technical gates - schemas, catalogs, release chains, vectors - run: | - cd python - pcs schema check - pcs pf-core audit-claims - pcs pf-core audit-boundary - pcs pf-core audit-lean-catalog - pcs pf-core audit-lean-no-sorry - pcs examples check - pcs validate-release-chain ../examples/labtrust-release/ - pcs validate-release-chain ../examples/tool-use-release/ - pcs validate-release-chain ../examples/computation-release/ - pcs conformance run --suite all - python -m pcs_core.hash_vectors --verify - pcs shared-hash-vectors verify - python scripts/gen_pf_core_catalog.py - git diff --exit-code \ - ../python/pcs_core/pf_core_catalog.py \ - ../lean/PFCore/Catalog.lean \ - ../rust/crates/pcs-core/src/pf_core_catalog.rs \ - ../typescript/packages/core/src/pfCoreCatalog.ts - - name: Rust quality gate - run: | - cd rust - cargo fmt --check - cargo clippy --locked --all-targets -- -D warnings - cargo test --locked - - name: TypeScript quality gate - run: | - cd typescript - npm ci - npm run lint - npm test - - name: Lean kernels + proof binding + # After provision.env is sourced (when pin ready). Preview may pass while + # CertifyEdge remains unpinned and TrustedKeyRegistry is unset. + pcs release check-gates --mode "${PCS_RELEASE_MODE}" + - name: Lean kernels + proof binding + release-grade conformance run: | export PATH="$HOME/.elan/bin:$PATH" cd lean @@ -126,7 +365,8 @@ jobs: cd ../python pcs pf-core lean-check \ --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ - --out /tmp/pfcore-release-cert.json + --out /tmp/pfcore-release-cert.json \ + --result-out /tmp/pfcore-release-lean-check.json pcs pf-core verify-proof-binding \ --certificate /tmp/pfcore-release-cert.json \ --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json @@ -137,19 +377,42 @@ jobs: test -f dist/sbom/pcs-core.cdx.json - name: Assemble bundle + external attestation gate env: - PF_CORE_CERTIFYEDGE_CLI: ${{ secrets.PF_CORE_CERTIFYEDGE_CLI }} + # Optional last-resort override — only applied when non-empty (never blank out provision.env). + PF_CORE_CERTIFYEDGE_CLI_SECRET: ${{ secrets.PF_CORE_CERTIFYEDGE_CLI }} run: | cd python + if [ -n "${PF_CORE_CERTIFYEDGE_CLI_SECRET:-}" ] && [ -f "${PF_CORE_CERTIFYEDGE_CLI_SECRET}" ]; then + export PF_CORE_CERTIFYEDGE_CLI="${PF_CORE_CERTIFYEDGE_CLI_SECRET}" + echo "Using non-empty PF_CORE_CERTIFYEDGE_CLI secret override" + fi mkdir -p ../dist/release-bundle pcs pf-core bundle-release \ --trace ../examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ --cert /tmp/pfcore-release-cert.json \ + --lean-check-result /tmp/pfcore-release-lean-check.json \ --out ../dist/release-bundle pcs pf-core validate-bundle ../dist/release-bundle + python3 - <<'PY' + import json, sys + from pathlib import Path + manifest = json.loads(Path("../dist/release-bundle/manifest.json").read_text(encoding="utf-8")) + lean_rel = manifest.get("lean_check_result_path") + if not lean_rel: + raise SystemExit("FAIL: preview/release bundle missing lean_check_result_path") + lean_path = Path("../dist/release-bundle") / lean_rel + if not lean_path.is_file(): + raise SystemExit(f"FAIL: lean-check result missing at {lean_path}") + pin = Path("../dist/release-bundle/certifyedge_pin.json") + if not pin.is_file(): + raise SystemExit("FAIL: release bundle missing certifyedge_pin.json") + print(f"OK lean-check-result bound in bundle: {lean_rel}") + print(f"OK certifyedge pin record in bundle: {pin.name}") + PY if [ "${PCS_RELEASE_MODE}" = "release" ]; then if [ -z "${PF_CORE_CERTIFYEDGE_CLI:-}" ] || [ ! -f "${PF_CORE_CERTIFYEDGE_CLI}" ]; then if command -v certifyedge >/dev/null 2>&1; then export PF_CORE_CERTIFYEDGE_CLI="$(command -v certifyedge)" + echo "WARNING: using unpinned certifyedge on PATH (untrusted_development)" fi fi export PF_CORE_CERTIFYEDGE_MODE=live @@ -178,6 +441,86 @@ jobs: run: | echo "Create an annotated GPG/SSH-signed tag matching VERSION after gates pass." echo "This workflow does not create or push tags." + + - name: Build release provenance binding + run: | + set -euo pipefail + pip install build >/dev/null + export PCS_PROVENANCE_BUNDLE_DIR="${GITHUB_WORKSPACE}/dist/release-bundle" + export PCS_PROVENANCE_SBOM_DIR="${GITHUB_WORKSPACE}/dist/sbom" + export PCS_PROVENANCE_BUILD_SBOM=0 + if [ "${PCS_RELEASE_MODE}" = "release" ] && [ "${{ vars.PCS_PROVENANCE_ALLOW_GATED }}" != "true" ]; then + export PCS_PROVENANCE_REQUIRE_SIGNED=1 + else + export PCS_PROVENANCE_REQUIRE_SIGNED=0 + fi + bash scripts/build-release-provenance.sh dist/provenance + test -f dist/provenance/ReleaseProvenanceBinding.v0.json + test -f dist/provenance/subjects-attest.sha256 + + - name: Attest build provenance (immutable subjects) + id: attest_prov + continue-on-error: true + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-checksums: dist/provenance/subjects-attest.sha256 + + - name: Attest SBOM + id: attest_sbom + continue-on-error: true + uses: actions/attest-sbom@115c3be05ff3974bcbd596578934b3f9ce39bf68 # v2.2.0 + with: + subject-path: dist/provenance/sbom/pcs-core.cdx.json + sbom-path: dist/provenance/sbom/pcs-core.cdx.json + + - name: Finalize provenance attestation status + id: finalize_prov + run: | + set -euo pipefail + PROV_OK="${{ steps.attest_prov.outcome }}" + SBOM_OK="${{ steps.attest_sbom.outcome }}" + IDS="${{ steps.attest_prov.outputs.attestation-id }}" + URLS="${{ steps.attest_prov.outputs.attestation-url }}" + if [ -n "${{ steps.attest_sbom.outputs.attestation-id }}" ]; then + if [ -n "${IDS}" ]; then IDS="${IDS},"; fi + IDS="${IDS}${{ steps.attest_sbom.outputs.attestation-id }}" + fi + if [ -n "${{ steps.attest_sbom.outputs.attestation-url }}" ]; then + if [ -n "${URLS}" ]; then URLS="${URLS},"; fi + URLS="${URLS}${{ steps.attest_sbom.outputs.attestation-url }}" + fi + if [ "${PROV_OK}" = "success" ]; then + bash scripts/finalize-provenance-attestation.sh dist/provenance signed "" "${IDS}" "${URLS}" + else + REASON="actions/attest-build-provenance failed (outcome=${PROV_OK}; sbom_outcome=${SBOM_OK}). " + REASON+="Common causes: private repository without GitHub Enterprise Cloud, " + REASON+="missing id-token/attestations permissions, or org policy blocking Sigstore OIDC." + bash scripts/finalize-provenance-attestation.sh dist/provenance gated "${REASON}" "" "" + fi + if [ "${{ vars.PCS_PROVENANCE_ALLOW_GATED }}" = "true" ]; then + export PCS_PROVENANCE_ALLOW_GATED=true + fi + STATUS="$(python3 -c "import json; print(json.load(open('dist/provenance/ReleaseProvenanceBinding.v0.json'))['attestation']['status'])")" + echo "status=${STATUS}" >> "$GITHUB_OUTPUT" + if [ "${PCS_RELEASE_MODE}" = "release" ] && [ "${STATUS}" != "signed" ]; then + if [ "${PCS_PROVENANCE_ALLOW_GATED:-}" = "true" ]; then + echo "WARN: release mode allowing gated provenance via PCS_PROVENANCE_ALLOW_GATED" + else + echo "FAIL: stable release requires signed provenance (status=${STATUS})" + exit 1 + fi + fi + # Re-run unified gate checker against the finalized provenance package. + python3 scripts/check-release-gates.py --mode "${PCS_RELEASE_MODE}" \ + --provenance-dir dist/provenance + + - name: Attest sealed provenance binding + if: ${{ steps.finalize_prov.outputs.status == 'signed' }} + continue-on-error: true + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: dist/provenance/ReleaseProvenanceBinding.v0.json + - name: Upload local release artifacts (dry-run assembly) uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -185,4 +528,43 @@ jobs: path: | dist/release-bundle/ dist/sbom/ + dist/provenance/ retention-days: 30 + + provenance-consumer: + name: Consumer provenance verify + needs: unified-release-gate + runs-on: ubuntu-latest + permissions: + contents: read + attestations: read + actions: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: | + scripts + python + schemas + catalog + pins + sparse-checkout-cone-mode: true + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install validator package + run: | + cd python + pip install -c requirements.lock -e "." + - name: Download release artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: pcs-core-release-bundle-${{ needs.unified-release-gate.outputs.release_mode }} + path: /tmp/pcs-release + - name: Verify provenance package as clean consumer + env: + GH_TOKEN: ${{ github.token }} + PCS_PROVENANCE_REQUIRE_SIGNED: ${{ needs.unified-release-gate.outputs.provenance_status == 'signed' && '1' || '0' }} + run: | + test -d /tmp/pcs-release/provenance + bash scripts/verify-release-provenance.sh /tmp/pcs-release/provenance From f5e70de7413efa14ebdd23917aacca7b2506cfeb Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:02:35 -0700 Subject: [PATCH 19/24] Advance compositional PF-Core Lean proofs and research suites. Extend DenyClosed, PairedExecution, and ResourcePattern surfaces so Workstream C regression suites exercise compositional certificate modes fail-closed. --- docs/pf-core/compositional-trust-roadmap.md | 7 +- .../resource_scope_violation/README.md | 15 + .../README.md | 12 +- lean/PFCore/Compositional.lean | 77 +- lean/PFCore/DenyClosed.lean | 12 + lean/PFCore/PairedExecution.lean | 43 +- lean/PFCore/ResourcePattern.lean | 49 +- python/pcs_core/pf_core_lean_codegen.py | 869 +++++++++++++++--- .../test_pf_core_certificate_mode_codegen.py | 53 +- python/tests/test_pf_core_compositional.py | 7 +- .../tests/test_pf_core_phase4_differential.py | 10 + python/tests/test_pf_core_phase5.py | 17 + python/tests/test_pf_core_phase_d.py | 36 +- python/tests/test_pf_core_research_grade.py | 3 + python/tests/test_pf_core_stage4.py | 13 +- 15 files changed, 1070 insertions(+), 153 deletions(-) create mode 100644 examples/pf-core-invalid/resource_scope_violation/README.md diff --git a/docs/pf-core/compositional-trust-roadmap.md b/docs/pf-core/compositional-trust-roadmap.md index ef45ef6..f3562a1 100644 --- a/docs/pf-core/compositional-trust-roadmap.md +++ b/docs/pf-core/compositional-trust-roadmap.md @@ -6,7 +6,8 @@ Extend PF-Core from per-trace concrete proofs to compositional theorems that pre | Theorem | Intent | Status | |---------|--------|--------| -| `safe_extension_preserves_trace_safe` | Appending an `EventSafe` event to a `TraceSafe` trace yields `TraceSafe` | **Proved** (`lean/PFCore/Compositional.lean`; alias of `trace_safe_invariant_preserved_cons`) | +| `safe_extension_preserves_trace_safe` | Appending an `EventSafe` event to a `TraceSafe` trace yields `TraceSafe` | **Proved** (`lean/PFCore/Compositional.lean`; alias of `trace_safe_invariant_preserved_cons`; also `TracePrefixSafe`) | +| `compositional_safe_extension_yields_safe_extended_trace` | A6: safe prefix + EventSafe + Applies + FrameValid ⇒ TraceSafe extended | **Proved** (`Compositional.lean`; experimental `CompositionalExtensionCertificate`) | | `handoff_composition_does_not_expand_authority` | Chained `HandoffSafe` records do not expand authority beyond the first source when the second hop stays within the first delegation envelope | **Proved** (`lean/PFCore/Compositional.lean`) | | `contract_invariant_preserved_by_safe_extension` | Trace-safe contract invariant preserved under `EventSafe` extension | **Proved** (`lean/PFCore/Compositional.lean`) | | `composed_contract_preserves_component_invariants` | `Contract.seq` invariant splits/joins component invariants | **Proved** (`lean/PFCore/Compositional.lean`) | @@ -51,6 +52,6 @@ See [non-interference.md](non-interference.md) and [assumptions.md](assumptions. | `traceSafe_implies_tenant_isolation` | Allowed events in safe traces stay tenant-scoped | **Proved** (`NonInterference.lean`) | | `traceSafe_implies_low_events_tenant_scoped` | Low-projected events in safe traces are tenant-scoped | **Proved** (`Observational.lean`; not paired-execution NI) | | `traceSafe_implies_tenant_projection_isolation` | Single-trace observational isolation | **Proved** (`Observational.lean`; user-facing name for prior observational NI) | -| `accepted_transition_no_undeclared_sensitive_observation` | Observed sensitive effects stay in declared frame under instrumentation | **Proved** (`ObservedEffect.lean`; assumes `TrustedInstrumentation`) | -| `eventSafeDenyClosed_implies_eventSafe` | Deny-closed refinement of `EventSafe` | **Proved** (`DenyClosed.lean`) | +| `accepted_transition_no_undeclared_sensitive_observation` | Observed sensitive effects stay in declared frame under observation soundness | **Proved** (`ObservedEffect.lean`; `TrustedInstrumentation` = attested execution, not mere agree) | +| `eventSafeDenyClosed_implies_eventSafe` | Deny-closed refinement of `EventSafe` | **Proved** (`DenyClosed.lean`; `DenyClosedCertificate` scaffolded/disabled) | | `PairedExecutionNonInterference` | Paired executions + scheduler + timing | **Scaffolding only** (`PairedExecution.lean`; not proved) | diff --git a/examples/pf-core-invalid/resource_scope_violation/README.md b/examples/pf-core-invalid/resource_scope_violation/README.md new file mode 100644 index 0000000..46a8b77 --- /dev/null +++ b/examples/pf-core-invalid/resource_scope_violation/README.md @@ -0,0 +1,15 @@ +# resource_scope_violation (A11 differential vector) + +All base `ActionAdmissible` / `TraceSafe` conditions pass (`cap:file-read`, +matching effects, in-tenant principal), but the read URI `/etc/passwd` lies +outside the declared capability pattern `/data/*`. + +| Decider | Expected | +|---------|----------| +| `TraceSafe` / `traceSafeD` | `true` | +| `TraceSafeR` / `traceSafeRD` | `false` | + +Hash-chain / runtime validation still reports `ResourceScopeViolation`. + +Shared across Lean (`PFCore.ResourcePattern` A11 examples), Python +(`lean_check.trace_safe_d` / `trace_safe_rd`), Rust, and TypeScript. diff --git a/examples/pf-core-valid/certificate_mode_compositionalextensioncertificate/README.md b/examples/pf-core-valid/certificate_mode_compositionalextensioncertificate/README.md index 85e0161..8410d7a 100644 --- a/examples/pf-core-valid/certificate_mode_compositionalextensioncertificate/README.md +++ b/examples/pf-core-valid/certificate_mode_compositionalextensioncertificate/README.md @@ -1,7 +1,13 @@ # Valid CompositionalExtensionCertificate fixture -Valid PF-Core trace exercising **`CompositionalExtensionCertificate`** certificate-mode codegen obligations. +Valid PF-Core trace exercising **`CompositionalExtensionCertificate`** certificate-mode +codegen obligations (A6). -Compositional trace extension safety. +Obligations: safe prefix + EventSafe extension + successful `stepState` application + +preserved `FrameValid` frames ⇒ `TraceSafe` extended trace (`CompositionalSafeExtension`). +Prefix-only `TraceSafe` chaining is the narrower `TracePrefixSafe` claim. -Regenerate via `python/scripts/gen_certificate_mode_fixtures.py` when certificate-mode obligations change. +Status: `experimental` (not `release_candidate`). + +Regenerate via `python/scripts/gen_certificate_mode_fixtures.py` when certificate-mode +obligations change. diff --git a/lean/PFCore/Compositional.lean b/lean/PFCore/Compositional.lean index dd0aad0..aa6045e 100644 --- a/lean/PFCore/Compositional.lean +++ b/lean/PFCore/Compositional.lean @@ -2,29 +2,96 @@ import PFCore.Contract import PFCore.Handoff import PFCore.NonInterference import PFCore.ResourcePattern +import PFCore.Transition /-! # PF-Core compositional trust (conservative extension layer) -Conservative theorems for trace extension, handoff chaining, and sequential contract -invariants. This module does not introduce full state/transition machinery; it -composes existing kernel predicates only. +Conservative theorems for trace extension, handoff chaining, sequential contract +invariants, and the substantive A6 compositional-extension predicate. + +**Certificate naming (A6):** +- `CompositionalExtensionCertificate` targets `CompositionalSafeExtension` → safe extended trace + (safe prefix + admissible extension + successful operational application + preserved frames). +- Prefix-only `TraceSafe` chaining is the narrower claim `TracePrefixSafe` (document / + experimental alias `TracePrefixSafeCertificate`); it does **not** discharge operational + application or frame preservation. -/ namespace PFCore +/-- +Narrower claim: a trace is prefix-safe when it is `TraceSafe`. + +Prefer documenting certificates that only chain `TraceSafe` prefixes as +`TracePrefixSafeCertificate`. This is **not** the A6 compositional-extension predicate. +-/ +abbrev TracePrefixSafe : Trace → Prop := TraceSafe + /-- **Meaning:** Appending an `EventSafe` event to a `TraceSafe` trace yields `TraceSafe`. -**Trusted use:** Compositional trace-safety reasoning under controlled extension; +**Trusted use:** Prefix-safe / `TracePrefixSafe` composition under controlled extension; alias of `trace_safe_invariant_preserved_cons` with compositional naming. -**Does not imply:** Hash-chain integrity, replay validity, or contract pre/post discharge. +**Does not imply:** Operational `Applies`, frame preservation, hash-chain integrity, +replay validity, or contract pre/post discharge. -/ theorem safe_extension_preserves_trace_safe (tr : Trace) (ev : Event) : TraceSafe tr → EventSafe ev → TraceSafe (Trace.cons tr ev) := trace_safe_invariant_preserved_cons tr ev +/-- Prefix-safe naming for the same cons lemma. -/ +theorem trace_prefix_safe_extension (tr : Trace) (ev : Event) : + TracePrefixSafe tr → EventSafe ev → TracePrefixSafe (Trace.cons tr ev) := + safe_extension_preserves_trace_safe tr ev + +/-- +**A6 compositional extension predicate:** safe prefix + admissible (`EventSafe`) extension ++ successful operational application + preserved resource/capability frames. + +When the contract uses `traceSafeInvariant`, contract-invariant preservation follows +from safe extension (see `compositional_safe_extension_preserves_contract_invariant`). +-/ +def CompositionalSafeExtension (tr : Trace) (ev : Event) (s s' : State) : Prop := + TraceSafe tr ∧ + EventSafe ev ∧ + Applies ev s s' ∧ + FrameValid s ∧ + FrameValid s' + +/-- +**Meaning:** The A6 predicate yields a safe extended trace. + +**Trusted use:** `CompositionalExtensionCertificate` witness obligation. + +**Does not imply:** Hash-chain integrity, replay validity, handoff composition without +separate `HandoffSafe` evidence, or arbitrary user-defined contract invariants. +-/ +theorem compositional_safe_extension_yields_safe_extended_trace + (tr : Trace) (ev : Event) (s s' : State) + (h : CompositionalSafeExtension tr ev s s') : + TraceSafe (Trace.cons tr ev) := by + rcases h with ⟨hTr, hEv, _, _, _⟩ + exact safe_extension_preserves_trace_safe tr ev hTr hEv + +/-- +**Meaning:** Under `traceSafeInvariant`, A6 extension preserves the contract invariant. + +**Trusted use:** Optional contract composition when resolved contract evidence is present. + +**Does not imply:** Custom invariants without matching `traceSafeInvariant` structure. +-/ +theorem compositional_safe_extension_preserves_contract_invariant + (c : Contract) (tr : Trace) (ev : Event) (s s' : State) + (hInv : c.invariant = traceSafeInvariant) + (h : CompositionalSafeExtension tr ev s s') + (_hC : c.invariant tr) : + c.invariant (Trace.cons tr ev) := by + have hSafe := compositional_safe_extension_yields_safe_extended_trace tr ev s s' h + rw [hInv] + exact hSafe + /-- **Meaning:** The canonical trace-safe contract invariant is preserved when extending with an `EventSafe` event. diff --git a/lean/PFCore/DenyClosed.lean b/lean/PFCore/DenyClosed.lean index 61a72f2..924f483 100644 --- a/lean/PFCore/DenyClosed.lean +++ b/lean/PFCore/DenyClosed.lean @@ -12,6 +12,18 @@ record: no resource mutation, no side-effecting effect kinds, no tool invocation observations, no delegated authority, and optional deny-reason consistency. Base `EventSafe` / `TraceSafe` remain unchanged and compatible. + +## DenyClosedCertificate (Workstream C2) — scaffolded / disabled + +A public `DenyClosedCertificate` would require runtime evidence that after a deny +there are no tool invocations, mutations, network/message/code/release/state, or +delegation effects — beyond the **declared** footprint constraints proved here. + +v0 runtime evidence does **not** yet support that stronger claim. Do **not** issue +`DenyClosedCertificate` as a release or experimental public claim. Keep using +`EventSafeDenyClosed` / `DenyClosedBundle` for declared-footprint refinement only. +See `docs/pf-core/runtime-semantics.md` and `schemas/pf_core.certificate_mode_status.json` +(`scaffolded_modes`). -/ namespace PFCore diff --git a/lean/PFCore/PairedExecution.lean b/lean/PFCore/PairedExecution.lean index 39c078f..e52bb80 100644 --- a/lean/PFCore/PairedExecution.lean +++ b/lean/PFCore/PairedExecution.lean @@ -7,12 +7,19 @@ import PFCore.Transition **Status:** Research scaffolding only. Strong paired-execution non-interference theorems are **not proved** here and must **not** be cited as release claims. -The proved single-trace observational property is `TenantProjectionIsolation` -(see `Observational.lean`). Reserve the name **NonInterference** for a future -paired-execution theorem family in a later schema/kernel version. +## Claim boundary (Workstream C3) -This module records vocabulary and assumptions required for that future family: -paired executions, low-equivalent initial states, high-input perturbations, +| Formal predicate | Status | May be called “non-interference”? | +|------------------|--------|-----------------------------------| +| `TenantProjectionIsolation` | **Proved** (single-trace observational) | Only with the qualifier “single-trace observational / projection isolation” | +| `NonInterference` (Observational abbrev) | Compatibility alias of the above | Prefer `TenantProjectionIsolation` in user-facing claims | +| `PairedExecutionNonInterference` | **Unproved scaffolding** | Never as a stable or public claim | + +No stable certificate or public claim may use the bare phrase “non-interference” +without naming which formal predicate is meant. + +This module records vocabulary and assumptions required for a future paired-execution +family: paired executions, low-equivalent initial states, high-input perturbations, an explicit scheduler model, low-output equivalence, termination and timing assumptions, and declassification rules. -/ @@ -132,4 +139,30 @@ theorem tenant_projection_isolation_of_trace_safe TenantProjectionIsolation tenantLow tenantHigh tr := traceSafe_implies_tenant_projection_isolation tenantLow tenantHigh tr h +/-- +Scaffolding vocabulary: a paired run under explicit assumptions. Inhabitance of +this structure is **not** a non-interference proof. +-/ +structure PairedRun where + tenantLow : String + tenantHigh : String + left : Execution + right : Execution + assumptions : PairedExecutionAssumptions +deriving Repr + +/-- Name the unproved paired-execution obligation for a `PairedRun`. -/ +def PairedRun.NonInterferenceObligation (r : PairedRun) : Prop := + PairedExecutionNonInterference r.tenantLow r.tenantHigh r.left r.right r.assumptions + +/-- +Same-execution paired runs satisfy low-output equivalence only. This does **not** +prove `PairedRun.NonInterferenceObligation` under high-input perturbation. +-/ +theorem paired_run_same_execution_low_output + (tenantLow _tenantHigh : String) (e : Execution) + (_asm : PairedExecutionAssumptions) : + LowOutputEquivalent tenantLow e e := + low_output_equivalent_refl tenantLow e + end PFCore diff --git a/lean/PFCore/ResourcePattern.lean b/lean/PFCore/ResourcePattern.lean index f965327..a710264 100644 --- a/lean/PFCore/ResourcePattern.lean +++ b/lean/PFCore/ResourcePattern.lean @@ -380,7 +380,7 @@ theorem eventSafe_allow_and_scope_implies_eventSafeR (ev : Event) (h : EventSafe **Trusted use:** Runtime/codegen alignment: resource-pattern decider is stricter than kernel decider. -**Does not imply:** Python `action_admissible_d` parity without catalog URI mapping. +**Does not imply:** Reverse refinement (`traceSafeD` alone does not imply `traceSafeRD`). -/ theorem traceSafeRD_implies_traceSafeD (tr : Trace) (h : traceSafeRD tr = true) : traceSafeD tr = true := by @@ -397,4 +397,51 @@ theorem uriMatchesPattern_star (uri : String) : UriMatchesPattern uri "*" := by simp [UriMatchesPattern, globMatch, globMatchChars, globMatchCharsFuel] +/-! +## A11 differential vector — base TraceSafe vs refined TraceSafeR + +Shared with Python/Rust/TypeScript fixture +`examples/pf-core-invalid/resource_scope_violation/trace.json`: +all base admissibility conditions pass, but the read URI `/etc/passwd` lies +outside catalog pattern `/data/*` for `cap:file-read`. + +Expected: `traceSafeD = true`, `traceSafeRD = false`. +-/ + +def a11_resource_scope_principal : Principal := + { + id := "agent-1", + tenant := "tenant-a", + roles := ["agent"], + capabilities := ["cap:file-read"] + } + +def a11_resource_scope_action : Action := + { + id := "act-1", + toolName := "filesystem.read", + capability := "cap:file-read", + capabilityEffect := Effect.read, + effects := [Effect.read], + reads := [{ uri := "/etc/passwd", tenant := "tenant-a", labels := [] }], + writes := [] + } + +def a11_resource_scope_event : Event := + { + id := "ev-file-read-1", + principal := a11_resource_scope_principal, + action := a11_resource_scope_action, + decision := Decision.allow + } + +def a11_resource_scope_trace : Trace := + Trace.ofEvents [a11_resource_scope_event] + +/-- Base TraceSafe holds when only resource-pattern scope fails. -/ +example : traceSafeD a11_resource_scope_trace = true := by native_decide + +/-- Refined TraceSafeR rejects URI outside capability pattern. -/ +example : traceSafeRD a11_resource_scope_trace = false := by native_decide + end PFCore diff --git a/python/pcs_core/pf_core_lean_codegen.py b/python/pcs_core/pf_core_lean_codegen.py index 8cf704d..2b5f3cc 100644 --- a/python/pcs_core/pf_core_lean_codegen.py +++ b/python/pcs_core/pf_core_lean_codegen.py @@ -18,10 +18,18 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping +from typing import Any, Mapping, Sequence +from pcs_core.asset_resolver import ( + lean_root as resolve_lean_root, +) +from pcs_core.asset_resolver import ( + pcs_kernel_root, + pf_core_kernel_root, + proof_ref_from_path, + require_lean_root, +) from pcs_core.hash import canonical_hash -from pcs_core.paths import repo_root from pcs_core.pf_core_contract import ( field_semantics_layer, load_contracts_from_dir, @@ -42,6 +50,10 @@ class GeneratedLeanProof: mode_witness_proposition: str = "" semantic_projection_hash: str | None = None semantic_projection: Mapping[str, Any] | None = None + theorem_specs: tuple[Any, ...] = () + theorem_manifest: Mapping[str, Any] | None = None + theorem_manifest_hash: str | None = None + theorem_manifest_path: Path | None = None class CertificateModeEvidenceMissing(ValueError): @@ -111,6 +123,7 @@ class CertificateModeEvidenceMissing(ValueError): "concrete_trace_safe", "concrete_trace_safe_prop", "concrete_allowed_events_allowed", + "compositional_frame_valid_initial", "concrete_compositional_extension", } ), @@ -125,7 +138,10 @@ class CertificateModeEvidenceMissing(ValueError): } _LEAN_IDENT_RE = re.compile(r"[^a-zA-Z0-9_]") -_THEOREM_SIGNATURE_RE = re.compile(r"theorem (\w+) : (.+) :=", re.DOTALL) +_THEOREM_SIGNATURE_RE = re.compile( + r"theorem\s+(\w+)(?:\s*\([^)]*\))*\s*:\s*(.+?)\s*:=", + re.DOTALL, +) def _workflow_certificate_mode_from_catalog(workflow_id: str) -> str | None: @@ -203,16 +219,35 @@ def certificate_mode_obligations( mode: str, events: list[Mapping[str, Any]], ) -> frozenset[str]: - """Static + per-allow-event obligations for a certificate mode.""" + """Static + per-event obligations for a certificate mode.""" base = MODE_OBLIGATION_THEOREMS.get(mode, frozenset()) - if mode != "TraceSafeRCertificate": - return base | {"concrete_certificate_mode_witness"} - resource_scope = frozenset( - f"concrete_action_resource_scope_{lean_ident('ev', str(event.get('event_id') or index))}" - for index, event in enumerate(events) - if str(event.get("decision") or "") == "allow" - ) - return base | resource_scope | {"concrete_certificate_mode_witness"} + if mode == "TraceSafeRCertificate": + resource_scope = frozenset( + f"concrete_action_resource_scope_{lean_ident('ev', str(event.get('event_id') or index))}" + for index, event in enumerate(events) + if str(event.get("decision") or "") == "allow" + ) + return base | resource_scope | {"concrete_certificate_mode_witness"} + if mode == "FramePreservedCertificate": + transition_names: set[str] = set() + for index, event in enumerate(events): + event_name = lean_ident("ev", str(event.get("event_id") or index)) + transition_names.add(f"step_state_applies_{event_name}") + transition_names.add(f"frame_valid_after_{event_name}") + decision = str(event.get("decision") or "") + if decision == "deny": + transition_names.add(f"deny_identity_{event_name}") + else: + transition_names.update( + { + f"resource_frame_update_{event_name}", + f"active_principal_update_{event_name}", + f"tenant_update_{event_name}", + f"capability_frame_update_{event_name}", + } + ) + return base | transition_names | {"concrete_certificate_mode_witness"} + return base | {"concrete_certificate_mode_witness"} def theorem_inventory_hash(theorem_names: frozenset[str] | set[str]) -> str: @@ -236,6 +271,7 @@ def verify_certificate_mode_prerequisites( handoffs: list[Mapping[str, Any]], contracts: Mapping[str, Mapping[str, Any]], contract_theorems: list[str], + effect_frame: Mapping[str, Any] | None = None, ) -> None: """Fail closed when a certificate mode lacks required evidence.""" if mode == "HandoffSafeCertificate": @@ -263,14 +299,55 @@ def verify_certificate_mode_prerequisites( raise CertificateModeEvidenceMissing( "FramePreservedCertificate requires concrete initial state (event principal)" ) + from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + simulate_frame_preserved_transitions, + ) + + try: + simulate_frame_preserved_transitions(events) + except EvidenceResolutionError as exc: + raise CertificateModeEvidenceMissing(str(exc)) from exc elif mode == "EffectFrameCertificate": if not events: raise CertificateModeEvidenceMissing("EffectFrameCertificate requires ≥1 event") + if effect_frame is None: + raise CertificateModeEvidenceMissing( + "EffectFrameCertificate requires an independently declared " + "PFCoreEffectFrame.v0 (evidence_selection.effect_frame_id)" + ) + from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + assert_actions_in_declared_frame, + effect_frame_allowed_kinds, + ) + + if not effect_frame_allowed_kinds(effect_frame): + raise CertificateModeEvidenceMissing( + "EffectFrameCertificate declared frame has empty allowed_effect_kinds" + ) + try: + assert_actions_in_declared_frame(frame=effect_frame, events=events) + except EvidenceResolutionError as exc: + raise CertificateModeEvidenceMissing(str(exc)) from exc elif mode == "CompositionalExtensionCertificate": if not events: raise CertificateModeEvidenceMissing( "CompositionalExtensionCertificate requires ≥1 event" ) + # A6: operational application + frame preservation (same transition evidence as + # FramePreserved). Prefix-only TraceSafe chaining is TracePrefixSafeCertificate. + from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + simulate_frame_preserved_transitions, + ) + + try: + simulate_frame_preserved_transitions(events) + except EvidenceResolutionError as exc: + raise CertificateModeEvidenceMissing( + f"CompositionalExtensionCertificate operational application failed: {exc}" + ) from exc elif mode == "TraceSafeCertificate": if not events: raise CertificateModeEvidenceMissing("TraceSafeCertificate requires ≥1 event") @@ -321,15 +398,16 @@ def lean_and_intro_theorem(name: str, props: list[str], proof_refs: list[str]) - typ = " ∧ ".join(props) proof = proof_refs[-1] for ref in reversed(proof_refs[:-1]): - proof = f"And.intro {ref} {proof}" + # Parenthesize nested And.intro so Lean does not parse + # ``And.intro a And.intro b c`` as a failed function application. + proof = f"And.intro {ref} ({proof})" return f"theorem {name} : {typ} := {proof}" def _parse_theorem_signature(lean_theorem: str) -> tuple[str, str] | None: - match = _THEOREM_SIGNATURE_RE.search(lean_theorem) - if match is None: - return None - return match.group(1), " ".join(match.group(2).split()) + from pcs_core.pf_core_theorem_manifest import parse_theorem_signature + + return parse_theorem_signature(lean_theorem) def lean_string_literal(value: str) -> str: @@ -352,6 +430,26 @@ def effect_kind_to_lean(effect_kind: str) -> str: return mapped +def declared_effect_frame_to_lean( + frame: Mapping[str, Any], + *, + name: str = "concreteDeclaredFrame", +) -> str: + """Emit an independent Lean ``List Effect`` from a PFCoreEffectFrame.v0 artifact. + + The frame is never derived from ``action.effects``. + """ + from pcs_core.pf_core_resolved_evidence import effect_frame_allowed_kinds + + kinds = effect_frame_allowed_kinds(frame) + if not kinds: + raise CertificateModeEvidenceMissing( + "declared effect frame requires ≥1 allowed_effect_kinds" + ) + effect_exprs = [effect_kind_to_lean(kind) for kind in kinds] + return f"def {name} : List Effect :=\n [{', '.join(effect_exprs)}]" + + def principal_to_lean(principal: Mapping[str, Any], *, name: str) -> str: roles = [lean_string_literal(str(role)) for role in principal.get("roles", [])] capabilities = [lean_string_literal(str(cap)) for cap in principal.get("capabilities", [])] @@ -577,12 +675,37 @@ def generate_contract_proof_obligations( contracts: Mapping[str, Mapping[str, Any]], *, inventory: set[str] | None = None, + ctx: Any | None = None, ) -> tuple[list[str], list[str]]: """Return (contract Lean defs, contract proof theorems) for referenced contracts.""" + from pcs_core.pf_core_theorem_manifest import TheoremBuildContext + defs: list[str] = [] theorems: list[str] = [] seen_contracts: set[str] = set() - names = inventory if inventory is not None else set() + build_ctx = ctx if isinstance(ctx, TheoremBuildContext) else None + names = ( + build_ctx.inventory + if build_ctx is not None + else (inventory if inventory is not None else set()) + ) + + def _emit(lean: str, *, node: str, evidence: tuple[str, ...] = ()) -> None: + if build_ctx is not None: + theorems.append( + build_ctx.emit( + lean, + category="contract", + generation_node=node, + evidence_artifact_ids=evidence, + certificate_mode_role="required", + ) + ) + else: + parsed = _parse_theorem_signature(lean) + if parsed is not None: + register_theorem_name(names, parsed[0]) + theorems.append(lean) trace_id = str(trace.get("trace_id") or "trace") trace_var = lean_ident("trace", trace_id) @@ -592,45 +715,51 @@ def generate_contract_proof_obligations( if not isinstance(refs, list): continue event_name = lean_ident("ev", str(event.get("event_id") or index)) + event_id = str(event.get("event_id") or index) for ref in refs: contract_id = str(ref) contract = contracts.get(contract_id) if contract is None: continue base_name = lean_ident("contract", contract_id) + evidence = (contract_id, event_id) if contract_id not in seen_contracts: seen_contracts.add(contract_id) defs.append(contract_specs_to_lean(contract, base_name=base_name)) theorem_name = f"concrete_trace_satisfies_{base_name}" - register_theorem_name(names, theorem_name) - theorems.append( + _emit( f"theorem {theorem_name} : " f"traceSatisfiesContractSpecsD {base_name}Pre {base_name}Post " f"{base_name}Inv {trace_var} = true := by\n" - " decide" + " decide", + node=f"codegen.contract.{contract_id}.trace_satisfies", + evidence=(contract_id,), ) theorem_name = f"concrete_satisfies_{base_name}_{event_name}" - register_theorem_name(names, theorem_name) - theorems.append( + _emit( f"theorem {theorem_name} : " f"satisfiesContractSpecD {base_name}Pre {base_name}Post {event_name} = true := by\n" - " decide" + " decide", + node=f"codegen.contract.{contract_id}.event.{event_id}.satisfies", + evidence=evidence, ) if _contract_has_lean_pre_fields(contract): pre_name = f"concrete_contract_pre_{base_name}_{event_name}" - register_theorem_name(names, pre_name) - theorems.append( + _emit( f"theorem {pre_name} : " f"contractPreD {base_name}Pre {event_name}Principal {event_name}Action = true := by\n" - " decide" + " decide", + node=f"codegen.contract.{contract_id}.event.{event_id}.pre", + evidence=evidence, ) if _contract_has_lean_post_fields(contract): post_name = f"concrete_contract_post_{base_name}_{event_name}" - register_theorem_name(names, post_name) - theorems.append( + _emit( f"theorem {post_name} : " f"contractPostD {base_name}Post {event_name} = true := by\n" - " decide" + " decide", + node=f"codegen.contract.{contract_id}.event.{event_id}.post", + evidence=evidence, ) return defs, theorems @@ -746,14 +875,10 @@ def handoff_to_lean(handoff: Mapping[str, Any], *, name: str) -> str: to_name = f"{name}To" from_def = principal_to_lean(from_principal, name=from_name) to_def = principal_to_lean(to_principal, name=to_name) - delegated = handoff.get("delegated_capabilities") - cap_ids: list[str] = [] - if isinstance(delegated, list): - for item in delegated: - if isinstance(item, dict): - cap_id = str(item.get("capability_id") or "") - if cap_id: - cap_ids.append(cap_id) + # Exact projected ID sequence — do not sort or rediscover from source siblings. + from pcs_core.pf_core_resolved_evidence import delegated_capability_ids + + cap_ids = delegated_capability_ids(handoff) caps_expr = "[]" if not cap_ids else f"[{', '.join(lean_string_literal(c) for c in cap_ids)}]" handoff_def = ( f"def {name} : Handoff :=\n" @@ -807,6 +932,7 @@ def generate_mode_proof_theorems( contract_theorems: list[str], inventory: set[str], trace_path: Path | None = None, + effect_frame: Mapping[str, Any] | None = None, ) -> tuple[list[str], str | None, str | None]: """Return mode theorems plus optional aggregate (prop, proof) for the mode witness. @@ -824,28 +950,129 @@ def generate_mode_proof_theorems( raise CertificateModeEvidenceMissing( "FramePreservedCertificate requires ≥1 event and concrete initial state" ) + from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + simulate_frame_preserved_transitions, + ) + + try: + simulate_frame_preserved_transitions(events) + except EvidenceResolutionError as exc: + raise CertificateModeEvidenceMissing(str(exc)) from exc + first = events[0] first_name = lean_ident("ev", str(first.get("event_id") or "0")) principal_name = f"{first_name}Principal" + pre_state = "frameState_0" + theorems.append(f"def {pre_state} : State := initialState {principal_name}") register_theorem_name(inventory, "frame_valid_initial") theorems.append( - f"theorem frame_valid_initial : frameValidD (initialState {principal_name}) = true := by\n" - " decide" + f"theorem frame_valid_initial : frameValidD {pre_state} = true := by\n decide" ) - state_expr = f"initialState {principal_name}" - step_props: list[str] = [f"frameValidD (initialState {principal_name}) = true"] + step_props: list[str] = [f"frameValidD {pre_state} = true"] step_refs: list[str] = ["frame_valid_initial"] for index, event in enumerate(events): event_name = lean_ident("ev", str(event.get("event_id") or index)) - next_state = f"applyEvent {state_expr} {event_name}" - step_theorem = f"frame_preserved_step_{event_name}" - register_theorem_name(inventory, step_theorem) + principal_ref = f"{event_name}Principal" + action_ref = f"{event_name}Action" + post_state = f"frameState_{index + 1}" + decision = str(event.get("decision") or "") + if decision == "deny": + theorems.append(f"def {post_state} : State := {pre_state}") + applies_name = f"step_state_applies_{event_name}" + register_theorem_name(inventory, applies_name) + theorems.append( + f"theorem {applies_name} : " + f"stepState {pre_state} {event_name} = some {post_state} := by\n" + " decide" + ) + identity_name = f"deny_identity_{event_name}" + register_theorem_name(inventory, identity_name) + theorems.append( + f"theorem {identity_name} : {pre_state} = {post_state} := by\n decide" + ) + step_props.extend( + [ + f"stepState {pre_state} {event_name} = some {post_state}", + f"{pre_state} = {post_state}", + ] + ) + step_refs.extend([applies_name, identity_name]) + else: + # Explicit post-state via expandResourceFrame (no applyEvent fallback). + theorems.append( + f"def {post_state} : State :=\n" + " {\n" + f" tenant := {principal_ref}.tenant\n" + f" activePrincipal := {principal_ref}\n" + f" resourceFrame := expandResourceFrame {pre_state}.resourceFrame " + f"{action_ref}\n" + f" capabilityFrame := {principal_ref}.capabilities\n" + " }" + ) + applies_name = f"step_state_applies_{event_name}" + register_theorem_name(inventory, applies_name) + theorems.append( + f"theorem {applies_name} : " + f"stepState {pre_state} {event_name} = some {post_state} := by\n" + " decide" + ) + resource_name = f"resource_frame_update_{event_name}" + register_theorem_name(inventory, resource_name) + theorems.append( + f"theorem {resource_name} : " + f"{post_state}.resourceFrame = " + f"expandResourceFrame {pre_state}.resourceFrame {action_ref} := by\n" + " decide" + ) + principal_upd = f"active_principal_update_{event_name}" + register_theorem_name(inventory, principal_upd) + theorems.append( + f"theorem {principal_upd} : " + f"{post_state}.activePrincipal = {principal_ref} := by\n" + " decide" + ) + tenant_upd = f"tenant_update_{event_name}" + register_theorem_name(inventory, tenant_upd) + theorems.append( + f"theorem {tenant_upd} : " + f"{post_state}.tenant = {principal_ref}.tenant := by\n" + " decide" + ) + caps_upd = f"capability_frame_update_{event_name}" + register_theorem_name(inventory, caps_upd) + theorems.append( + f"theorem {caps_upd} : " + f"{post_state}.capabilityFrame = {principal_ref}.capabilities := by\n" + " decide" + ) + step_props.extend( + [ + f"stepState {pre_state} {event_name} = some {post_state}", + f"{post_state}.resourceFrame = " + f"expandResourceFrame {pre_state}.resourceFrame {action_ref}", + f"{post_state}.activePrincipal = {principal_ref}", + f"{post_state}.tenant = {principal_ref}.tenant", + f"{post_state}.capabilityFrame = {principal_ref}.capabilities", + ] + ) + step_refs.extend( + [ + applies_name, + resource_name, + principal_upd, + tenant_upd, + caps_upd, + ] + ) + frame_after = f"frame_valid_after_{event_name}" + register_theorem_name(inventory, frame_after) theorems.append( - f"theorem {step_theorem} : frameValidD {next_state} = true := by\n decide" + f"theorem {frame_after} : frameValidD {post_state} = true := by\n decide" ) - step_props.append(f"frameValidD {next_state} = true") - step_refs.append(step_theorem) - state_expr = next_state + step_props.append(f"frameValidD {post_state} = true") + step_refs.append(frame_after) + pre_state = post_state register_theorem_name(inventory, "frame_preserved_steps") theorems.append(lean_and_intro_theorem("frame_preserved_steps", step_props, step_refs)) aggregate_prop = " ∧ ".join(step_props) @@ -854,6 +1081,14 @@ def generate_mode_proof_theorems( if mode == "EffectFrameCertificate": if not events: raise CertificateModeEvidenceMissing("EffectFrameCertificate requires ≥1 event") + if effect_frame is None: + raise CertificateModeEvidenceMissing( + "EffectFrameCertificate requires an independently declared " + "PFCoreEffectFrame.v0 bound via evidence_selection.effect_frame_id" + ) + # One global declared frame for all events (v0 policy). + frame_name = "concreteDeclaredFrame" + theorems.append(declared_effect_frame_to_lean(effect_frame, name=frame_name)) effect_props: list[str] = [] effect_refs: list[str] = [] for index, event in enumerate(events): @@ -861,12 +1096,13 @@ def generate_mode_proof_theorems( action_name = f"{event_name}Action" step_theorem = f"concrete_action_effects_in_frame_{event_name}" register_theorem_name(inventory, step_theorem) + # Non-tautological: frame is the independent declared artifact, never action.effects. theorems.append( f"theorem {step_theorem} : " - f"actionEffectsInFrameD {action_name} {action_name}.effects = true := by\n" + f"actionEffectsInFrameD {action_name} {frame_name} = true := by\n" " decide" ) - effect_props.append(f"actionEffectsInFrameD {action_name} {action_name}.effects = true") + effect_props.append(f"actionEffectsInFrameD {action_name} {frame_name} = true") effect_refs.append(step_theorem) register_theorem_name(inventory, "concrete_action_effects_in_frame") theorems.append( @@ -905,38 +1141,120 @@ def generate_mode_proof_theorems( aggregate_proof = "concrete_handoff_safe" if mode == "CompositionalExtensionCertificate": + # A6: safe prefix + EventSafe + Applies + FrameValid pre/post → TraceSafe extended. + # Prefix-only TraceSafe chaining belongs under TracePrefixSafeCertificate (docs alias). if not events: raise CertificateModeEvidenceMissing( "CompositionalExtensionCertificate requires ≥1 event" ) + from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + simulate_frame_preserved_transitions, + ) + + try: + simulate_frame_preserved_transitions(events) + except EvidenceResolutionError as exc: + raise CertificateModeEvidenceMissing( + f"CompositionalExtensionCertificate operational application failed: {exc}" + ) from exc + + first = events[0] + first_name = lean_ident("ev", str(first.get("event_id") or "0")) + principal_name = f"{first_name}Principal" + pre_state = "compositionalState_0" + theorems.append(f"def {pre_state} : State := initialState {principal_name}") + register_theorem_name(inventory, "compositional_frame_valid_initial") + theorems.append( + f"theorem compositional_frame_valid_initial : " + f"frameValidD {pre_state} = true := by\n" + " decide" + ) trace_expr = "Trace.empty" - compositional_props: list[str] = [] - compositional_refs: list[str] = [] + compositional_props: list[str] = [f"frameValidD {pre_state} = true"] + compositional_refs: list[str] = ["compositional_frame_valid_initial"] + frame_valid_pre = "compositional_frame_valid_initial" for index, event in enumerate(events): event_name = lean_ident("ev", str(event.get("event_id") or index)) + principal_ref = f"{event_name}Principal" + action_ref = f"{event_name}Action" + post_state = f"compositionalState_{index + 1}" prev_trace = trace_expr - trace_expr = f"Trace.cons ({prev_trace}) {event_name}" + # Fully parenthesize so `TraceSafe (Trace.cons ...)` parses correctly. + trace_expr = f"(Trace.cons ({prev_trace}) {event_name})" + decision = str(event.get("decision") or "") + if decision == "deny": + theorems.append(f"def {post_state} : State := {pre_state}") + else: + theorems.append( + f"def {post_state} : State :=\n" + " {\n" + f" tenant := {principal_ref}.tenant\n" + f" activePrincipal := {principal_ref}\n" + f" resourceFrame := expandResourceFrame {pre_state}.resourceFrame " + f"{action_ref}\n" + f" capabilityFrame := {principal_ref}.capabilities\n" + " }" + ) + applies_name = f"compositional_step_applies_{event_name}" + register_theorem_name(inventory, applies_name) + theorems.append( + f"theorem {applies_name} : " + f"stepState {pre_state} {event_name} = some {post_state} := by\n" + " decide" + ) + frame_after = f"compositional_frame_valid_after_{event_name}" + register_theorem_name(inventory, frame_after) + theorems.append( + f"theorem {frame_after} : frameValidD {post_state} = true := by\n decide" + ) step_theorem = f"concrete_compositional_extension_{event_name}" register_theorem_name(inventory, step_theorem) if index == 0: - theorems.append( - f"theorem {step_theorem} : " - f"TraceSafe {trace_expr} :=\n" - f" safe_extension_preserves_trace_safe {prev_trace} {event_name} " - "traceSafe_empty " - f"concrete_event_safe_{event_name}" - ) + prefix_proof = "traceSafe_empty" else: prev_event = lean_ident("ev", str(events[index - 1].get("event_id") or index - 1)) - theorems.append( - f"theorem {step_theorem} : " - f"TraceSafe {trace_expr} :=\n" - f" safe_extension_preserves_trace_safe {prev_trace} {event_name} " - f"concrete_compositional_extension_{prev_event} " - f"concrete_event_safe_{event_name}" - ) - compositional_props.append(f"TraceSafe {trace_expr}") - compositional_refs.append(step_theorem) + prefix_proof = f"concrete_compositional_extension_{prev_event}" + # Package CompositionalSafeExtension via the A6 yield lemma. + # eventSafeD → EventSafe via soundness; stepState equality is Applies. + theorems.append( + f"theorem {step_theorem} :\n" + f" TraceSafe {trace_expr} :=\n" + f" compositional_safe_extension_yields_safe_extended_trace " + f"({prev_trace}) {event_name} {pre_state} {post_state} ⟨\n" + f" {prefix_proof},\n" + f" (eventSafeD_sound {event_name}).mp concrete_event_safe_{event_name},\n" + f" {applies_name},\n" + f" (frameValidD_sound {pre_state}).mp {frame_valid_pre},\n" + f" (frameValidD_sound {post_state}).mp {frame_after}⟩" + ) + compositional_props.extend( + [ + f"stepState {pre_state} {event_name} = some {post_state}", + f"frameValidD {post_state} = true", + f"TraceSafe {trace_expr}", + ] + ) + compositional_refs.extend([applies_name, frame_after, step_theorem]) + pre_state = post_state + frame_valid_pre = frame_after + + # Optional handoff / contract composition when PR2/PR3 resolved evidence exists. + for handoff_theorem in handoff_theorems: + parsed = _parse_theorem_signature(handoff_theorem) + if parsed is None: + continue + theorem_name, prop_type = parsed + compositional_props.append(prop_type) + compositional_refs.append(theorem_name) + for contract_theorem in contract_theorems: + parsed = _parse_theorem_signature(contract_theorem) + if parsed is None: + continue + theorem_name, prop_type = parsed + compositional_props.append(prop_type) + compositional_refs.append(theorem_name) + register_theorem_name(inventory, "concrete_compositional_extension") theorems.append( lean_and_intro_theorem( @@ -1080,6 +1398,90 @@ def generate_trust_boundary_theorems( ] +def _adopt_theorem_ir( + ctx: Any, + lean_text: str, + *, + category: str, + generation_node: str, + evidence_artifact_ids: Sequence[str] | tuple[str, ...] = (), + certificate_mode_role: str = "supporting", +) -> str: + """Record a Lean theorem into shared IR. Non-theorem fragments (defs) pass through.""" + from pcs_core.pf_core_theorem_manifest import TheoremSpec, parse_theorem_signature + + parsed = parse_theorem_signature(lean_text) + if parsed is None: + return lean_text + name, prop = parsed + if name not in ctx.inventory: + ctx.register_name(name) + if any(spec.name == name for spec in ctx.specs): + return lean_text + ctx.specs.append( + TheoremSpec( + name=name, + normalized_proposition=prop, + category=category, + generation_node=generation_node, + evidence_artifact_ids=tuple(evidence_artifact_ids), + certificate_mode_role=certificate_mode_role, + lean_text=lean_text, + ) + ) + return lean_text + + +def _classify_mode_theorem_name(name: str, mode: str) -> tuple[str, str]: + """Return (category, certificate_mode_role) for a mode-generated theorem name.""" + if name == "concrete_certificate_mode_witness": + return "mode_witness", "final_witness" + aggregates = { + "concrete_handoff_safe", + "concrete_contract_checked", + "concrete_action_effects_in_frame", + "frame_preserved_steps", + "concrete_compositional_extension", + } + if name in aggregates: + return "mode_aggregate", "aggregate" + if name.startswith("concrete_compositional_extension_"): + return "compositional", "required" + if name.startswith( + ( + "compositional_step_applies_", + "compositional_frame_valid_", + ) + ): + return "compositional", "required" + if name == "compositional_frame_valid_initial": + return "compositional", "required" + if name.startswith("concrete_action_effects_in_frame_"): + return "effect_frame", "required" + if name.startswith( + ( + "step_state_applies_", + "frame_valid_", + "deny_identity_", + "resource_frame_update_", + "active_principal_update_", + "tenant_update_", + "capability_frame_update_", + ) + ): + return "transition", "required" + if name.startswith("concrete_trace_safe_r"): + return "trace_safety", "required" + mode_defaults = { + "EffectFrameCertificate": ("effect_frame", "required"), + "FramePreservedCertificate": ("transition", "required"), + "HandoffSafeCertificate": ("handoff_safety", "required"), + "ContractCheckedCertificate": ("contract", "required"), + "CompositionalExtensionCertificate": ("compositional", "required"), + } + return mode_defaults.get(mode, ("mode_aggregate", "required")) + + def generate_proof_obligation_file( trace: Mapping[str, Any], out_dir: Path, @@ -1087,20 +1489,31 @@ def generate_proof_obligation_file( trace_path: Path | None = None, certificate_mode: str | None = None, release_grade: bool = False, + resolved_evidence: Any | None = None, ) -> GeneratedLeanProof: """Write a `.lean` file proving concrete trace/event (and optional handoff) safety. - Lean terms are emitted from a hashed semantic projection of Lean-relevant fields - (not the full PFCoreTrace.v0 envelope). Returns a :class:`GeneratedLeanProof` whose - ``theorem_names`` inventory is built at construction time (not by regex-scanning the - emitted source as the primary mechanism). + Lean terms and ``PFCoreTheoremManifest.v0`` are produced from the same structured + theorem IR collected during construction. """ + from pcs_core.pf_core_resolved_evidence import ( + EvidenceResolutionError, + assert_handoff_capability_fidelity, + resolve_pf_core_evidence, + ) from pcs_core.pf_core_semantic_projection import ( build_semantic_projection, + extract_lean_delegated_capability_sequences, + projection_contract_ids, projection_contracts, projection_handoffs, projection_to_codegen_trace, ) + from pcs_core.pf_core_theorem_manifest import ( + TheoremBuildContext, + build_theorem_manifest, + write_theorem_manifest, + ) mode = resolve_certificate_mode( trace, @@ -1111,14 +1524,26 @@ def generate_proof_obligation_file( if release_grade and is_tool_use_trace(trace, trace_path=trace_path): mode = TOOL_USE_DEFAULT_CERTIFICATE_MODE - handoffs = collect_handoffs_near_trace(trace, trace_path=trace_path) - contracts = collect_contracts_for_trace(trace, trace_path=trace_path) + if resolved_evidence is None: + if trace_path is None: + raise CertificateModeEvidenceMissing( + "generate_proof_obligation_file requires trace_path or resolved_evidence" + ) + try: + resolved_evidence = resolve_pf_core_evidence( + trace, + trace_path=trace_path, + certificate_mode=mode, + ) + except EvidenceResolutionError as exc: + raise CertificateModeEvidenceMissing(str(exc)) from exc + + handoffs = resolved_evidence.handoff_artifacts projection = build_semantic_projection( trace, certificate_mode=mode, trace_path=trace_path, - handoffs=handoffs, - contracts=contracts, + resolved_evidence=resolved_evidence, ) projection_hash = str(projection["projection_hash"]) codegen_trace = projection_to_codegen_trace(projection) @@ -1132,10 +1557,15 @@ def generate_proof_obligation_file( out_path = out_dir / f"{module}.lean" events = trace_events(codegen_trace) - inventory: set[str] = set() + ctx = TheoremBuildContext() + inventory = ctx.inventory evidence_files: list[Path] = [] if trace_path is not None: evidence_files.append(trace_path) + evidence_files.extend(resolved_evidence.handoff_paths) + evidence_files.extend(resolved_evidence.contract_paths) + if resolved_evidence.effect_frame_path is not None: + evidence_files.append(resolved_evidence.effect_frame_path) trace_body = trace_to_lean(codegen_trace) trace_id = str(codegen_trace.get("trace_id") or "trace") @@ -1143,11 +1573,19 @@ def generate_proof_obligation_file( event_theorem_parts: list[str] = [] for index, event in enumerate(events): - event_name = lean_ident("ev", str(event.get("event_id") or index)) + event_id = str(event.get("event_id") or index) + event_name = lean_ident("ev", event_id) theorem_name = f"concrete_event_safe_{event_name}" - register_theorem_name(inventory, theorem_name) + lean = f"theorem {theorem_name} : eventSafeD {event_name} = true := by\n decide" event_theorem_parts.append( - f"theorem {theorem_name} : eventSafeD {event_name} = true := by\n decide" + _adopt_theorem_ir( + ctx, + lean, + category="event_safety", + generation_node=f"codegen.event.{event_id}.safe", + evidence_artifact_ids=(event_id,), + certificate_mode_role="supporting", + ) ) event_theorem_block = ("\n".join(event_theorem_parts) + "\n\n") if event_theorem_parts else "" @@ -1159,12 +1597,17 @@ def generate_proof_obligation_file( handoff_name = lean_ident("handoff", handoff_id) handoff_defs.append(handoff_to_lean(handoff, name=handoff_name)) theorem_name = f"concrete_handoff_safe_{handoff_name}" - register_theorem_name(inventory, theorem_name) + lean = f"theorem {theorem_name} : handoffSafeD {handoff_name} = true := by\n decide" handoff_theorems.append( - f"theorem {theorem_name} : handoffSafeD {handoff_name} = true := by\n decide" + _adopt_theorem_ir( + ctx, + lean, + category="handoff_safety", + generation_node=f"codegen.handoff.{handoff_id}.safe", + evidence_artifact_ids=(handoff_id,), + certificate_mode_role="required", + ) ) - if trace_path is not None: - evidence_files.append(trace_path.parent) handoff_block = "" if handoff_defs: @@ -1172,8 +1615,18 @@ def generate_proof_obligation_file( handoff_block += "\n\n".join(handoff_theorems) + "\n\n" projected_contracts = projection_contracts(projection) + source_contracts = resolved_evidence.contracts_by_id + if projected_contracts or source_contracts: + from pcs_core.pf_core_resolved_evidence import assert_contract_projection_ids + + assert_contract_projection_ids( + selected_contract_ids=resolved_evidence.selected_contract_ids, + projected_contract_ids=projection_contract_ids(projection), + ) + # Codegen binds Lean obligations from resolved source contracts (flat + # semantics_layer map), not the projection's materialized field records. contract_defs, contract_theorems = generate_contract_proof_obligations( - codegen_trace, projected_contracts, inventory=inventory + codegen_trace, source_contracts, inventory=inventory, ctx=ctx ) contract_def_block = "" contract_theorem_block = "" @@ -1195,16 +1648,36 @@ def generate_proof_obligation_file( mode, events=events, handoffs=list(projected_handoffs), - contracts=projected_contracts, + contracts=source_contracts, contract_theorems=contract_theorems, + effect_frame=resolved_evidence.effect_frame, ) - for name in ( - "concrete_trace_safe", - "concrete_trace_safe_prop", - "concrete_allowed_events_allowed", + base_trace_safe = f"theorem concrete_trace_safe : traceSafeD {trace_var} = true := by\n decide" + base_trace_safe_prop = ( + f"theorem concrete_trace_safe_prop : TraceSafe {trace_var} :=\n" + f" (traceSafeD_sound {trace_var}).mp concrete_trace_safe" + ) + base_allowed = ( + f"theorem concrete_allowed_events_allowed :\n" + f" ∀ ev, EventIn ev {trace_var} → ev.decision = Decision.allow →\n" + f" ActionAllowed ev.principal ev.action :=\n" + f" fun ev hIn hAllow =>\n" + f" every_allowed_event_in_safe_trace_is_allowed {trace_var} ev " + f"concrete_trace_safe_prop hIn hAllow" + ) + for lean, node in ( + (base_trace_safe, "codegen.trace_safety.concrete_trace_safe"), + (base_trace_safe_prop, "codegen.trace_safety.concrete_trace_safe_prop"), + (base_allowed, "codegen.trace_safety.concrete_allowed_events_allowed"), ): - register_theorem_name(inventory, name) + _adopt_theorem_ir( + ctx, + lean, + category="trace_safety", + generation_node=node, + certificate_mode_role="required", + ) mode_theorems, aggregate_prop, aggregate_proof = generate_mode_proof_theorems( codegen_trace, @@ -1215,24 +1688,79 @@ def generate_proof_obligation_file( contract_theorems=contract_theorems, inventory=inventory, trace_path=trace_path, + effect_frame=resolved_evidence.effect_frame, ) + adopted_mode: list[str] = [] + for fragment in mode_theorems: + parsed = _parse_theorem_signature(fragment) + if parsed is None: + adopted_mode.append(fragment) + continue + name, _prop = parsed + category, role = _classify_mode_theorem_name(name, mode) + frame_id = "" + if resolved_evidence.effect_frame is not None: + frame_id = str(resolved_evidence.effect_frame.get("frame_id") or "") + evidence_ids: tuple[str, ...] = () + if frame_id and category == "effect_frame": + evidence_ids = (frame_id,) + adopted_mode.append( + _adopt_theorem_ir( + ctx, + fragment, + category=category, + generation_node=f"codegen.mode.{mode}.{name}", + evidence_artifact_ids=evidence_ids, + certificate_mode_role=role, + ) + ) mode_theorem_block = "" - if mode_theorems: - mode_theorem_block = "\n\n".join(mode_theorems) + "\n\n" + if adopted_mode: + mode_theorem_block = "\n\n".join(adopted_mode) + "\n\n" trust_boundary_theorems = generate_trust_boundary_theorems( events, trace_var=trace_var, inventory=inventory ) - trust_boundary_block = "\n\n".join(trust_boundary_theorems) + "\n\n" + adopted_trust: list[str] = [] + for lean in trust_boundary_theorems: + parsed = _parse_theorem_signature(lean) + name = parsed[0] if parsed else "trust" + adopted_trust.append( + _adopt_theorem_ir( + ctx, + lean, + category="trust_boundary", + generation_node=f"codegen.trust_boundary.{name}", + certificate_mode_role="supporting", + ) + ) + trust_boundary_block = "\n\n".join(adopted_trust) + "\n\n" resource_scope_theorems = ( generate_resource_scope_theorems(events, inventory=inventory) if mode == "TraceSafeRCertificate" else [] ) + adopted_resource: list[str] = [] + for index, lean in enumerate(resource_scope_theorems): + event = events[index] if index < len(events) else {} + event_id = str(event.get("event_id") or index) if isinstance(event, dict) else str(index) + # Resource theorems are only for allow events; match by parsed name. + parsed = _parse_theorem_signature(lean) + node_name = parsed[0] if parsed else f"resource_{index}" + adopted_resource.append( + _adopt_theorem_ir( + ctx, + lean, + category="resource_scope", + generation_node=f"codegen.resource_scope.{node_name}", + evidence_artifact_ids=(event_id,), + certificate_mode_role="required", + ) + ) resource_scope_block = "" - if resource_scope_theorems: - resource_scope_block = "\n\n".join(resource_scope_theorems) + "\n\n" + if adopted_resource: + resource_scope_block = "\n\n".join(adopted_resource) + "\n\n" witness_prop, witness_proof = mode_witness_proposition_and_proof( mode, @@ -1240,14 +1768,38 @@ def generate_proof_obligation_file( aggregate_prop=aggregate_prop, aggregate_proof=aggregate_proof, ) - register_theorem_name(inventory, "concrete_certificate_mode_witness") + witness_lean = ( + f"theorem concrete_certificate_mode_witness :\n" + f" SelectedCertificateModePredicate :=\n" + f" {witness_proof}" + ) + # Witness proposition is the SelectedCertificateModePredicate alias body. + _adopt_theorem_ir( + ctx, + f"theorem concrete_certificate_mode_witness : {witness_prop} := {witness_proof}", + category="mode_witness", + generation_node="codegen.witness.concrete_certificate_mode_witness", + certificate_mode_role="final_witness", + ) + # Keep lean_text as the module form for emit consistency in the IR entry. + if ctx.specs and ctx.specs[-1].name == "concrete_certificate_mode_witness": + from pcs_core.pf_core_theorem_manifest import TheoremSpec + + last = ctx.specs[-1] + ctx.specs[-1] = TheoremSpec( + name=last.name, + normalized_proposition=last.normalized_proposition, + category=last.category, + generation_node=last.generation_node, + evidence_artifact_ids=last.evidence_artifact_ids, + certificate_mode_role=last.certificate_mode_role, + lean_text=witness_lean, + ) witness_block = ( f"/-- Final certificate-mode witness for `{mode}`. -/\n" f"def SelectedCertificateModePredicate : Prop :=\n" f" {witness_prop}\n\n" - f"theorem concrete_certificate_mode_witness :\n" - f" SelectedCertificateModePredicate :=\n" - f" {witness_proof}\n\n" + f"{witness_lean}\n\n" ) required = certificate_mode_obligations(mode, events) @@ -1258,13 +1810,48 @@ def generate_proof_obligation_file( f"{sorted(missing)}" ) + effect_frame_import = "import PFCore.EffectFrame\n" if mode == "EffectFrameCertificate" else "" + transition_import = ( + "import PFCore.Transition\n" + if mode in {"FramePreservedCertificate", "CompositionalExtensionCertificate"} + else "" + ) + compositional_import = ( + "import PFCore.Compositional\n" + if mode == "CompositionalExtensionCertificate" + else "" + ) + effect_frame_doc = ( + "EffectFrameCertificate binds `actionEffectsInFrameD` against the independent " + "`concreteDeclaredFrame` (v0: one global frame).\n" + if mode == "EffectFrameCertificate" + else "" + ) + transition_doc = ( + "FramePreservedCertificate proves `stepState pre event = some post` for allows, " + "deny identity transitions, frame validity at every post-state, and " + "resource/active-principal/tenant/capability-frame update equalities " + "(no `applyEvent` fallback).\n" + if mode == "FramePreservedCertificate" + else "" + ) + compositional_doc = ( + "CompositionalExtensionCertificate (A6) proves `CompositionalSafeExtension`: " + "safe prefix + EventSafe extension + successful `stepState` application + " + "preserved FrameValid resource/capability frames => TraceSafe extended trace. " + "Prefix-only TraceSafe chaining is the narrower `TracePrefixSafe` claim " + "(experimental alias TracePrefixSafeCertificate); handoff/contract composition " + "is included only when resolved evidence supplies those theorems.\n" + if mode == "CompositionalExtensionCertificate" + else "" + ) source = f"""import PFCore.Theorems import PFCore.TraceCheck import PFCore.State import PFCore.NonInterference import PFCore.Observational import PFCore.ResourcePattern - +{effect_frame_import}{transition_import}{compositional_import} /-! # Generated concrete trace proof for `{trace_id}` @@ -1276,7 +1863,7 @@ def generate_proof_obligation_file( are discharged via proved links from `TraceSafe`. `TraceSafeRCertificate` additionally discharges `concrete_trace_safe_r*` and per-event `concrete_action_resource_scope_*`. Base `TraceSafe` / `ActionAdmissible` omit pattern discharge; `TraceSafeR` refines them. - +{effect_frame_doc}{transition_doc}{compositional_doc} Release-grade tool-use lean-check treats `TraceSafeRCertificate` as the sole supported `LeanKernelChecked` path (refinement to base `TraceSafe` via `traceSafeR_implies_traceSafe`). -/ @@ -1285,21 +1872,38 @@ def generate_proof_obligation_file( {trace_body} -{contract_def_block}{handoff_block}theorem concrete_trace_safe : traceSafeD {trace_var} = true := by - decide +{contract_def_block}{handoff_block}{base_trace_safe} -theorem concrete_trace_safe_prop : TraceSafe {trace_var} := - (traceSafeD_sound {trace_var}).mp concrete_trace_safe +{base_trace_safe_prop} -theorem concrete_allowed_events_allowed : - ∀ ev, EventIn ev {trace_var} → ev.decision = Decision.allow → - ActionAllowed ev.principal ev.action := - fun ev hIn hAllow => - every_allowed_event_in_safe_trace_is_allowed {trace_var} ev concrete_trace_safe_prop hIn hAllow +{base_allowed} {event_theorem_block}{trust_boundary_block}{resource_scope_block}{contract_theorem_block}{mode_theorem_block}{witness_block}end PFCore.Generated.{module} """ + if projected_handoffs: + lean_sequences = extract_lean_delegated_capability_sequences(source) + assert_handoff_capability_fidelity( + source_handoffs=handoffs, + projected_handoffs=projected_handoffs, + lean_capability_sequences=lean_sequences, + ) out_path.write_text(source, encoding="utf-8") + proof_file_hash = f"sha256:{hashlib.sha256(out_path.read_bytes()).hexdigest()}" + theorem_manifest = build_theorem_manifest( + specs=ctx.specs, + generated_module_name=module, + proof_file_hash=proof_file_hash, + semantic_projection_hash=projection_hash, + certificate_mode=mode, + final_witness_theorem="concrete_certificate_mode_witness", + final_witness_proposition=witness_prop, + ) + theorem_manifest_hash = str(theorem_manifest["theorem_manifest_digest"]) + manifest_path = out_dir / "PFCoreTheoremManifest.v0.json" + write_theorem_manifest(theorem_manifest, manifest_path) + # Also persist the projection next to the generated proof for replay/mutation tests. + projection_path = out_dir / "PFCoreSemanticProjection.v0.json" + projection_path.write_text(json.dumps(projection, indent=2), encoding="utf-8") # Deduplicate evidence paths while preserving order. seen_evidence: set[Path] = set() evidence_tuple: list[Path] = [] @@ -1319,6 +1923,10 @@ def generate_proof_obligation_file( mode_witness_proposition=witness_prop, semantic_projection_hash=projection_hash, semantic_projection=projection, + theorem_specs=tuple(ctx.specs), + theorem_manifest=theorem_manifest, + theorem_manifest_hash=theorem_manifest_hash, + theorem_manifest_path=manifest_path, ) @@ -1327,13 +1935,17 @@ def validate_contracts_before_codegen( *, trace_path: Path | None = None, contracts_dir: Path | None = None, + resolved_evidence: Any | None = None, ) -> list[str]: """Return contract validation errors (empty when satisfied or no contract JSON).""" if not trace_has_contract_refs(trace): return [] - contracts = collect_contracts_for_trace( - trace, trace_path=trace_path, contracts_dir=contracts_dir - ) + if resolved_evidence is not None: + contracts = resolved_evidence.contracts_by_id + else: + contracts = collect_contracts_for_trace( + trace, trace_path=trace_path, contracts_dir=contracts_dir + ) if not contracts: return [] issues = validate_trace_contracts(trace, contracts) @@ -1345,7 +1957,10 @@ def validate_contracts_before_codegen( def pfcore_kernel_lean_paths() -> list[Path]: """Sorted PF-Core kernel Lean sources (excludes Generated/).""" - pfcore_dir = repo_root() / "lean" / "PFCore" + try: + pfcore_dir = pf_core_kernel_root() + except FileNotFoundError: + return [] if not pfcore_dir.is_dir(): return [] paths = sorted(pfcore_dir.rglob("*.lean")) @@ -1354,7 +1969,10 @@ def pfcore_kernel_lean_paths() -> list[Path]: def pcs_kernel_lean_paths() -> list[Path]: """Sorted PCS Lean sources for PCS release-chain proof paths (excludes Generated/).""" - pcs_dir = repo_root() / "lean" / "PCS" + try: + pcs_dir = pcs_kernel_root() + except FileNotFoundError: + return [] if not pcs_dir.is_dir(): return [] paths = sorted(pcs_dir.rglob("*.lean")) @@ -1374,13 +1992,16 @@ def compute_pfcore_kernel_hash() -> str: def compute_lean_environment_hash(*, include_pcs: bool = False) -> str: """Hash Lean toolchain, lake project files, and PF-Core kernel Lean sources.""" - lean_root = repo_root() / "lean" + try: + lean_project = require_lean_root() + except FileNotFoundError: + lean_project = resolve_lean_root() or Path() parts: list[bytes] = [] - toolchain = lean_root / "lean-toolchain" + toolchain = lean_project / "lean-toolchain" if toolchain.is_file(): parts.append(toolchain.read_bytes()) for rel in ("lakefile.lean", "lake-manifest.json"): - path = lean_root / rel + path = lean_project / rel if path.is_file(): parts.append(path.read_bytes()) for path in pfcore_kernel_lean_paths(): @@ -1431,8 +2052,4 @@ def compute_lean_environment_hash_from_bundle( def proof_term_ref_from_path(path: Path) -> str: - root = repo_root() - try: - return str(path.relative_to(root)).replace("\\", "/") - except ValueError: - return str(path).replace("\\", "/") + return proof_ref_from_path(path) diff --git a/python/tests/test_pf_core_certificate_mode_codegen.py b/python/tests/test_pf_core_certificate_mode_codegen.py index 0d9f4da..8d3c326 100644 --- a/python/tests/test_pf_core_certificate_mode_codegen.py +++ b/python/tests/test_pf_core_certificate_mode_codegen.py @@ -27,11 +27,16 @@ def _load(path: Path) -> dict: return __import__("json").loads(path.read_text(encoding="utf-8")) +EFFECT_FRAME_TRACE = ( + REPO / "examples" / "pf-core-valid" / "certificate_mode_effectframecertificate" / "trace.json" +) + + @pytest.mark.parametrize( "mode,trace_path", [ ("FramePreservedCertificate", FILE_READ), - ("EffectFrameCertificate", FILE_READ), + ("EffectFrameCertificate", EFFECT_FRAME_TRACE), ("HandoffSafeCertificate", VALID_TRACE), ("CompositionalExtensionCertificate", FILE_READ), ("ContractCheckedCertificate", CONTRACT_TRACE), @@ -50,8 +55,17 @@ def test_certificate_mode_codegen_has_no_trivial_aggregates( if mode == "HandoffSafeCertificate": handoff = _load(HANDOFF_FIXTURE) (work / "handoff.json").write_text(json.dumps(handoff), encoding="utf-8") + # Explicit handoff binding (PR2); sibling auto-scan is no longer accepted. + trace = dict(trace) + trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "handoff_ids": [str(handoff["handoff_id"])], + } + trace_file.write_text(json.dumps(trace), encoding="utf-8") if mode == "ContractCheckedCertificate": # Keep sibling contract JSON resolvable beside the source fixture. + # Explicit contract binding (PR3); sibling auto-scan is not accepted. codegen_trace_path = CONTRACT_TRACE for path in CONTRACT_TRACE.parent.glob("*.json"): if path.name == CONTRACT_TRACE.name: @@ -59,8 +73,30 @@ def test_certificate_mode_codegen_has_no_trivial_aggregates( target = work / path.name if not target.exists(): target.write_text(path.read_text(encoding="utf-8"), encoding="utf-8") + trace = dict(trace) + if "evidence_selection" not in trace: + contract_ids: list[str] = [] + for event in trace.get("events") or []: + if isinstance(event, dict): + refs = event.get("contract_refs") + if isinstance(refs, list): + contract_ids.extend(str(ref) for ref in refs if str(ref)) + trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "contract_ids": sorted(set(contract_ids)), + } codegen_trace_path = work / CONTRACT_TRACE.name codegen_trace_path.write_text(json.dumps(trace), encoding="utf-8") + if mode == "EffectFrameCertificate": + for path in EFFECT_FRAME_TRACE.parent.glob("*.json"): + if path.name == EFFECT_FRAME_TRACE.name: + continue + target = work / path.name + if not target.exists(): + target.write_text(path.read_text(encoding="utf-8"), encoding="utf-8") + codegen_trace_path = work / "trace.json" + codegen_trace_path.write_text(json.dumps(trace), encoding="utf-8") generated = generate_proof_obligation_file( trace, work / "out", @@ -72,6 +108,17 @@ def test_certificate_mode_codegen_has_no_trivial_aggregates( assert TRIVIAL_RE.search(text) is None, f"{mode} still emits trivial aggregate" assert f"Certificate mode: `{mode}`" in text assert "concrete_certificate_mode_witness" in generated.theorem_names + if mode == "EffectFrameCertificate": + assert "concreteDeclaredFrame" in text + assert ".effects = true" not in text + assert "actionEffectsInFrameD" in text + assert "concreteDeclaredFrame = true" in text + if mode == "CompositionalExtensionCertificate": + assert "compositional_safe_extension_yields_safe_extended_trace" in text + assert "compositional_frame_valid_initial" in text + assert "stepState" in text + assert "CompositionalSafeExtension" in text or "compositional_safe_extension" in text + assert "compositionalState_0" in text def test_tool_use_default_certificate_mode_is_trace_safe_r() -> None: @@ -172,11 +219,13 @@ def test_handoff_mode_without_handoff_fails(tmp_path: Path) -> None: from pcs_core.pf_core_lean_codegen import CertificateModeEvidenceMissing trace = _load(FILE_READ) + trace_file = tmp_path / "trace.json" + trace_file.write_text(json.dumps(trace), encoding="utf-8") with pytest.raises(CertificateModeEvidenceMissing, match="handoff"): generate_proof_obligation_file( trace, tmp_path, - trace_path=tmp_path / "trace.json", + trace_path=trace_file, certificate_mode="HandoffSafeCertificate", ) diff --git a/python/tests/test_pf_core_compositional.py b/python/tests/test_pf_core_compositional.py index c0afa5d..10e62a8 100644 --- a/python/tests/test_pf_core_compositional.py +++ b/python/tests/test_pf_core_compositional.py @@ -30,6 +30,8 @@ "contract_invariant_preserved_by_safe_extension", "handoff_composition_does_not_expand_authority", "composed_contract_preserves_component_invariants", + "compositional_safe_extension_yields_safe_extended_trace", + "trace_prefix_safe_extension", } ) @@ -65,7 +67,10 @@ def test_lean_catalog_includes_compositional_and_role_map() -> None: def test_compositional_lean_sources_exist() -> None: - assert (REPO / "lean" / "PFCore" / "Compositional.lean").is_file() + compositional = (REPO / "lean" / "PFCore" / "Compositional.lean").read_text(encoding="utf-8") + assert "def CompositionalSafeExtension" in compositional + assert "abbrev TracePrefixSafe" in compositional + assert "theorem compositional_safe_extension_yields_safe_extended_trace" in compositional assert (REPO / "lean" / "PFCore" / "RoleMap.lean").is_file() diff --git a/python/tests/test_pf_core_phase4_differential.py b/python/tests/test_pf_core_phase4_differential.py index e5da8ff..d2f6df2 100644 --- a/python/tests/test_pf_core_phase4_differential.py +++ b/python/tests/test_pf_core_phase4_differential.py @@ -58,6 +58,16 @@ def test_generated_lean_theorem_result_for_valid_trace(tmp_path: Path) -> None: assert generated.semantic_projection_hash.startswith("sha256:") +def test_a11_base_vs_refined_resource_pattern_differential() -> None: + """A11: out-of-pattern URI → TraceSafe true, TraceSafeR false.""" + from pcs_core.lean_check import trace_safe_d, trace_safe_rd + + path = INVALID_EXAMPLES / "resource_scope_violation" / "trace.json" + events = json.loads(path.read_text(encoding="utf-8"))["events"] + assert trace_safe_d(events) is True + assert trace_safe_rd(events) is False + + def test_lean_json_decoder_deferred_documented() -> None: assert "deferred" in LEAN_JSON_DECODER_STATUS docs = (REPO / "docs" / "pf-core" / "semantic-projection.md").read_text(encoding="utf-8") diff --git a/python/tests/test_pf_core_phase5.py b/python/tests/test_pf_core_phase5.py index 7a7fb1f..41457fb 100644 --- a/python/tests/test_pf_core_phase5.py +++ b/python/tests/test_pf_core_phase5.py @@ -55,12 +55,21 @@ def test_observed_effect_structure_present() -> None: text = _lean_source("ObservedEffect.lean") for name in ( "structure ObservedEffect", + "def ObservationSoundness", + "def ObservationCompleteness", + "def EffectAttribution", + "def InstrumentationAuthenticity", + "def AttestedExecution", "def TrustedInstrumentation", "theorem observed_sensitive_effects_in_frame", "theorem accepted_transition_no_undeclared_sensitive_observation", + "theorem observation_soundness_not_trusted_without_authenticity", "TrustedInstrumentation", ): assert name in text, f"missing {name}" + # TrustedInstrumentation must not be definitionally mere ObservationsAgree. + assert "def TrustedInstrumentation (a : Action) (obs : List ObservedEffect) : Prop :=\n ObservationsAgree" not in text + assert "AttestedExecution ctx" in text or "AttestedExecution" in text def test_deny_closed_refinement_present() -> None: @@ -75,6 +84,8 @@ def test_deny_closed_refinement_present() -> None: "theorem traceSafeDenyClosed_implies_traceSafe", ): assert name in text, f"missing {name}" + assert "DenyClosedCertificate" in text + assert "disabled" in text.lower() or "scaffolded" in text.lower() def test_tenant_projection_isolation_naming() -> None: @@ -86,6 +97,8 @@ def test_tenant_projection_isolation_naming() -> None: assert "def PairedExecutionNonInterference" in paired assert "not proved" in paired.lower() or "unproved" in paired.lower() assert "Research scaffolding" in paired or "research scaffolding" in paired.lower() + assert "PairedRun" in paired + assert "formal predicate" in paired.lower() or "Claim boundary" in paired def test_runtime_semantics_doc() -> None: @@ -96,9 +109,13 @@ def test_runtime_semantics_doc() -> None: "EventSafeDenyClosed", "PairedExecutionNonInterference", "instrumentation", + "ObservationSoundness", + "AttestedExecution", + "DenyClosedCertificate", ): assert phrase in doc, f"missing {phrase!r} in runtime-semantics.md" assert "not proved" in doc.lower() or "unproved" in doc.lower() + assert "ObservationsAgree" in doc def test_non_interference_doc_prefers_tenant_projection_isolation() -> None: diff --git a/python/tests/test_pf_core_phase_d.py b/python/tests/test_pf_core_phase_d.py index b37ba9f..c6a01f9 100644 --- a/python/tests/test_pf_core_phase_d.py +++ b/python/tests/test_pf_core_phase_d.py @@ -43,10 +43,21 @@ def test_generated_proof_includes_per_event_theorems(tmp_path: Path) -> None: def test_generated_proof_documents_contract_refs_when_missing(tmp_path: Path) -> None: - trace = _load(CONTRACT_TRACE) + trace = dict(_load(CONTRACT_TRACE)) assert trace_has_contract_refs(trace) - # No contract JSON alongside trace -> documents gap - generated = generate_proof_obligation_file(trace, tmp_path) + # Non-ContractChecked path: contract refs present, no sibling contract JSON. + trace.pop("required_certificate_mode", None) + trace.pop("evidence_selection", None) + work = tmp_path / "case" + work.mkdir() + trace_path = work / "trace.json" + trace_path.write_text(json.dumps(trace), encoding="utf-8") + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="TraceSafeCertificate", + ) proof_path = generated.path text = proof_path.read_text(encoding="utf-8") assert "validate-contracts" in text @@ -54,7 +65,16 @@ def test_generated_proof_documents_contract_refs_when_missing(tmp_path: Path) -> def test_generated_proof_discharges_contracts_with_json(tmp_path: Path) -> None: trace = _load(CONTRACT_TRACE) - generated = generate_proof_obligation_file(trace, tmp_path, trace_path=CONTRACT_TRACE) + work = tmp_path / "case" + work.mkdir() + for path in CONTRACT_TRACE.parent.glob("*.json"): + (work / path.name).write_text(path.read_text(encoding="utf-8"), encoding="utf-8") + generated = generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=work / CONTRACT_TRACE.name, + certificate_mode="ContractCheckedCertificate", + ) proof_path = generated.path text = proof_path.read_text(encoding="utf-8") assert "concrete_trace_satisfies_contract_" in text @@ -62,7 +82,12 @@ def test_generated_proof_discharges_contracts_with_json(tmp_path: Path) -> None: def test_generated_proof_includes_handoff_when_present(tmp_path: Path) -> None: handoff = _load(HANDOFF_FIXTURE) - trace = _load(VALID_TRACE) + trace = dict(_load(VALID_TRACE)) + trace["evidence_selection"] = { + "policy": "explicit_ids", + "policy_version": "v0", + "handoff_ids": [str(handoff["handoff_id"])], + } trace_file = tmp_path / "pfcore_trace.json" trace_file.write_text(json.dumps(trace), encoding="utf-8") (tmp_path / "handoff.json").write_text(json.dumps(handoff), encoding="utf-8") @@ -71,6 +96,7 @@ def test_generated_proof_includes_handoff_when_present(tmp_path: Path) -> None: text = proof_path.read_text(encoding="utf-8") assert "handoffSafeD" in text assert "theorem concrete_handoff_safe_" in text + assert 'delegatedCapabilities := ["cap:handoff"]' in text @pytest.mark.skipif(not LAKE_AVAILABLE, reason="lake or WSL not available") diff --git a/python/tests/test_pf_core_research_grade.py b/python/tests/test_pf_core_research_grade.py index b5fae6a..bfc13c6 100644 --- a/python/tests/test_pf_core_research_grade.py +++ b/python/tests/test_pf_core_research_grade.py @@ -109,6 +109,9 @@ def test_contract_refinement_present() -> None: assert "def ContractRefinement" in text assert "theorem contract_refinement_preserves_trace_safe" in text assert "theorem handoff_composition_global" in text + assert "def CompositionalSafeExtension" in text + assert "abbrev TracePrefixSafe" in text + assert "theorem compositional_safe_extension_yields_safe_extended_trace" in text def test_tenant_isolation_present() -> None: diff --git a/python/tests/test_pf_core_stage4.py b/python/tests/test_pf_core_stage4.py index dd5bb3f..d9085bc 100644 --- a/python/tests/test_pf_core_stage4.py +++ b/python/tests/test_pf_core_stage4.py @@ -77,8 +77,15 @@ def test_empty_trace_codegen(tmp_path: Path) -> None: trace = _load(EMPTY_TRACE) source = trace_to_lean(trace) assert "Trace.empty" in source + trace_path = tmp_path / "empty_trace.json" + trace_path.write_text(json.dumps(trace), encoding="utf-8") with pytest.raises(CertificateModeEvidenceMissing, match="TraceSafeCertificate requires"): - generate_proof_obligation_file(trace, tmp_path, certificate_mode="TraceSafeCertificate") + generate_proof_obligation_file( + trace, + tmp_path / "out", + trace_path=trace_path, + certificate_mode="TraceSafeCertificate", + ) def test_invalid_trace_fails_before_lean_proof() -> None: @@ -136,7 +143,9 @@ def test_concrete_lean_proof_passes_for_valid_trace() -> None: from pcs_core.lean_check import pfcore_generated_dir trace = _load(VALID_TRACE) - generated = generate_proof_obligation_file(trace, pfcore_generated_dir()) + generated = generate_proof_obligation_file( + trace, pfcore_generated_dir(), trace_path=VALID_TRACE + ) proof_path = generated.path ok, detail = run_lean_concrete_proof(proof_path, skip_build=False) assert ok, detail From 7fa372625a2ff4a28968edf4f9c3c2ebead43dc8 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:02:43 -0700 Subject: [PATCH 20/24] Add operator release gates and runbook checks. Encode release checklist gates as executable checks so operators can fail closed before publishing without relying on manual doc walkthroughs alone. --- docs/pf-core/operator-release-gates.md | 159 ++++++ docs/pf-core/release-checklist.md | 76 ++- python/pcs_core/release_gates.py | 754 +++++++++++++++++++++++++ python/tests/test_release_gates.py | 321 +++++++++++ scripts/check-release-gates.py | 91 +++ scripts/release-gate.sh | 16 +- 6 files changed, 1408 insertions(+), 9 deletions(-) create mode 100644 docs/pf-core/operator-release-gates.md create mode 100644 python/pcs_core/release_gates.py create mode 100644 python/tests/test_release_gates.py create mode 100644 scripts/check-release-gates.py diff --git a/docs/pf-core/operator-release-gates.md b/docs/pf-core/operator-release-gates.md new file mode 100644 index 0000000..2e125c4 --- /dev/null +++ b/docs/pf-core/operator-release-gates.md @@ -0,0 +1,159 @@ +# Operator runbook: remaining org / infrastructure release gates + +How to close the honest remaining gates that block **stable** (`PCS_RELEASE_MODE=release`) +pcs-core releases. Preview may remain digest-only / absence-notice / gated with disclosure. + +Machine check (one command): + +```bash +# Preview (current repo should pass) +pcs release check-gates --mode preview +# or: python3 scripts/check-release-gates.py --mode preview + +# Stable (fails until org pins/keys/attestations exist — expected today) +PCS_RELEASE_MODE=release pcs release check-gates --mode release +``` + +After assemble, re-check with optional roots: + +```bash +pcs release check-gates --mode release \ + --registry /path/to/TrustedKeyRegistry.v0.json \ + --release-root dist/release-bundle \ + --provenance-dir dist/provenance +``` + +Unified local gate (`scripts/release-gate.sh`) and CI (`release.yml`, +`pf-core-release-gate.yml`) invoke the same checker first. + +Do **not** invent placeholder CertifyEdge digests or commit production private keys. + +--- + +## 1. Ed25519 keys / TrustedKeyRegistry.v0 (ArtifactIntegrity.v1) + +**Why:** Stable releases must authenticate manifests, certificates, Lean-check results, +external attestations, and publication bundles via domain-separated Ed25519 signatures +(`docs/trust-model.md`). pcs-core does not ship production private keys. + +**Close the gate** + +1. Generate an org release key pair offline (HSM or sealed secret store preferred). + Local experiment only: `PCS_RELEASE_SIGNING_SEED_B64` + `PCS_RELEASE_SIGNING_KEY_ID` + (never commit the seed). +2. Publish a `TrustedKeyRegistry.v0` allowlist of **public** keys (`key_id`, + `valid_from` / `valid_until`, `purposes` including `release_signing`). Schema: + `schemas/TrustedKeyRegistry.v0.schema.json`. +3. Distribute the registry to verifiers and CI as a pinned artifact or secret file. + Set `PCS_TRUSTED_KEY_REGISTRY` to that path. +4. At release assemble time, sign with the matching private seed / key_id + (`pcs_core.artifact_integrity.sign_artifact` / integrity sidecars). +5. Rotate by publishing a new `key_id` before retiring the old; revoke with `revoked_at`. + +**Private keys live:** org secret store / HSM / GitHub Actions encrypted secrets — +not in this repository. + +**Stable check:** `artifact_integrity_registry` fails if the registry is missing or has +no usable `release_signing` key. With `--release-root`, signatures are verified +(`allow_digest_only` only in preview). + +--- + +## 2. Real CertifyEdge production pin + +**Why:** `pins/certifyedge.json` is currently `status=unpinned`. Stable live +`CertificateChecked` attestation requires an immutable pin and `trust_grade=pinned`. + +**Close the gate** + +1. Obtain a real CertifyEdge artifact: OCI image digest, signed binary URL+sha256, or + locked `source_commit` (40-char SHA) build. Do not invent digests. +2. Update `pins/certifyedge.json`: + - `status=pinned` + - `provision_strategy` one of `oci_digest` | `signed_binary` | `source_commit_build` + - Fill the matching digest / URL / commit fields +3. Run `bash scripts/provision-certifyedge.sh` and **source** + `.tools/certifyedge/provision.env` (do not overwrite `PF_CORE_CERTIFYEDGE_CLI` with + an empty secret). +4. Confirm `PCS_CERTIFYEDGE_TRUST_GRADE=pinned`. +5. Flip external `CertificateChecked` from preview only after authenticated pin + live + attest path is green (`schemas/pf_core.certificate_mode_status.json` / + `docs/pf-core/certifyedge.md`). + +`dev_fixture` remains test/preview only (`untrusted_development`). + +**Stable check:** `certifyedge_pin` + `certifyedge_trust_grade` fail closed when unpinned +or trust grade is not `pinned`. Helpers: +`scripts/verify-certifyedge-pin.py`, `pins/README.md`. + +--- + +## 3. Sigstore / GHEC signed provenance + +**Why:** `ReleaseProvenanceBinding.v0` may finalize as `attestation.status=gated` when +GitHub artifact attestations are unavailable (private repo without GHEC, missing +OIDC/`attestations` permissions, org policy). + +**Close the gate** + +| Repo visibility | Action | +|-----------------|--------| +| Public | Ensure workflow `permissions: id-token: write` + `attestations: write`; run `release.yml` / `release-provenance.yml` | +| Private | Enable GitHub Enterprise Cloud artifact attestations (or equivalent org capability), then same permissions | + +Consumer verify: `scripts/verify-release-provenance.sh` (+ `gh attestation verify` when +`status=signed`). + +**Break-glass only:** `PCS_PROVENANCE_ALLOW_GATED=true` (repository variable / env). +Forbidden for claimed SLSA-attested stable releases. Clear the variable once signed +provenance is green on version tags. + +**Stable check:** with `--provenance-dir`, `provenance_attestation` fails on `gated` +unless allow-gated is set. CI mirrors this after finalize in `release.yml`. + +--- + +## 4. Cosign + GHCR verifier image publish + +**Why:** `docker/verifier` + `distribution.yml` build/test the image; signed GHCR publish +is still org-gated. + +**Close the gate** + +1. Build/push by digest to GHCR (see `docs/distribution.md`). +2. Attach SBOM + provenance; `cosign sign` / `cosign attest` (keyless OIDC preferred). +3. Publish digest + signature references in the GitHub Release. +4. Optionally set `PCS_VERIFIER_OCI_DIGEST=sha256:…` so `check-gates + --require-oci-publish` can machine-check presence (still confirm `cosign verify` + out of band). + +**Stable check:** `oci_cosign_publish` is advisory by default; pass +`--require-oci-publish` only when org policy mandates it. + +--- + +## 5. Certificate mode status policy + +Authoritative table: `schemas/pf_core.certificate_mode_status.json`. + +| Mode / claim | Policy | +|--------------|--------| +| `TraceSafeRCertificate` | Sole tool-use **release_candidate** | +| Specialized modes (Handoff / Contract / EffectFrame / FramePreserved) | **disabled** (`allowed_issuance=false`) until a later enablement pass | +| `CompositionalExtensionCertificate` | **experimental** (not RC) | +| External `CertificateChecked` | **preview** until authenticated CertifyEdge pin | + +Do not advertise disabled/experimental modes as stable public claims. +`certificate_mode_policy` in `check-gates` enforces TraceSafeR RC + closed disabled modes. + +--- + +## Cross-links + +- Release checklist: [release-checklist.md](release-checklist.md) +- Trust / signing: [../trust-model.md](../trust-model.md) +- Distribution / OCI: [../distribution.md](../distribution.md) +- Security / org admin: [../security-governance.md](../security-governance.md) +- CertifyEdge: [certifyedge.md](certifyedge.md), [certifyedge-ci.md](certifyedge-ci.md) +- Pins contract: [../../pins/README.md](../../pins/README.md) +- Gap audit (remaining honesty): [current-gap-audit.md](current-gap-audit.md) diff --git a/docs/pf-core/release-checklist.md b/docs/pf-core/release-checklist.md index d72d381..fad0a23 100644 --- a/docs/pf-core/release-checklist.md +++ b/docs/pf-core/release-checklist.md @@ -2,11 +2,63 @@ Pre-release verification for PF-Core in `pcs-core`. Run from repository root unless noted. -## CI gates (what they prove) +## Mandatory CI matrix (blocking jobs) + +Separate blocking jobs. Prefer requiring `CI matrix gate` / `Distribution matrix gate` in branch protection, or list each job below. + +### `.github/workflows/ci.yml` + +| Job | Proves | +|-----|--------| +| `Python full tests` | Full pytest, ruff, schemas, audits, conformance, benchmarks, catalog drift | +| `Python full-package typecheck` | `pyright pcs_core` (full package) | +| `Python branch coverage` | Branch coverage on full suite; fail-under on trust-critical modules | +| `Rust fmt/clippy/tests/fuzz-smoke` | `cargo fmt` / clippy / tests + proptest smoke (`rust/FUZZING.md`) | +| `TypeScript lint/tests/property-vectors` | `npm run lint` / `test` / `test:hash-vectors` | +| `Lean PCS build` | `lake build PCS` | +| `Lean PF-Core build` | `lake build PFCore` + lean-check + proof-binding + validate-contracts | +| `Certificate-mode end-to-end` | Mode status/codegen/resolution + handoff/contract/effect-frame/transition evidence | +| `Cross-language differential` | Python/Rust/TS parity + `pf-core-cross-language` conformance | +| `Semantic-projection replay` | PCS projection binding + PF-Core projection/TCB + bundle verify | +| `Theorem-manifest replay` | `PFCoreTheoremManifest.v0` binding / replay | +| `Scientific payload mutation` | ResultArtifact payload bytes + computation mutation fixtures | +| `Signature and key-revocation` | ArtifactIntegrity Ed25519 + pin + external attestation | +| `Preview release workflow` | lean-check → bundle → validate → absence notice → upload | +| `Stable release dry-run` | In-repo bundle dry-run + mock rejection; **live CertifyEdge gated** on pin + `secrets.PF_CORE_CERTIFYEDGE_CLI` | +| `Provenance verification` | Digest binding + consumer verify; **signed attestations gated** on org OIDC / GHEC | +| `Validate CLI contract` | Required CLI smoke (after Python tests) | +| `CI matrix gate` | Aggregator over the jobs above | +| `PF-Core adapter parity` | Adapter pin parity (`continue-on-error` off `main` only) | + +### `.github/workflows/distribution.yml` + +| Job | Proves | +|-----|--------| +| `Validator-wheel clean install` | Schema/semantic OK; Lean unavailable | +| `Verifier-wheel clean install` | Bundled Lean assets + lean-check + bundle/verify | +| `Verifier OCI Dockerfile pin` | Base digest + non-root user | +| `Verifier OCI clean execution` | `docker build` + capabilities + lean-check (`scripts/test-verifier-oci.sh`) | +| `Distribution matrix gate` | Aggregator | + +### Release / provenance workflows + +| Workflow / job | Proves | Gating | +|----------------|--------|--------| +| `release.yml` parallel quality jobs | Same matrix as CI quality lanes | Always on tag / `workflow_dispatch` | +| `release.yml` → `Preview/stable release assemble` | lean-check → bundle → attest/absence → provenance | Live CertifyEdge + signed provenance require pin/secrets/OIDC | +| `release.yml` → `Consumer provenance verify` | Clean-consumer provenance verify | Signed require when producer status=`signed` | +| `pf-core-release-gate.yml` | Live CertifyEdge (release) or preview path | `secrets.PF_CORE_CERTIFYEDGE_CLI` / pinned provision | +| `release-provenance.yml` | Standalone produce + consumer verify | Signed path gated on org attestation capability | + +### Detailed pytest / CLI mapping | Job / step | Proves | |------------|--------| | `pytest tests/test_pf_core_tier1.py` | semantics_layer, PCS envelope alias, negative vectors | +| `pytest tests/test_pf_core_handoff_evidence.py` | Handoff delegated_capabilities fidelity + explicit handoff_ids | +| `pytest tests/test_pf_core_contract_evidence.py` | Contract semantics_layer projection + ContractChecked binding | +| `pytest tests/test_pf_core_effect_frame_evidence.py` | Independent PFCoreEffectFrame.v0 + non-tautological EffectFrameCertificate | +| `pytest tests/test_pf_core_transition_evidence.py` | FramePreserved stepState witnesses + cross-tenant no-op reject | | `pytest tests/test_pf_core_cross_language.py` | Python/Rust/TS parity on shared vectors | | `pcs pf-core audit-claims` | No forbidden overclaim phrases in docs/examples | | `pcs pf-core audit-boundary` | Trusted-boundary docs and registry consistency | @@ -14,13 +66,12 @@ Pre-release verification for PF-Core in `pcs-core`. Run from repository root unl | `pcs pf-core audit-lean-no-sorry` | No `sorry` / `axiom` in `lean/PFCore/` | | `pcs examples check` | Valid/invalid PF-Core fixtures including replay and isolation | | Lean job: `lake build PFCore` | Kernel compiles; decider soundness theorems check | -| Lean job: `pcs pf-core lean-check` | Concrete trace proof + `LeanKernelChecked` path on fixture | +| Lean job: `pcs pf-core lean-check` | Concrete trace proof + `LeanKernelChecked` path on fixture; prints deterministic artifact paths | | Lean job: `validate-contracts` | Contract runtime checker on `contract_checked/` | -| `pcs pf-core bundle-release` / `validate-bundle` | Release bundle manifest with trace, certificate, proof, kernel hashes | -| `.github/workflows/pf-core-release-gate.yml` | Live CertifyEdge required (mock/stub rejected; staging stub via `PF_CORE_CERTIFYEDGE_ALLOW_STUB=1`) on main/tags | -| `python scripts/gen_pf_core_catalog.py` in CI | Generated catalog artifacts match `schemas/pf_core.catalog.json` | +| `pcs pf-core bundle-release` / `validate-bundle` / `verify-bundle` | Closed release bundle with projection, theorem manifest, evidence digests; validate=structural; verify=replay+Lean compile (required for stable) | +| Mode status table | `schemas/pf_core.certificate_mode_status.json` — public claim surface; disabled modes fail closed | +| `python scripts/gen_pf_core_catalog.py` in CI | Generated catalog artifacts match `catalog/pf_core.catalog.json` | | `scripts/pf-core-release-grade-local.{ps1,sh}` | Full local release-grade matrix: pytest sweep, catalog drift, audit-lean-no-sorry, PFCore+PCS lake, lean-check (`TraceSafeRCertificate`), bundle kernel manifest, CertifyEdge mock+stub | -| `pf-core-adapter` job on `main` | `continue-on-error: false` — adapter parity blocks main | ## Local verification matrix @@ -39,9 +90,10 @@ Run from repository root unless noted. Each row maps a release-checklist gate to | PF-Core conformance | `pcs conformance run --suite pf-core --release-grade` | suite pass (requires lake/WSL) | | Lean kernel build | `cd lean && lake build PFCore` | exit 0 | | PCS envelope kernel | `cd lean && lake build PCS` | exit 0 | -| Concrete trace proof | `pcs pf-core lean-check --trace examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json` | `LeanKernelChecked` certificate | +| Concrete trace proof | `pcs pf-core lean-check --trace examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json --result-out /tmp/lean-check.json` | `LeanKernelChecked` certificate + LeanCheckResult | | Contract runtime checker | `pcs pf-core validate-contracts examples/pf-core-valid/contract_checked/trace.json --contracts-dir examples/pf-core-valid/contract_checked` | exit 0 | -| Release bundle | `pcs pf-core bundle-release --trace ... --cert ... --out /tmp/bundle` then `pcs pf-core validate-bundle /tmp/bundle` | manifest + kernel hashes | +| Release bundle | `pcs pf-core bundle-release --trace ... --cert ... --lean-check-result ... --out /tmp/bundle` then `pcs pf-core validate-bundle /tmp/bundle` and `pcs pf-core verify-bundle /tmp/bundle` | closed manifest includes projection/theorem/evidence/lean-check hashes; verify-bundle required for stable | +| Mode status table | `pytest -q tests/test_pf_core_certificate_mode_status.py` | disabled modes fail closed | | Catalog drift | `python scripts/gen_pf_core_catalog.py && git diff --exit-code python/pcs_core/pf_core_catalog.py lean/PFCore/Catalog.lean rust/crates/pcs-core/src/pf_core_catalog.rs typescript/packages/core/src/pfCoreCatalog.ts` | no diff | | Rust PF-Core | `cd rust && cargo test pf_core -q` | all pass | | TypeScript PF-Core | `cd typescript/packages/core && npm test` | all pass | @@ -50,6 +102,7 @@ Run from repository root unless noted. Each row maps a release-checklist gate to | CertifyEdge mock | `scripts/pf-core-certifyedge-dry-run.ps1` (or `.sh`) | mock attestation | | CertifyEdge stub | `scripts/pf-core-certifyedge-stub-dry-run.ps1` (or `.sh`) | `stub://` + `checker_version` | | Full matrix | `scripts/pf-core-release-grade-local.ps1` (or `.sh`) | all steps green | +| Org/infra release gates | `pcs release check-gates --mode preview` (stable: `--mode release`) | preview pass; release fail-closed until pins/keys/attestations | ## Local full demo @@ -151,6 +204,13 @@ bash scripts/pf-core-release-grade-local.sh Complete before tagging; record date and operator in this section (local edit only). +Org/infrastructure gates (CertifyEdge pin, TrustedKeyRegistry, signed provenance, cosign OCI): +see [operator-release-gates.md](operator-release-gates.md). Stable check: + +```bash +pcs release check-gates --mode release +``` + | Gate | Command | Pass (Y/N) | Date | Notes | |------|---------|------------|------|-------| | Full release-grade matrix | `scripts/pf-core-release-grade-local.{ps1,sh}` | Y | 2026-06-29 | Windows native `lake` | diff --git a/python/pcs_core/release_gates.py b/python/pcs_core/release_gates.py new file mode 100644 index 0000000..4e7b428 --- /dev/null +++ b/python/pcs_core/release_gates.py @@ -0,0 +1,754 @@ +"""Fail-closed org/infrastructure release gates for stable vs preview. + +Stable (``PCS_RELEASE_MODE=release``) refuses: + +- Unpinned / non-production CertifyEdge pins and untrusted/dev_fixture trust grades + when live attestation is required +- Missing TrustedKeyRegistry / ArtifactIntegrity signing infrastructure +- Provenance ``attestation.status=gated`` unless ``PCS_PROVENANCE_ALLOW_GATED=true`` + (break-glass only) + +Preview may proceed with digest-only integrity, absence notices, and gated +provenance with explicit disclosure. This module does not invent production pins +or private keys. + +Operator runbook: ``docs/pf-core/operator-release-gates.md``. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Mapping, Sequence + +from pcs_core.artifact_integrity import ( + IntegrityError, + resolve_trusted_key_registry, + verify_release_root_signatures, +) +from pcs_core.certifyedge_pin import ( + classify_checker_trust, + load_certifyedge_pin, + load_provision_environment, + pin_is_production_ready, +) +from pcs_core.paths import repo_root +from pcs_core.pf_core_certificate_mode_status import ( + get_certificate_mode_status, + get_external_claim_class_status, + load_certificate_mode_status, +) + +ReleaseMode = Literal["release", "preview", "dev"] +GateSeverity = Literal["fail", "warn", "info", "pass"] +GateId = Literal[ + "certifyedge_pin", + "certifyedge_trust_grade", + "artifact_integrity_registry", + "artifact_integrity_signatures", + "provenance_attestation", + "certificate_mode_policy", + "oci_cosign_publish", +] + + +@dataclass(frozen=True) +class GateResult: + gate_id: GateId + severity: GateSeverity + ok: bool + message: str + details: tuple[str, ...] = () + + def to_dict(self) -> dict[str, Any]: + return { + "gate_id": self.gate_id, + "severity": self.severity, + "ok": self.ok, + "message": self.message, + "details": list(self.details), + } + + +@dataclass(frozen=True) +class ReleaseGateReport: + mode: ReleaseMode + results: tuple[GateResult, ...] + allow_gated_provenance: bool = False + hard_failures: tuple[GateResult, ...] = field(default_factory=tuple) + + @property + def ok(self) -> bool: + return not self.hard_failures + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": "v0", + "artifact_type": "ReleaseGateCheckReport.v0", + "release_mode": self.mode, + "ok": self.ok, + "allow_gated_provenance": self.allow_gated_provenance, + "results": [r.to_dict() for r in self.results], + "hard_failures": [r.to_dict() for r in self.hard_failures], + } + + +def resolve_release_mode(mode: str | None = None) -> ReleaseMode: + raw = (mode or os.environ.get("PCS_RELEASE_MODE") or "preview").strip().lower() + if raw in {"release", "stable"}: + return "release" + if raw in {"preview", "dev"}: + return "preview" if raw == "preview" else "dev" + raise ValueError(f"unknown release mode {raw!r}; expected release|preview|dev") + + +def provenance_allow_gated() -> bool: + return os.environ.get("PCS_PROVENANCE_ALLOW_GATED", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def _registry_has_release_signing_key(registry: Any) -> tuple[bool, list[str]]: + notes: list[str] = [] + if not registry.keys: + return False, ["TrustedKeyRegistry.keys is empty"] + matching = [k for k in registry.keys if not k.purposes or "release_signing" in k.purposes] + if not matching: + return False, [ + "no key with purpose release_signing (or empty purposes allowing all) " + "in TrustedKeyRegistry" + ] + active = [k for k in matching if k.revoked_at is None] + if not active: + return False, ["all release_signing keys are revoked"] + notes.append(f"{len(active)} release_signing key(s) present") + if registry.registry_id: + notes.append(f"registry_id={registry.registry_id}") + return True, notes + + +def check_certifyedge_pin( + *, + mode: ReleaseMode, + pin_path: Path | None = None, +) -> GateResult: + path = pin_path or (repo_root() / "pins" / "certifyedge.json") + try: + pin = load_certifyedge_pin(path) + except (OSError, json.JSONDecodeError, ValueError) as exc: + return GateResult( + gate_id="certifyedge_pin", + severity="fail", + ok=False, + message=f"CertifyEdge pin unreadable: {exc}", + ) + ready, errors = pin_is_production_ready(pin) + if mode == "release": + if not ready: + return GateResult( + gate_id="certifyedge_pin", + severity="fail", + ok=False, + message=( + "CertifyEdge pin is not production-ready for stable release " + f"(status={pin.status!r}, strategy={pin.provision_strategy!r})" + ), + details=tuple(errors), + ) + return GateResult( + gate_id="certifyedge_pin", + severity="pass", + ok=True, + message=( + f"CertifyEdge pin production-ready " + f"(strategy={pin.provision_strategy}, status={pin.status})" + ), + ) + if ready: + return GateResult( + gate_id="certifyedge_pin", + severity="pass", + ok=True, + message=f"CertifyEdge pin ready (strategy={pin.provision_strategy})", + ) + return GateResult( + gate_id="certifyedge_pin", + severity="info", + ok=True, + message=( + f"CertifyEdge pin not production-ready; allowed in {mode} " + f"(status={pin.status!r}, strategy={pin.provision_strategy!r})" + ), + details=tuple(errors), + ) + + +def check_certifyedge_trust_grade(*, mode: ReleaseMode) -> GateResult: + """Fail closed in release when provision.env trust grade is untrusted/unpinned.""" + provision = load_provision_environment() + try: + pin = load_certifyedge_pin() + except (OSError, json.JSONDecodeError, ValueError): + pin = None + + if provision is None: + if mode == "release": + # Pin readiness is covered by check_certifyedge_pin; missing provision.env + # after a successful pin is still a release blocker when live attestation + # is required — report as fail so operators run provision-certifyedge.sh. + ready = False + if pin is not None: + ready, _ = pin_is_production_ready(pin) + if ready: + return GateResult( + gate_id="certifyedge_trust_grade", + severity="fail", + ok=False, + message=( + "CertifyEdge pin is production-ready but provision.env is missing; " + "run scripts/provision-certifyedge.sh and source provision.env " + "before stable live attestation" + ), + ) + return GateResult( + gate_id="certifyedge_trust_grade", + severity="info", + ok=True, + message="No CertifyEdge provision.env (expected while pin is unpinned)", + ) + return GateResult( + gate_id="certifyedge_trust_grade", + severity="info", + ok=True, + message=f"No CertifyEdge provision.env (acceptable in {mode})", + ) + + grade = provision.trust_grade + if mode == "release" and grade != "pinned": + return GateResult( + gate_id="certifyedge_trust_grade", + severity="fail", + ok=False, + message=( + f"CertifyEdge trust_grade={grade!r}; stable release requires pinned " + "(dev_fixture / arbitrary PATH checkers are untrusted_development)" + ), + details=( + f"strategy={provision.provision_strategy}", + f"pin_identity={provision.pin_identity}", + f"binary_digest={provision.binary_digest}", + ), + ) + + exe = Path(provision.executable_path) if provision.executable_path else None + classified = classify_checker_trust(executable=exe, pin=pin, provision=provision) + return GateResult( + gate_id="certifyedge_trust_grade", + severity="pass" if grade == "pinned" else "info", + ok=True, + message=f"CertifyEdge trust_grade={grade} (classified={classified})", + details=(f"strategy={provision.provision_strategy}",), + ) + + +def check_artifact_integrity_registry( + *, + mode: ReleaseMode, + registry_path: Path | str | None = None, +) -> GateResult: + try: + registry = resolve_trusted_key_registry(registry_path) + except IntegrityError as exc: + if mode == "release": + return GateResult( + gate_id="artifact_integrity_registry", + severity="fail", + ok=False, + message=f"TrustedKeyRegistry invalid: {exc}", + ) + return GateResult( + gate_id="artifact_integrity_registry", + severity="warn", + ok=True, + message=f"TrustedKeyRegistry invalid (allowed in {mode}): {exc}", + ) + + if registry is None: + env_hint = "set PCS_TRUSTED_KEY_REGISTRY to a published TrustedKeyRegistry.v0 JSON" + if mode == "release": + return GateResult( + gate_id="artifact_integrity_registry", + severity="fail", + ok=False, + message=( + "TrustedKeyRegistry required for stable ArtifactIntegrity.v1 signing; " + + env_hint + ), + ) + return GateResult( + gate_id="artifact_integrity_registry", + severity="info", + ok=True, + message=( + f"TrustedKeyRegistry absent; digest-only integrity allowed in {mode} ({env_hint})" + ), + ) + + ok, notes = _registry_has_release_signing_key(registry) + if not ok: + if mode == "release": + return GateResult( + gate_id="artifact_integrity_registry", + severity="fail", + ok=False, + message="TrustedKeyRegistry present but no usable release_signing key", + details=tuple(notes), + ) + return GateResult( + gate_id="artifact_integrity_registry", + severity="warn", + ok=True, + message="TrustedKeyRegistry present without release_signing key (preview)", + details=tuple(notes), + ) + return GateResult( + gate_id="artifact_integrity_registry", + severity="pass", + ok=True, + message="TrustedKeyRegistry usable for ArtifactIntegrity.v1 release signing", + details=tuple(notes), + ) + + +def check_artifact_integrity_signatures( + *, + mode: ReleaseMode, + release_root: Path | None, + registry_path: Path | str | None = None, +) -> GateResult: + if release_root is None: + return GateResult( + gate_id="artifact_integrity_signatures", + severity="info", + ok=True, + message=( + "No --release-root; signature verification deferred to assemble/verify " + "(registry gate still applies in release mode)" + ), + ) + if not release_root.is_dir(): + return GateResult( + gate_id="artifact_integrity_signatures", + severity="fail", + ok=False, + message=f"release root is not a directory: {release_root}", + ) + + try: + registry = resolve_trusted_key_registry(registry_path) + except IntegrityError as exc: + return GateResult( + gate_id="artifact_integrity_signatures", + severity="fail", + ok=False, + message=f"cannot load TrustedKeyRegistry for signature verify: {exc}", + ) + if registry is None: + if mode == "release": + return GateResult( + gate_id="artifact_integrity_signatures", + severity="fail", + ok=False, + message="Cannot verify release-root signatures without TrustedKeyRegistry", + ) + return GateResult( + gate_id="artifact_integrity_signatures", + severity="info", + ok=True, + message=f"Skipping signature verify (no registry; digest-only in {mode})", + ) + + allow_digest_only = mode != "release" + errors = verify_release_root_signatures( + release_root, + registry, + allow_digest_only=allow_digest_only, + ) + hard = [e for e in errors if not e.startswith("DigestOnlyAllowed:")] + soft = [e for e in errors if e.startswith("DigestOnlyAllowed:")] + if hard: + return GateResult( + gate_id="artifact_integrity_signatures", + severity="fail", + ok=False, + message=f"Release-root ArtifactIntegrity failures under {release_root}", + details=tuple(hard), + ) + if soft: + return GateResult( + gate_id="artifact_integrity_signatures", + severity="info", + ok=True, + message=f"Digest-only integrity notices under {release_root} ({mode})", + details=tuple(soft), + ) + return GateResult( + gate_id="artifact_integrity_signatures", + severity="pass", + ok=True, + message=f"Release-root ArtifactIntegrity signatures verified under {release_root}", + ) + + +def _read_provenance_status(provenance_dir: Path) -> tuple[str | None, list[str]]: + notes: list[str] = [] + binding = provenance_dir / "ReleaseProvenanceBinding.v0.json" + status_file = provenance_dir / "attestation-status.json" + status: str | None = None + if binding.is_file(): + try: + data = json.loads(binding.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + notes.append(f"unreadable binding: {exc}") + else: + att = data.get("attestation") if isinstance(data, Mapping) else None + if isinstance(att, Mapping): + status = str(att.get("status") or "") or None + if status: + notes.append(f"binding.attestation.status={status}") + reason = att.get("gate_reason") + if reason: + notes.append(f"gate_reason={reason}") + if status_file.is_file(): + try: + st = json.loads(status_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + notes.append(f"unreadable attestation-status.json: {exc}") + else: + file_status = str(st.get("status") or "") or None + if file_status: + notes.append(f"attestation-status.json={file_status}") + if status is None: + status = file_status + elif file_status and file_status != status: + notes.append(f"status mismatch: binding={status!r} status_file={file_status!r}") + return status, notes + + +def check_provenance_attestation( + *, + mode: ReleaseMode, + provenance_dir: Path | None, + allow_gated: bool | None = None, +) -> GateResult: + allow = provenance_allow_gated() if allow_gated is None else allow_gated + if provenance_dir is None: + return GateResult( + gate_id="provenance_attestation", + severity="info", + ok=True, + message=( + "No --provenance-dir; provenance status checked when binding is present " + f"(release forbids gated unless PCS_PROVENANCE_ALLOW_GATED; allow={allow})" + ), + ) + if not provenance_dir.is_dir(): + return GateResult( + gate_id="provenance_attestation", + severity="fail", + ok=False, + message=f"provenance dir is not a directory: {provenance_dir}", + ) + + status, notes = _read_provenance_status(provenance_dir) + if status is None: + if mode == "release": + return GateResult( + gate_id="provenance_attestation", + severity="fail", + ok=False, + message=( + "Provenance package missing attestation status " + "(expected ReleaseProvenanceBinding.v0.json attestation.status)" + ), + details=tuple(notes), + ) + return GateResult( + gate_id="provenance_attestation", + severity="warn", + ok=True, + message="Provenance package present but attestation status missing (preview)", + details=tuple(notes), + ) + + if status == "signed": + return GateResult( + gate_id="provenance_attestation", + severity="pass", + ok=True, + message="Provenance attestation.status=signed", + details=tuple(notes), + ) + + if status == "gated": + gated_notice = provenance_dir / "PROVENANCE_ATTESTATION_GATED.json" + if not gated_notice.is_file(): + return GateResult( + gate_id="provenance_attestation", + severity="fail", + ok=False, + message="attestation.status=gated without PROVENANCE_ATTESTATION_GATED.json", + details=tuple(notes), + ) + if mode == "release" and not allow: + return GateResult( + gate_id="provenance_attestation", + severity="fail", + ok=False, + message=( + "Stable release forbids gated provenance; enable Sigstore/GHEC " + "attestations or set PCS_PROVENANCE_ALLOW_GATED=true only as break-glass" + ), + details=tuple(notes), + ) + severity: GateSeverity = "warn" if mode == "release" else "info" + return GateResult( + gate_id="provenance_attestation", + severity=severity, + ok=True, + message=( + "Provenance attestation.status=gated " + + ( + "(PCS_PROVENANCE_ALLOW_GATED break-glass)" + if allow + else f"(allowed disclosure in {mode})" + ) + ), + details=tuple(notes), + ) + + if status == "pending": + return GateResult( + gate_id="provenance_attestation", + severity="fail", + ok=False, + message="attestation.status=pending (producer did not finalize signed/gated)", + details=tuple(notes), + ) + + return GateResult( + gate_id="provenance_attestation", + severity="fail", + ok=False, + message=f"unknown attestation.status={status!r}", + details=tuple(notes), + ) + + +def check_certificate_mode_policy(*, mode: ReleaseMode) -> GateResult: + try: + load_certificate_mode_status() + except Exception as exc: # noqa: BLE001 — surface table errors as gate failures + return GateResult( + gate_id="certificate_mode_policy", + severity="fail", + ok=False, + message=f"certificate mode status table unreadable: {exc}", + ) + + details: list[str] = [] + rc = get_certificate_mode_status("TraceSafeRCertificate") + if rc is None or str(rc.get("status")) != "release_candidate": + return GateResult( + gate_id="certificate_mode_policy", + severity="fail", + ok=False, + message="TraceSafeRCertificate must remain status=release_candidate (sole tool-use RC)", + ) + details.append("TraceSafeRCertificate=release_candidate") + + for disabled in ( + "HandoffSafeCertificate", + "ContractCheckedCertificate", + "EffectFrameCertificate", + "FramePreservedCertificate", + ): + entry = get_certificate_mode_status(disabled) + if entry is None: + continue + if bool(entry.get("allowed_issuance")): + return GateResult( + gate_id="certificate_mode_policy", + severity="fail", + ok=False, + message=f"{disabled} must keep allowed_issuance=false while status=disabled", + ) + details.append(f"{disabled}={entry.get('status')} (issuance closed)") + + experimental = get_certificate_mode_status("CompositionalExtensionCertificate") + if experimental and str(experimental.get("status")) == "release_candidate": + return GateResult( + gate_id="certificate_mode_policy", + severity="fail", + ok=False, + message=( + "CompositionalExtensionCertificate must not be release_candidate " + "(experimental only)" + ), + ) + if experimental: + details.append(f"CompositionalExtensionCertificate={experimental.get('status')} (not RC)") + + external = get_external_claim_class_status("CertificateChecked") + if external: + details.append(f"external CertificateChecked={external.get('status')}") + if mode == "release" and str(external.get("status")) == "preview": + # Informational: CertificateChecked stays preview until CertifyEdge is pinned. + # The CertifyEdge pin gate already fail-closes live attestation. + details.append("CertificateChecked remains preview until authenticated CertifyEdge pin") + + return GateResult( + gate_id="certificate_mode_policy", + severity="pass", + ok=True, + message="Certificate mode policy: TraceSafeRCertificate sole tool-use RC", + details=tuple(details), + ) + + +def check_oci_cosign_publish( + *, + mode: ReleaseMode, + require_oci_publish: bool = False, +) -> GateResult: + """OCI cosign publish is org-infra; advisory unless explicitly required.""" + marker = os.environ.get("PCS_VERIFIER_OCI_DIGEST", "").strip() + if marker.startswith("sha256:") and len(marker) == 71: + return GateResult( + gate_id="oci_cosign_publish", + severity="pass", + ok=True, + message=f"PCS_VERIFIER_OCI_DIGEST set ({marker[:19]}…)", + details=( + "Confirm cosign verify + SBOM attestation against GHCR before claiming signed OCI", + ), + ) + msg = ( + "Verifier OCI cosign/GHCR publish is org-gated " + "(see docs/pf-core/operator-release-gates.md); set PCS_VERIFIER_OCI_DIGEST " + "after publishing by digest" + ) + if require_oci_publish and mode == "release": + return GateResult( + gate_id="oci_cosign_publish", + severity="fail", + ok=False, + message=msg, + ) + return GateResult( + gate_id="oci_cosign_publish", + severity="info", + ok=True, + message=msg + f" (advisory in {mode})", + ) + + +def evaluate_release_gates( + *, + mode: str | ReleaseMode | None = None, + pin_path: Path | None = None, + registry_path: Path | str | None = None, + release_root: Path | None = None, + provenance_dir: Path | None = None, + allow_gated_provenance: bool | None = None, + require_oci_publish: bool = False, +) -> ReleaseGateReport: + resolved = resolve_release_mode(None if mode is None else str(mode)) + allow = provenance_allow_gated() if allow_gated_provenance is None else allow_gated_provenance + results: list[GateResult] = [ + check_certifyedge_pin(mode=resolved, pin_path=pin_path), + check_certifyedge_trust_grade(mode=resolved), + check_artifact_integrity_registry(mode=resolved, registry_path=registry_path), + check_artifact_integrity_signatures( + mode=resolved, + release_root=release_root, + registry_path=registry_path, + ), + check_provenance_attestation( + mode=resolved, + provenance_dir=provenance_dir, + allow_gated=allow, + ), + check_certificate_mode_policy(mode=resolved), + check_oci_cosign_publish(mode=resolved, require_oci_publish=require_oci_publish), + ] + hard = tuple(r for r in results if not r.ok and r.severity == "fail") + return ReleaseGateReport( + mode=resolved, + results=tuple(results), + allow_gated_provenance=allow, + hard_failures=hard, + ) + + +def format_report_lines(report: ReleaseGateReport) -> list[str]: + lines = [ + f"== PCS release gates mode={report.mode} allow_gated={report.allow_gated_provenance} ==", + ] + for result in report.results: + mark = "OK" if result.ok else "FAIL" + lines.append(f"[{mark}] {result.gate_id}: {result.message}") + for detail in result.details: + lines.append(f" - {detail}") + if report.ok: + lines.append(f"OK release gates passed (mode={report.mode})") + else: + lines.append( + f"FAIL release gates ({len(report.hard_failures)} hard failure(s) " + f"in mode={report.mode})" + ) + return lines + + +def run_release_gate_check( + *, + mode: str | None = None, + pin_path: Path | None = None, + registry_path: Path | str | None = None, + release_root: Path | None = None, + provenance_dir: Path | None = None, + allow_gated_provenance: bool | None = None, + require_oci_publish: bool = False, + as_json: bool = False, +) -> tuple[int, str]: + report = evaluate_release_gates( + mode=mode, + pin_path=pin_path, + registry_path=registry_path, + release_root=release_root, + provenance_dir=provenance_dir, + allow_gated_provenance=allow_gated_provenance, + require_oci_publish=require_oci_publish, + ) + if as_json: + payload = json.dumps(report.to_dict(), indent=2, sort_keys=True) + "\n" + return (0 if report.ok else 1), payload + text = "\n".join(format_report_lines(report)) + "\n" + return (0 if report.ok else 1), text + + +def gate_ids() -> Sequence[GateId]: + return ( + "certifyedge_pin", + "certifyedge_trust_grade", + "artifact_integrity_registry", + "artifact_integrity_signatures", + "provenance_attestation", + "certificate_mode_policy", + "oci_cosign_publish", + ) diff --git a/python/tests/test_release_gates.py b/python/tests/test_release_gates.py new file mode 100644 index 0000000..f75403c --- /dev/null +++ b/python/tests/test_release_gates.py @@ -0,0 +1,321 @@ +"""Fail-closed release gate checker tests.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from pcs_core.artifact_integrity import ( + build_trusted_key, + build_trusted_key_registry, + generate_ed25519_keypair, +) +from pcs_core.certifyedge_pin import ProvisionEnvironment, pin_is_production_ready +from pcs_core.release_gates import ( + evaluate_release_gates, + run_release_gate_check, +) + +REPO = Path(__file__).resolve().parents[2] + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _write_registry(path: Path) -> tuple[bytes, str]: + seed, pub = generate_ed25519_keypair() + key_id = "ops-release-1" + doc = build_trusted_key_registry( + [ + build_trusted_key( + key_id=key_id, + public_key=pub, + valid_from=_utcnow() - timedelta(days=1), + purposes=["release_signing"], + ) + ], + registry_id="test-ops-registry", + ) + path.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8") + return seed, key_id + + +def _write_pinned_certifyedge(path: Path) -> None: + digest = "sha256:" + ("ab" * 32) + path.write_text( + json.dumps( + { + "tool": "certifyedge", + "status": "pinned", + "version": "1.2.3", + "provision_strategy": "signed_binary", + "image": "", + "image_digest": "", + "binary_url": "https://example.invalid/certifyedge-1.2.3", + "binary_sha256": digest, + "source_repo": "", + "source_commit": "", + "notes": ["test pin only"], + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +def _write_gated_provenance(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + binding = { + "schema_version": "v0", + "artifact_type": "ReleaseProvenanceBinding.v0", + "attestation": { + "status": "gated", + "gate_reason": "test gated status", + }, + "signature_or_digest": "sha256:" + ("cd" * 32), + } + (path / "ReleaseProvenanceBinding.v0.json").write_text( + json.dumps(binding, indent=2) + "\n", encoding="utf-8" + ) + (path / "attestation-status.json").write_text( + json.dumps({"status": "gated", "require_signed": False}, indent=2) + "\n", + encoding="utf-8", + ) + (path / "PROVENANCE_ATTESTATION_GATED.json").write_text( + json.dumps({"status": "gated", "reason": "test"}, indent=2) + "\n", + encoding="utf-8", + ) + + +def test_repo_unpinned_fails_release_passes_preview(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PCS_TRUSTED_KEY_REGISTRY", raising=False) + monkeypatch.delenv("PCS_PROVENANCE_ALLOW_GATED", raising=False) + monkeypatch.delenv("PCS_CERTIFYEDGE_PROVISION_ENV", raising=False) + + preview = evaluate_release_gates(mode="preview") + assert preview.ok + assert any(r.gate_id == "certifyedge_pin" and r.ok for r in preview.results) + + release = evaluate_release_gates(mode="release") + assert not release.ok + fail_ids = {r.gate_id for r in release.hard_failures} + assert "certifyedge_pin" in fail_ids + assert "artifact_integrity_registry" in fail_ids + + +def test_release_passes_with_pin_registry_and_provision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("PCS_PROVENANCE_ALLOW_GATED", raising=False) + pin_path = tmp_path / "certifyedge.json" + _write_pinned_certifyedge(pin_path) + ready, errors = pin_is_production_ready(json.loads(pin_path.read_text(encoding="utf-8"))) + assert ready, errors + + registry_path = tmp_path / "TrustedKeyRegistry.v0.json" + _write_registry(registry_path) + + digest = "sha256:" + ("ab" * 32) + exe = tmp_path / "certifyedge" + exe.write_bytes(b"x" * 16) + # Digest won't match pin; force provision.env trust_grade=pinned for gate unit test. + env = ProvisionEnvironment( + executable_path=str(exe), + binary_digest=digest, + version="1.2.3", + pin_identity=f"binary:{digest}", + provision_strategy="signed_binary", + trust_grade="pinned", + ) + provision_path = env.write(tmp_path / "provision.env") + monkeypatch.setenv("PCS_CERTIFYEDGE_PROVISION_ENV", str(provision_path)) + monkeypatch.setenv("PCS_TRUSTED_KEY_REGISTRY", str(registry_path)) + + report = evaluate_release_gates(mode="release", pin_path=pin_path, registry_path=registry_path) + assert report.ok, [r.to_dict() for r in report.hard_failures] + + +def test_gated_provenance_fail_closed_unless_allow( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pin_path = tmp_path / "certifyedge.json" + _write_pinned_certifyedge(pin_path) + registry_path = tmp_path / "TrustedKeyRegistry.v0.json" + _write_registry(registry_path) + digest = "sha256:" + ("ab" * 32) + env = ProvisionEnvironment( + executable_path=str(tmp_path / "ce"), + binary_digest=digest, + version="1.2.3", + pin_identity=f"binary:{digest}", + provision_strategy="signed_binary", + trust_grade="pinned", + ) + provision_path = env.write(tmp_path / "provision.env") + monkeypatch.setenv("PCS_CERTIFYEDGE_PROVISION_ENV", str(provision_path)) + monkeypatch.setenv("PCS_TRUSTED_KEY_REGISTRY", str(registry_path)) + monkeypatch.delenv("PCS_PROVENANCE_ALLOW_GATED", raising=False) + + prov = tmp_path / "provenance" + _write_gated_provenance(prov) + + blocked = evaluate_release_gates( + mode="release", + pin_path=pin_path, + registry_path=registry_path, + provenance_dir=prov, + allow_gated_provenance=False, + ) + assert not blocked.ok + assert any(r.gate_id == "provenance_attestation" for r in blocked.hard_failures) + + allowed = evaluate_release_gates( + mode="release", + pin_path=pin_path, + registry_path=registry_path, + provenance_dir=prov, + allow_gated_provenance=True, + ) + assert allowed.ok + assert allowed.allow_gated_provenance is True + + preview = evaluate_release_gates( + mode="preview", + pin_path=pin_path, + registry_path=registry_path, + provenance_dir=prov, + allow_gated_provenance=False, + ) + assert preview.ok + + +def test_untrusted_trust_grade_fails_release( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pin_path = tmp_path / "certifyedge.json" + _write_pinned_certifyedge(pin_path) + registry_path = tmp_path / "TrustedKeyRegistry.v0.json" + _write_registry(registry_path) + env = ProvisionEnvironment( + executable_path=str(tmp_path / "ce"), + binary_digest="sha256:" + ("11" * 32), + version="dev", + pin_identity="dev_fixture:x", + provision_strategy="dev_fixture", + trust_grade="untrusted_development", + ) + provision_path = env.write(tmp_path / "provision.env") + monkeypatch.setenv("PCS_CERTIFYEDGE_PROVISION_ENV", str(provision_path)) + monkeypatch.setenv("PCS_TRUSTED_KEY_REGISTRY", str(registry_path)) + + report = evaluate_release_gates(mode="release", pin_path=pin_path, registry_path=registry_path) + assert not report.ok + assert any(r.gate_id == "certifyedge_trust_grade" for r in report.hard_failures) + + +def test_certificate_mode_policy_trace_safer_rc() -> None: + report = evaluate_release_gates(mode="preview") + mode_gate = next(r for r in report.results if r.gate_id == "certificate_mode_policy") + assert mode_gate.ok + assert "TraceSafeRCertificate" in mode_gate.message + + +def test_cli_and_script_preview_ok(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PCS_TRUSTED_KEY_REGISTRY", raising=False) + monkeypatch.delenv("PCS_PROVENANCE_ALLOW_GATED", raising=False) + code, text = run_release_gate_check(mode="preview", as_json=True) + assert code == 0 + payload = json.loads(text) + assert payload["ok"] is True + assert payload["artifact_type"] == "ReleaseGateCheckReport.v0" + + script = REPO / "scripts" / "check-release-gates.py" + proc = subprocess.run( + [sys.executable, str(script), "--mode", "preview", "--json"], + capture_output=True, + text=True, + check=False, + cwd=str(REPO), + env={**os.environ, "PCS_RELEASE_MODE": "preview"}, + ) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout)["ok"] is True + + cli = subprocess.run( + [sys.executable, "-m", "pcs_core.cli", "release", "check-gates", "--mode", "preview"], + capture_output=True, + text=True, + check=False, + cwd=str(REPO / "python"), + env={**os.environ, "PYTHONPATH": str(REPO / "python")}, + ) + assert cli.returncode == 0, cli.stderr + cli.stdout + + +def test_script_release_fails_on_current_repo(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PCS_TRUSTED_KEY_REGISTRY", raising=False) + monkeypatch.delenv("PCS_PROVENANCE_ALLOW_GATED", raising=False) + script = REPO / "scripts" / "check-release-gates.py" + proc = subprocess.run( + [sys.executable, str(script), "--mode", "release"], + capture_output=True, + text=True, + check=False, + cwd=str(REPO), + env={ + k: v + for k, v in os.environ.items() + if k + not in { + "PCS_TRUSTED_KEY_REGISTRY", + "PCS_PROVENANCE_ALLOW_GATED", + "PCS_CERTIFYEDGE_PROVISION_ENV", + } + }, + ) + assert proc.returncode == 1 + assert "FAIL" in proc.stderr or "FAIL" in proc.stdout + + +def test_require_oci_publish_optional(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + pin_path = tmp_path / "certifyedge.json" + _write_pinned_certifyedge(pin_path) + registry_path = tmp_path / "TrustedKeyRegistry.v0.json" + _write_registry(registry_path) + digest = "sha256:" + ("ab" * 32) + env = ProvisionEnvironment( + executable_path=str(tmp_path / "ce"), + binary_digest=digest, + version="1.2.3", + pin_identity=f"binary:{digest}", + provision_strategy="signed_binary", + trust_grade="pinned", + ) + monkeypatch.setenv("PCS_CERTIFYEDGE_PROVISION_ENV", str(env.write(tmp_path / "provision.env"))) + monkeypatch.setenv("PCS_TRUSTED_KEY_REGISTRY", str(registry_path)) + monkeypatch.delenv("PCS_VERIFIER_OCI_DIGEST", raising=False) + + soft = evaluate_release_gates( + mode="release", + pin_path=pin_path, + registry_path=registry_path, + require_oci_publish=False, + ) + assert soft.ok + + hard = evaluate_release_gates( + mode="release", + pin_path=pin_path, + registry_path=registry_path, + require_oci_publish=True, + ) + assert not hard.ok + assert any(r.gate_id == "oci_cosign_publish" for r in hard.hard_failures) diff --git a/scripts/check-release-gates.py b/scripts/check-release-gates.py new file mode 100644 index 0000000..7a30bb7 --- /dev/null +++ b/scripts/check-release-gates.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Fail-closed org/infrastructure release gate checker. + +Exit codes: + 0 — all hard gates passed for the requested mode + 1 — one or more hard failures (stable/release fail-closed) + 2 — usage error + +See docs/pf-core/operator-release-gates.md. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +_PY = _REPO / "python" +if _PY.is_dir() and str(_PY) not in sys.path: + sys.path.insert(0, str(_PY)) + +from pcs_core.release_gates import run_release_gate_check # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--mode", + choices=("release", "preview", "dev"), + default=None, + help="Override PCS_RELEASE_MODE (default: env or preview)", + ) + parser.add_argument( + "--pin", + type=Path, + default=None, + help="Path to pins/certifyedge.json", + ) + parser.add_argument( + "--registry", + type=Path, + default=None, + help="TrustedKeyRegistry.v0 JSON (else PCS_TRUSTED_KEY_REGISTRY)", + ) + parser.add_argument( + "--release-root", + type=Path, + default=None, + help="Optional release/bundle root for ArtifactIntegrity signature verify", + ) + parser.add_argument( + "--provenance-dir", + type=Path, + default=None, + help="Optional provenance package dir (ReleaseProvenanceBinding.v0.json)", + ) + parser.add_argument( + "--require-oci-publish", + action="store_true", + help="Fail release mode when PCS_VERIFIER_OCI_DIGEST is unset", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit ReleaseGateCheckReport.v0 JSON", + ) + args = parser.parse_args(argv) + + code, text = run_release_gate_check( + mode=args.mode, + pin_path=args.pin, + registry_path=args.registry, + release_root=args.release_root, + provenance_dir=args.provenance_dir, + require_oci_publish=args.require_oci_publish, + as_json=args.json, + ) + if code == 0: + sys.stdout.write(text) + else: + # Hard failures go to stderr for CI log scanning; JSON always stdout. + if args.json: + sys.stdout.write(text) + else: + sys.stderr.write(text) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release-gate.sh b/scripts/release-gate.sh index 27a30af..99b5673 100644 --- a/scripts/release-gate.sh +++ b/scripts/release-gate.sh @@ -34,6 +34,9 @@ cd "${ROOT}/python" pip install -c requirements.lock -e ".[dev,quality]" >/dev/null pcs capabilities +echo "== Gate: org/infra release gates ==" +pcs release check-gates --mode "${MODE}" + echo "== Gate: quality (Python) ==" ruff check pcs_core tests ruff format --check pcs_core tests @@ -97,7 +100,7 @@ if [[ "${SKIP_LEAN}" != "1" ]]; then fi fi -echo "== Gate: SBOM + provenance scaffold ==" +echo "== Gate: SBOM scaffold ==" bash "${ROOT}/scripts/generate-sbom.sh" "${ROOT}/dist/sbom" test -f "${ROOT}/dist/sbom/pcs-core.cdx.json" @@ -139,6 +142,17 @@ else echo "OK preview mode: external attestation present or absence notice recorded" fi +echo "== Gate: release provenance binding (local gated; CI signs) ==" +PCS_PROVENANCE_BUILD_SBOM=0 \ +PCS_PROVENANCE_SBOM_DIR="${ROOT}/dist/sbom" \ +PCS_PROVENANCE_BUNDLE_DIR="${BUNDLE_OUT}" \ + bash "${ROOT}/scripts/build-release-provenance.sh" "${ROOT}/dist/provenance" +test -f "${ROOT}/dist/provenance/ReleaseProvenanceBinding.v0.json" +bash "${ROOT}/scripts/finalize-provenance-attestation.sh" "${ROOT}/dist/provenance" gated \ + "local release-gate.sh cannot mint GitHub Sigstore attestations; CI release-provenance.yml does" +PCS_PROVENANCE_REQUIRE_SIGNED=0 \ + bash "${ROOT}/scripts/verify-release-provenance.sh" "${ROOT}/dist/provenance" + echo "== Gate: signed tag policy (document only; do not push) ==" echo "Documented: create an annotated GPG/SSH-signed tag matching VERSION after gates pass." echo "This script does not create or push tags." From 2c34a34123b75239c7af41f2625d3aaf79c55165 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:02:57 -0700 Subject: [PATCH 21/24] Wire CLI and validators to new evidence schemas. Expose detect/validate paths for effect frames, theorem manifests, and release gates so operators can invoke the new surfaces without bespoke scripts. --- python/pcs_core/cli.py | 188 +++++++++++++++++++++++++- python/pcs_core/paths.py | 6 +- python/pcs_core/registry_data.py | 79 +++++++++++ python/pcs_core/registry_semantics.py | 5 + python/pcs_core/validate_detect.py | 25 ++++ python/pcs_core/validate_semantics.py | 55 +++++--- 6 files changed, 332 insertions(+), 26 deletions(-) diff --git a/python/pcs_core/cli.py b/python/pcs_core/cli.py index 70f4e18..4831e84 100644 --- a/python/pcs_core/cli.py +++ b/python/pcs_core/cli.py @@ -527,10 +527,11 @@ def cmd_pf_core_lean_check( skip_lean_proof: bool, certificate_mode: str | None, release_grade: bool = False, + allow_non_public_modes: bool = False, ) -> int: from pcs_core.lean_check import run_pfcore_lean_check - code, _result = run_pfcore_lean_check( + code, result = run_pfcore_lean_check( trace, out_path=out, result_out_path=result_out, @@ -538,13 +539,52 @@ def cmd_pf_core_lean_check( skip_lean_proof=skip_lean_proof, certificate_mode=certificate_mode, release_grade=release_grade, - ) + allow_non_public_modes=allow_non_public_modes, + ) + paths = result.get("artifact_paths") if isinstance(result, dict) else None + if isinstance(paths, dict) and paths: + print("PF-Core lean-check artifact paths:") + for key in ( + "certificate", + "lean_check_result", + "generated_proof", + "semantic_projection", + "theorem_manifest", + ): + if key in paths: + print(f" {key}: {paths[key]}") if code == 0: dest = out or trace.with_name("PFCoreCertificate.v0.json") print(f"OK PF-Core lean-check {trace} -> {dest}") return code +def cmd_release_check_gates( + *, + mode: str | None, + pin: Path | None, + registry: Path | None, + release_root: Path | None, + provenance_dir: Path | None, + require_oci_publish: bool, + as_json: bool, +) -> int: + from pcs_core.release_gates import run_release_gate_check + + code, text = run_release_gate_check( + mode=mode, + pin_path=pin, + registry_path=registry, + release_root=release_root, + provenance_dir=provenance_dir, + require_oci_publish=require_oci_publish, + as_json=as_json, + ) + stream = sys.stdout if code == 0 or as_json else sys.stderr + stream.write(text) + return code + + def cmd_pf_core_bundle_release( trace: Path, cert: Path, @@ -575,6 +615,33 @@ def cmd_pf_core_validate_bundle(path: Path) -> int: return 0 +def cmd_pf_core_verify_bundle( + path: Path, + *, + skip_lean_compile: bool = False, + result_out: Path | None = None, +) -> int: + from pcs_core.pf_core_bundle import verify_bundle + + result = verify_bundle( + path, + skip_lean_compile=skip_lean_compile, + result_out=result_out, + ) + if result.result_path: + print(f"verification result: {result.result_path}") + if not result.ok: + print(f"FAIL PF-Core verify-bundle {path}", file=sys.stderr) + for issue in result.issues: + print(f" - {issue.code}: {issue.message}", file=sys.stderr) + return 1 + print(f"OK PF-Core verify-bundle {path}") + for check in result.checks: + detail = f" ({check.detail})" if check.detail else "" + print(f" {check.check_id}: {check.status}{detail}") + return 0 + + def cmd_pf_core_audit_lean_no_sorry() -> int: from pcs_core.lean_check import audit_pfcore_lean_no_sorry @@ -827,12 +894,30 @@ def main(argv: list[str] | None = None) -> int: "--certificate-mode", type=str, default=None, - help="Compositional certificate mode for generated Lean obligations", + help=( + "Certificate mode for generated Lean obligations. Public claim surface is " + "schemas/pf_core.certificate_mode_status.json: TraceSafeRCertificate=release_candidate " + "(sole tool-use RC); TraceSafeCertificate=legacy; CompositionalExtensionCertificate=" + "experimental; HandoffSafe/ContractChecked/EffectFrame/FramePreserved=disabled " + "(handoff/contract/effect-frame/transition evidence repaired; public enablement deferred; " + "fail closed). External CertificateChecked is preview." + ), ) pf_core_lean.add_argument( "--release-grade", action="store_true", - help="Enforce release-grade certificate mode policy for tool-use traces", + help=( + "Enforce release-grade policy: tool-use requires TraceSafeRCertificate; " + "disabled/experimental modes fail closed" + ), + ) + pf_core_lean.add_argument( + "--allow-non-public-modes", + action="store_true", + help=( + "Allow issuance of disabled/preview modes for fixture and codegen tests. " + "Not for public release-candidate issuance." + ), ) pf_core_bundle = pf_core_sub.add_parser( "bundle-release", @@ -849,8 +934,31 @@ def main(argv: list[str] | None = None) -> int: ) pf_core_sub.add_parser( "validate-bundle", - help="Validate PF-Core release bundle manifest and hashes", + help=( + "Structural PF-Core release bundle check (manifests + digests). " + "Stable releases must also run verify-bundle." + ), ).add_argument("path", type=Path) + pf_core_verify_bundle = pf_core_sub.add_parser( + "verify-bundle", + help=( + "Independently verify a closed PF-Core release bundle: digest checks, " + "projection replay, theorem reconstruction, Lean compile against bundled " + "kernel, and attestation when required. Required for stable releases." + ), + ) + pf_core_verify_bundle.add_argument("path", type=Path) + pf_core_verify_bundle.add_argument( + "--skip-lean-compile", + action="store_true", + help="Skip bundled-kernel Lean compile (not valid for stable release verification)", + ) + pf_core_verify_bundle.add_argument( + "--result-out", + type=Path, + default=None, + help="Write PFCoreBundleVerificationResult.v0 JSON (default: inside bundle dir)", + ) pf_core_sub.add_parser( "audit-lean-no-sorry", help="Scan lean/PFCore/ for sorry/admit/axiom/unsafe", @@ -1093,6 +1201,59 @@ def main(argv: list[str] | None = None) -> int: p_benchmark_run.add_argument("--json", action="store_true", help="Emit BenchmarkReport.v0 JSON") p_benchmark_run.add_argument("--out", type=Path, default=None, help="Write report JSON to path") + release_parser = sub.add_parser( + "release", + help="Stable/preview release infrastructure gates", + ) + release_sub = release_parser.add_subparsers(dest="release_cmd", required=True) + p_release_gates = release_sub.add_parser( + "check-gates", + help=( + "Fail-closed org/infra gates (CertifyEdge pin, TrustedKeyRegistry, " + "provenance gated policy, certificate-mode policy)" + ), + ) + p_release_gates.add_argument( + "--mode", + choices=("release", "preview", "dev"), + default=None, + help="Override PCS_RELEASE_MODE (default: env or preview)", + ) + p_release_gates.add_argument( + "--pin", + type=Path, + default=None, + help="Path to pins/certifyedge.json", + ) + p_release_gates.add_argument( + "--registry", + type=Path, + default=None, + help="TrustedKeyRegistry.v0 JSON (else PCS_TRUSTED_KEY_REGISTRY)", + ) + p_release_gates.add_argument( + "--release-root", + type=Path, + default=None, + help="Optional release/bundle root for ArtifactIntegrity signature verify", + ) + p_release_gates.add_argument( + "--provenance-dir", + type=Path, + default=None, + help="Optional provenance package dir", + ) + p_release_gates.add_argument( + "--require-oci-publish", + action="store_true", + help="Fail release mode when PCS_VERIFIER_OCI_DIGEST is unset", + ) + p_release_gates.add_argument( + "--json", + action="store_true", + help="Emit ReleaseGateCheckReport.v0 JSON", + ) + args = parser.parse_args(argv) if args.command == "capabilities": @@ -1173,6 +1334,7 @@ def main(argv: list[str] | None = None) -> int: args.skip_lean_proof, args.certificate_mode, args.release_grade, + args.allow_non_public_modes, ) if args.command == "pf-core" and args.pf_core_cmd == "bundle-release": return cmd_pf_core_bundle_release( @@ -1183,6 +1345,12 @@ def main(argv: list[str] | None = None) -> int: ) if args.command == "pf-core" and args.pf_core_cmd == "validate-bundle": return cmd_pf_core_validate_bundle(args.path) + if args.command == "pf-core" and args.pf_core_cmd == "verify-bundle": + return cmd_pf_core_verify_bundle( + args.path, + skip_lean_compile=args.skip_lean_compile, + result_out=args.result_out, + ) if args.command == "pf-core" and args.pf_core_cmd == "audit-lean-no-sorry": return cmd_pf_core_audit_lean_no_sorry() if args.command == "pf-core" and args.pf_core_cmd == "replay-trace": @@ -1265,6 +1433,16 @@ def main(argv: list[str] | None = None) -> int: return cmd_benchmark_normalize(args.dialect, args.out) if args.command == "benchmark" and args.benchmark_cmd == "run": return cmd_benchmark_run(args.suite, json_output=args.json, out_path=args.out) + if args.command == "release" and args.release_cmd == "check-gates": + return cmd_release_check_gates( + mode=args.mode, + pin=args.pin, + registry=args.registry, + release_root=args.release_root, + provenance_dir=args.provenance_dir, + require_oci_publish=args.require_oci_publish, + as_json=args.json, + ) parser.print_help() return 2 diff --git a/python/pcs_core/paths.py b/python/pcs_core/paths.py index 1fc32e1..3cdc66a 100644 --- a/python/pcs_core/paths.py +++ b/python/pcs_core/paths.py @@ -1,4 +1,8 @@ -"""Resolve repo and schema paths for dev checkouts and installed wheels.""" +"""Resolve repo and schema paths for dev checkouts and installed wheels. + +Lean roots, kernel sources, generated proofs, pins, and catalogs are resolved +by :mod:`pcs_core.asset_resolver` (authoritative for verifier assets). +""" from __future__ import annotations diff --git a/python/pcs_core/registry_data.py b/python/pcs_core/registry_data.py index b032d92..ea82b37 100644 --- a/python/pcs_core/registry_data.py +++ b/python/pcs_core/registry_data.py @@ -87,7 +87,11 @@ def _entry( "PFCoreTrace.v0", "PFCoreContract.v0", "PFCoreHandoff.v0", + "PFCoreEffectFrame.v0", "PFCoreCertificate.v0", + "PFCoreTheoremManifest.v0", + "PFCoreEvidenceManifest.v0", + "PFCoreBundleVerificationResult.v0", "PFCoreRuntimeObservation.v0", } ) @@ -705,6 +709,7 @@ def _pf_core_release_entry( semantic_checks=[ _sc("source_commit_not_placeholder", "release_blocking", SCIENTIFIC_COMPUTATION_DEMO), _sc("signature_or_digest_valid", "release_blocking", SCIENTIFIC_COMPUTATION_DEMO), + _sc("payload_bytes_match_digest", "release_blocking", PCS_CORE), ], consumer_repos=[CERTIFYEDGE, PF, SM, PCS_CORE], release_mode_required=True, @@ -1235,6 +1240,7 @@ def _pf_core_release_entry( ), "PFCoreContract.v0": _pf_core_primitive_entry("PFCoreContract.v0"), "PFCoreHandoff.v0": _pf_core_primitive_entry("PFCoreHandoff.v0"), + "PFCoreEffectFrame.v0": _pf_core_primitive_entry("PFCoreEffectFrame.v0"), "PFCoreTrace.v0": _pf_core_release_entry( "PFCoreTrace.v0", id_field="trace_id", @@ -1245,6 +1251,78 @@ def _pf_core_release_entry( id_field="certificate_id", extra_release_fields=["trace_hash", "claim_class"], ), + "PFCoreTheoremManifest.v0": _entry( + artifact_type="PFCoreTheoremManifest.v0", + schema="schemas/PFCoreTheoremManifest.v0.schema.json", + schema_owner=PCS_CORE, + runtime_producer=PCS_CORE, + allowed_runtime_producers=[PCS_CORE], + allowed_statuses=["Draft", "Validated", "Deprecated"], + required_release_fields=[ + "schema_version", + "artifact_type", + "generated_module_name", + "proof_file_hash", + "semantic_projection_hash", + "certificate_mode", + "final_witness_theorem", + "final_witness_proposition", + "theorems", + "theorem_manifest_digest", + ], + semantic_checks=[ + _sc("schema_valid", "release_blocking", PCS_CORE), + _sc("manifest_digest_matches", "release_blocking", PCS_CORE), + ], + consumer_repos=[PCS_CORE], + release_mode_required=False, + ), + "PFCoreEvidenceManifest.v0": _entry( + artifact_type="PFCoreEvidenceManifest.v0", + schema="schemas/PFCoreEvidenceManifest.v0.schema.json", + schema_owner=PCS_CORE, + runtime_producer=PCS_CORE, + allowed_runtime_producers=[PCS_CORE], + allowed_statuses=["Draft", "Validated", "Deprecated"], + required_release_fields=[ + "schema_version", + "artifact_type", + "evidence_selection_policy", + "evidence_selection_policy_version", + "files", + "evidence_manifest_digest", + ], + semantic_checks=[ + _sc("schema_valid", "release_blocking", PCS_CORE), + _sc("manifest_digest_matches", "release_blocking", PCS_CORE), + ], + consumer_repos=[PCS_CORE], + release_mode_required=True, + ), + "PFCoreBundleVerificationResult.v0": _entry( + artifact_type="PFCoreBundleVerificationResult.v0", + schema="schemas/PFCoreBundleVerificationResult.v0.schema.json", + schema_owner=PCS_CORE, + runtime_producer=PCS_CORE, + allowed_runtime_producers=[PCS_CORE], + allowed_statuses=["Draft", "Validated", "Deprecated"], + required_release_fields=[ + "schema_version", + "artifact_type", + "ok", + "bundle_dir", + "verifier", + "verifier_version", + "checks", + "issues", + "signature_or_digest", + ], + semantic_checks=[ + _sc("schema_valid", "release_blocking", PCS_CORE), + ], + consumer_repos=[PCS_CORE], + release_mode_required=False, + ), "PFCoreRuntimeObservation.v0": _pf_core_release_entry( "PFCoreRuntimeObservation.v0", id_field="observation_id", @@ -1292,6 +1370,7 @@ def _pf_core_release_entry( semantic_checks=[ _sc("schema_valid_before_path_follow", "release_blocking", PCS_CORE), _sc("explicit_artifact_type", "release_blocking", PCS_CORE), + _sc("closed_evidence_digests", "release_blocking", PCS_CORE), ], consumer_repos=[PCS_CORE], release_mode_required=True, diff --git a/python/pcs_core/registry_semantics.py b/python/pcs_core/registry_semantics.py index fa96682..31a759d 100644 --- a/python/pcs_core/registry_semantics.py +++ b/python/pcs_core/registry_semantics.py @@ -43,6 +43,7 @@ "result_hashes_match_result_artifacts": "artifact_validate", "code_commit_present": "artifact_validate", "computation_status_checked_for_release": "release_chain", + "payload_bytes_match_digest": "release_chain", "obligations_reference_known_kinds": "artifact_validate", "obligation_results_match_proof_obligation": "artifact_validate", "lean_theorem_in_catalog": "registry_metadata", @@ -99,6 +100,10 @@ "computation_status_checked_for_release": ( "Enforced via pcs validate-release-chain on computation-release fixtures." ), + "payload_bytes_match_digest": ( + "Verified during release-chain ResultArtifact payload resolution: contained path, " + "SHA-256 digest, and size_bytes must match the on-disk payload bytes." + ), } PCS_CORE_COMPONENT = "pcs-core" diff --git a/python/pcs_core/validate_detect.py b/python/pcs_core/validate_detect.py index 666b117..794053b 100644 --- a/python/pcs_core/validate_detect.py +++ b/python/pcs_core/validate_detect.py @@ -115,14 +115,20 @@ class DetectionMode(str, Enum): "PFCoreTrace.v0": "PFCoreTrace.v0.schema.json", "PFCoreContract.v0": "PFCoreContract.v0.schema.json", "PFCoreHandoff.v0": "PFCoreHandoff.v0.schema.json", + "PFCoreEffectFrame.v0": "PFCoreEffectFrame.v0.schema.json", "PFCoreRuntimeObservation.v0": "PFCoreRuntimeObservation.v0.schema.json", "PFCoreCertificate.v0": "PFCoreCertificate.v0.schema.json", + "PFCoreTheoremManifest.v0": "PFCoreTheoremManifest.v0.schema.json", + "PFCoreEvidenceManifest.v0": "PFCoreEvidenceManifest.v0.schema.json", + "PFCoreBundleVerificationResult.v0": "PFCoreBundleVerificationResult.v0.schema.json", "PCSBridgeCertificate.v0": "PCSBridgeCertificate.v0.schema.json", "PFCoreKernelManifest.v0": "PFCoreKernelManifest.v0.schema.json", "PFCoreReleaseBundleManifest.v0": "PFCoreReleaseBundleManifest.v0.schema.json", "ArtifactIntegrity.v1": "ArtifactIntegrity.v1.schema.json", + "TrustedKeyRegistry.v0": "TrustedKeyRegistry.v0.schema.json", "FormatAssertionProbe.v0": "FormatAssertionProbe.v0.schema.json", "ExternalAttestation.v0": "ExternalAttestation.v0.schema.json", + "ReleaseProvenanceBinding.v0": "ReleaseProvenanceBinding.v0.schema.json", } @@ -494,6 +500,19 @@ def _detect_artifact_type_heuristic(data: dict[str, Any]) -> str | None: and "certificate_path" in data ): return "PFCoreReleaseBundleManifest.v0" + if ( + data.get("schema_version") == "v0" + and data.get("artifact_type") == "PFCoreEvidenceManifest.v0" + and isinstance(data.get("files"), list) + ): + return "PFCoreEvidenceManifest.v0" + if ( + data.get("schema_version") == "v0" + and data.get("artifact_type") == "PFCoreBundleVerificationResult.v0" + and "ok" in data + and "checks" in data + ): + return "PFCoreBundleVerificationResult.v0" if ( data.get("schema_version") == "v1" and data.get("artifact_type") == "ArtifactIntegrity.v1" @@ -501,6 +520,12 @@ def _detect_artifact_type_heuristic(data: dict[str, Any]) -> str | None: and "signature" in data ): return "ArtifactIntegrity.v1" + if ( + data.get("schema_version") == "v0" + and data.get("artifact_type") == "TrustedKeyRegistry.v0" + and isinstance(data.get("keys"), list) + ): + return "TrustedKeyRegistry.v0" return None diff --git a/python/pcs_core/validate_semantics.py b/python/pcs_core/validate_semantics.py index 81c1e45..5599abf 100644 --- a/python/pcs_core/validate_semantics.py +++ b/python/pcs_core/validate_semantics.py @@ -85,6 +85,11 @@ def validate_semantics(data: dict[str, Any], artifact_type: str) -> list[str]: if artifact_type == "MigrationReport.v0": return errors + if artifact_type == "ReleaseProvenanceBinding.v0": + # Nested attestation.status / bundle.status are not ArtifactStatus enums. + _check_source_commits(data, "", errors) + return errors + if artifact_type == "ReleaseManifest.v0": errors.extend(validate_release_manifest_semantics(data)) return errors @@ -134,26 +139,9 @@ def validate_semantics(data: dict[str, Any], artifact_type: str) -> list[str]: return errors if artifact_type == "PCSProjectionManifest.v0": - for index, entry in enumerate(data.get("entries") or []): - if not isinstance(entry, dict): - errors.append(f"PCSProjectionManifest.v0.entries[{index}] must be an object") - continue - value = entry.get("normalized_value") - if not isinstance(value, str) or not value.strip(): - errors.append( - f"PCSProjectionManifest.v0.entries[{index}].normalized_value must be non-empty", - ) - elif "unknown" in value.lower(): - errors.append( - f"PCSProjectionManifest.v0.entries[{index}].normalized_value " - "must not contain an unknown placeholder", - ) - ident = entry.get("lean_identifier") - if isinstance(ident, str) and "unknown" in ident.lower(): - errors.append( - f"PCSProjectionManifest.v0.entries[{index}].lean_identifier " - "must not contain an unknown placeholder", - ) + from pcs_core.pcs_projection import validate_projection_manifest_structure + + errors.extend(validate_projection_manifest_structure(data)) return errors if artifact_type == "LeanCheckResult.v0": @@ -273,6 +261,19 @@ def validate_semantics(data: dict[str, Any], artifact_type: str) -> list[str]: if artifact_type == "LeanCheckResult.v0": errors.extend(_validate_lean_check_result(data)) + if artifact_type == "ArtifactIntegrity.v1": + from pcs_core.artifact_integrity import validate_artifact_integrity_semantics + + errors.extend(validate_artifact_integrity_semantics(data)) + + if artifact_type == "TrustedKeyRegistry.v0": + from pcs_core.artifact_integrity import IntegrityError, load_trusted_key_registry + + try: + load_trusted_key_registry(data) + except IntegrityError as exc: + errors.append(str(exc)) + if artifact_type in _PF_CORE_ARTIFACT_TYPES and artifact_type not in { "PFCoreTrace.v0", "PFCoreCertificate.v0", @@ -283,6 +284,7 @@ def validate_semantics(data: dict[str, Any], artifact_type: str) -> list[str]: "PFCoreReleaseBundleManifest.v0", "PFCoreKernelManifest.v0", "PFCoreSemanticProjection.v0", + "PFCoreTheoremManifest.v0", }: _validate_pfcore_claim_class( data, "root", errors, allowed=PF_CORE_CLAIM_CLASSES, artifact_kind="pf-core" @@ -380,6 +382,19 @@ def validate_file( f"Validation failed for {artifact_type}", errors=ref_errors, ) + if artifact_type == "ProofObligation.v0": + from pcs_core.pcs_projection import validate_proof_obligation_projection + + release_root = path.parent + if (release_root / "release_manifest.v0.json").is_file() or ( + release_root / "ReleaseManifest.v0.json" + ).is_file(): + replay_errors = validate_proof_obligation_projection(data, release_dir=release_root) + if replay_errors: + raise ValidationError( + f"Validation failed for {artifact_type}", + errors=replay_errors, + ) return artifact_type From 4c52736a1b0e8bff6b00d359b18ec49297b99c1a Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:03:03 -0700 Subject: [PATCH 22/24] Refresh release-chain fixtures for strengthened obligations. Regenerate proof obligations and registry examples so release-chain reports match the tightened payload and projection contracts. --- examples/artifact_registry.valid.json | 191 +++++++++++++++++- .../proof_obligation.v0.json | 82 +++++++- .../release_chain_validation_result.v0.json | 12 +- .../release_manifest.v0.json | 12 +- .../scientific_memory_import_report.json | 2 +- .../labtrust-release/proof_obligation.v0.json | 55 ++++- examples/proof_obligation.valid.json | 57 +++++- examples/semantic_check_execution.valid.json | 82 +++++++- .../tool-use-release/proof_obligation.v0.json | 76 ++++++- python/pcs_core/computation_release_chain.py | 24 +++ python/pcs_core/release_chain_report.py | 17 +- .../tests/test_phase1_protocol_hardening.py | 6 +- 12 files changed, 588 insertions(+), 28 deletions(-) diff --git a/examples/artifact_registry.valid.json b/examples/artifact_registry.valid.json index 9845fd4..13fed60 100644 --- a/examples/artifact_registry.valid.json +++ b/examples/artifact_registry.valid.json @@ -992,6 +992,13 @@ "responsible_component": "scientific-computation demo producer", "execution_required_in_release_mode": true, "allowed_to_skip": false + }, + { + "check_id": "payload_bytes_match_digest", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false } ], "consumer_repos": [ @@ -2119,6 +2126,49 @@ "canonical_hash_required": true, "release_mode_required": false }, + "PFCoreEffectFrame.v0": { + "artifact_type": "PFCoreEffectFrame.v0", + "schema": "schemas/PFCoreEffectFrame.v0.schema.json", + "schema_owner": "pcs-core", + "runtime_producer": "pcs-core", + "allowed_runtime_producers": [ + "pcs-core", + "AgentRuntime" + ], + "producer": "pcs-core", + "allowed_statuses": [ + "Draft", + "Validated", + "Deprecated" + ], + "required_release_fields": [ + "schema_version", + "artifact_type", + "signature_or_digest" + ], + "semantic_checks": [ + { + "check_id": "explicit_artifact_type", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false + }, + { + "check_id": "schema_valid", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false + } + ], + "consumer_repos": [ + "pcs-core", + "AgentRuntime" + ], + "canonical_hash_required": true, + "release_mode_required": false + }, "PFCoreTrace.v0": { "artifact_type": "PFCoreTrace.v0", "schema": "schemas/PFCoreTrace.v0.schema.json", @@ -2265,6 +2315,138 @@ "canonical_hash_required": true, "release_mode_required": true }, + "PFCoreTheoremManifest.v0": { + "artifact_type": "PFCoreTheoremManifest.v0", + "schema": "schemas/PFCoreTheoremManifest.v0.schema.json", + "schema_owner": "pcs-core", + "runtime_producer": "pcs-core", + "allowed_runtime_producers": [ + "pcs-core" + ], + "producer": "pcs-core", + "allowed_statuses": [ + "Draft", + "Validated", + "Deprecated" + ], + "required_release_fields": [ + "schema_version", + "artifact_type", + "generated_module_name", + "proof_file_hash", + "semantic_projection_hash", + "certificate_mode", + "final_witness_theorem", + "final_witness_proposition", + "theorems", + "theorem_manifest_digest" + ], + "semantic_checks": [ + { + "check_id": "schema_valid", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false + }, + { + "check_id": "manifest_digest_matches", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false + } + ], + "consumer_repos": [ + "pcs-core" + ], + "canonical_hash_required": true, + "release_mode_required": false + }, + "PFCoreEvidenceManifest.v0": { + "artifact_type": "PFCoreEvidenceManifest.v0", + "schema": "schemas/PFCoreEvidenceManifest.v0.schema.json", + "schema_owner": "pcs-core", + "runtime_producer": "pcs-core", + "allowed_runtime_producers": [ + "pcs-core" + ], + "producer": "pcs-core", + "allowed_statuses": [ + "Draft", + "Validated", + "Deprecated" + ], + "required_release_fields": [ + "schema_version", + "artifact_type", + "evidence_selection_policy", + "evidence_selection_policy_version", + "files", + "evidence_manifest_digest" + ], + "semantic_checks": [ + { + "check_id": "schema_valid", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false + }, + { + "check_id": "manifest_digest_matches", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false + } + ], + "consumer_repos": [ + "pcs-core" + ], + "canonical_hash_required": true, + "release_mode_required": true + }, + "PFCoreBundleVerificationResult.v0": { + "artifact_type": "PFCoreBundleVerificationResult.v0", + "schema": "schemas/PFCoreBundleVerificationResult.v0.schema.json", + "schema_owner": "pcs-core", + "runtime_producer": "pcs-core", + "allowed_runtime_producers": [ + "pcs-core" + ], + "producer": "pcs-core", + "allowed_statuses": [ + "Draft", + "Validated", + "Deprecated" + ], + "required_release_fields": [ + "schema_version", + "artifact_type", + "ok", + "bundle_dir", + "verifier", + "verifier_version", + "checks", + "issues", + "signature_or_digest" + ], + "semantic_checks": [ + { + "check_id": "schema_valid", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false + } + ], + "consumer_repos": [ + "pcs-core" + ], + "canonical_hash_required": true, + "release_mode_required": false + }, "PFCoreRuntimeObservation.v0": { "artifact_type": "PFCoreRuntimeObservation.v0", "schema": "schemas/PFCoreRuntimeObservation.v0.schema.json", @@ -2419,6 +2601,13 @@ "responsible_component": "pcs-core", "execution_required_in_release_mode": true, "allowed_to_skip": false + }, + { + "check_id": "closed_evidence_digests", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false } ], "consumer_repos": [ @@ -2471,5 +2660,5 @@ "release_mode_required": false } }, - "signature_or_digest": "sha256:571c5e466142a4173e66a1bb3ece88652994a01a47a5af369e8a8b4a99c30f30" + "signature_or_digest": "sha256:523658c76a705ef3dc3e4c277bc5624bdcedc770d773f18ca33127ed568967fa" } diff --git a/examples/computation-release/proof_obligation.v0.json b/examples/computation-release/proof_obligation.v0.json index 8ff46b9..847fd29 100644 --- a/examples/computation-release/proof_obligation.v0.json +++ b/examples/computation-release/proof_obligation.v0.json @@ -10,12 +10,12 @@ "inputs": { "witness_id": "witness-sci-comp-repro-001", "witness_result_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], "declared_result_artifact_hashes": [ - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c" ], - "result_artifact_sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "result_artifact_sha256": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", "witness_status": "CertificateChecked", "run_receipt_hash": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", "dataset_hash": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", @@ -63,8 +63,82 @@ "artifact_type": "SignedScienceClaimBundle.v0" } }, + "pcs_projection_manifest": { + "schema_version": "v0", + "artifact_type": "PCSProjectionManifest.v0", + "projection_id": "pcs-projection-release-pcs-v0.1-scientific-computation", + "release_id": "release-pcs-v0.1-scientific-computation", + "workflow_id": "scientific_computation.reproducibility_v0", + "entries": [ + { + "artifact_path": "computation_witness.json", + "artifact_digest": "sha256:944ab3a7bcc6ecd3b7b702c122fc3aa4e1048f2d885d14350894bb18618e3bf2", + "json_pointer": "/witness_id", + "normalized_value": "witness-sci-comp-repro-001", + "lean_identifier": "concreteComputationWitness.witnessId" + }, + { + "artifact_path": "computation_witness.json", + "artifact_digest": "sha256:944ab3a7bcc6ecd3b7b702c122fc3aa4e1048f2d885d14350894bb18618e3bf2", + "json_pointer": "/dataset_hash", + "normalized_value": "sha256:84c9037231eef6a1742c1d6d0a043878b4de8395397c168026450d8ca9e647e3", + "lean_identifier": "concreteComputationWitness.datasetHash" + }, + { + "artifact_path": "computation_witness.json", + "artifact_digest": "sha256:944ab3a7bcc6ecd3b7b702c122fc3aa4e1048f2d885d14350894bb18618e3bf2", + "json_pointer": "/environment_hash", + "normalized_value": "sha256:3739a2ed0a132d5c55dc6e7f53dabca2c7e57cbb188962a0ce670762850f4e01", + "lean_identifier": "concreteComputationWitness.environmentHash" + }, + { + "artifact_path": "computation_witness.json", + "artifact_digest": "sha256:944ab3a7bcc6ecd3b7b702c122fc3aa4e1048f2d885d14350894bb18618e3bf2", + "json_pointer": "/run_receipt_hash", + "normalized_value": "sha256:9c74749d2ad46c6a60394db676e5527929f9b7bef9a012439d6d14b26d960828", + "lean_identifier": "concreteComputationWitness.runReceiptHash" + }, + { + "artifact_path": "result_artifact.json", + "artifact_digest": "sha256:b3f437010792f1f1f70ade9912374a1795c1458bf35309d6e1f888d875d09f3c", + "json_pointer": "/sha256", + "normalized_value": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "lean_identifier": "concreteResultArtifactHash" + }, + { + "artifact_path": "outputs/metrics.json", + "artifact_digest": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "json_pointer": "/#payload_sha256", + "normalized_value": "sha256:3e8f8863ea36cfdcc6718ec4d738a6b69ed649f229d52edf79b88ebdd596768c", + "lean_identifier": "concreteVerifiedResultPayloadHash" + }, + { + "artifact_path": "verification_result.json", + "artifact_digest": "sha256:f78c35d74928bb139e2d507424a022f2dfa78fcc2e1a67ccd4adeb0f51e0b43c", + "json_pointer": "/verified_input/bundle_hash", + "normalized_value": "sha256:5a6a675d23354d219e85daec27a89443d8648d158249e86c48b99528b4412643", + "lean_identifier": "concreteVerification.verifiedInputBundleHash" + }, + { + "artifact_path": "#resolved/certified_bundle_hash", + "artifact_digest": "sha256:e2645d479366773b0a8960c302bb765f06146cae928170ba3cf0375f5371cee3", + "json_pointer": "/#resolved/certified_bundle_hash", + "normalized_value": "sha256:5a6a675d23354d219e85daec27a89443d8648d158249e86c48b99528b4412643", + "lean_identifier": "concreteCertifiedBundleHash" + }, + { + "artifact_path": "signed_science_claim_bundle.json", + "artifact_digest": "sha256:e6419afb62cf88f2ae12f5f8bf58fc7ebde8cf7f2f28b61c9aea1a2aba889c4a", + "json_pointer": "/signed_input_bundle_hash", + "normalized_value": "sha256:5a6a675d23354d219e85daec27a89443d8648d158249e86c48b99528b4412643", + "lean_identifier": "concreteSignedInputHash" + } + ], + "signature_or_digest": "sha256:1c6b86c12bbae1504d582a5533a31eeb56add9a2e855bcb3dc8e26ddb28f7914" + }, + "pcs_projection_manifest_hash": "sha256:1c6b86c12bbae1504d582a5533a31eeb56add9a2e855bcb3dc8e26ddb28f7914", "lean_module": "PCS.Theorems", "source_repo": "https://github.com/SentinelOps-CI/pcs-core", "source_commit": "d444444444444444444444444444444444444444", - "signature_or_digest": "sha256:64f1ce1b498811b195ad97f4449b82d7aa8f0fe355637f57e0163d317b6da5f4" + "signature_or_digest": "sha256:1f8f45334155b72c9a42e20083d6ce38c36164c73e6659e3538bb2a8bcf2afb0" } diff --git a/examples/computation-release/release_chain_validation_result.v0.json b/examples/computation-release/release_chain_validation_result.v0.json index 35e748d..c4f8336 100644 --- a/examples/computation-release/release_chain_validation_result.v0.json +++ b/examples/computation-release/release_chain_validation_result.v0.json @@ -49,6 +49,16 @@ ], "responsible_component": "CertifyEdge" }, + { + "check_id": "computation_result_payload_bytes", + "description": "ResultArtifact payload path resolves and SHA-256/size match bytes", + "status": "passed", + "details": {}, + "registry_check_refs": [ + "ResultArtifact.v0.payload_bytes_match_digest" + ], + "responsible_component": "pcs-core" + }, { "check_id": "computation_code_commit_present", "description": "ComputationWitness and run receipt carry non-zero code commits", @@ -116,7 +126,7 @@ "failure_codes": [], "source_repo": "https://github.com/SentinelOps-CI/pcs-core", "source_commit": "d444444444444444444444444444444444444444", - "signature_or_digest": "sha256:77166b5a1299f16eb4d489882dcdca544c6473ede35cf35de81e302acfaa3ab3", + "signature_or_digest": "sha256:f0e8f4c003da80b5a3f34b5734fa7fd204800cef74b23070e1e0b102bfb1ce74", "deferred_registry_checks": [ { "registry_ref": "HandoffManifest.v0.handoff_input_hashes_when_validated", diff --git a/examples/computation-release/release_manifest.v0.json b/examples/computation-release/release_manifest.v0.json index cfa5c12..f729f21 100644 --- a/examples/computation-release/release_manifest.v0.json +++ b/examples/computation-release/release_manifest.v0.json @@ -13,7 +13,7 @@ }, "release_chain_validation_result": { "path": "release_chain_validation_result.v0.json", - "sha256": "sha256:655eb161766f61ba6d804767be022ff49c9199337897012f44f84631657007b7" + "sha256": "sha256:4005a96945962a6d261dece69319f957b7a9afc4d4e00d2c1a77a126983238ea" }, "canonical_signed_bundle": { "path": "signed_science_claim_bundle.json", @@ -74,7 +74,7 @@ "producer": "pcs-core", "source_repo": "https://github.com/SentinelOps-CI/pcs-core", "source_commit": "d444444444444444444444444444444444444444", - "sha256": "sha256:a2b8d26f9d0e056e7fd963156021a88b43c764c84357e2ff8ae70cd2c2d99acc" + "sha256": "sha256:b3f437010792f1f1f70ade9912374a1795c1458bf35309d6e1f888d875d09f3c" }, "computation_witness.json": { "artifact_type": "ComputationWitness.v0", @@ -82,7 +82,7 @@ "producer": "pcs-core", "source_repo": "https://github.com/SentinelOps-CI/pcs-core", "source_commit": "d444444444444444444444444444444444444444", - "sha256": "sha256:b89def93118f055abb45b8b0187e2aaeb452ec6eae502c9ba9bbf7ded83377cb" + "sha256": "sha256:944ab3a7bcc6ecd3b7b702c122fc3aa4e1048f2d885d14350894bb18618e3bf2" }, "science_claim_bundle.certified.json": { "artifact_type": "ScienceClaimBundle.v0", @@ -118,13 +118,13 @@ } }, "release_status": "Validated", - "signature_or_digest": "sha256:ebddfa4e6202cac49c4eda4bb1927ce0da70f2b0b2630f9749ab53156195b925", + "signature_or_digest": "sha256:1245f737c423f4d37416fe307ee88b4d3c78e93115f60eb9f98b18a50e4d4b97", "proof_obligation": { "path": "proof_obligation.v0.json", - "sha256": "sha256:4f87c95582fa9739b36877329fbdd604050f28613454eda1fb5e4905e10936d5" + "sha256": "sha256:a734edd00cc3f944b7dc38fa070dc289fb8d74508f6b53d7a39a7dd9daa70c85" }, "lean_check_result": { "path": "lean_check_result.v0.json", - "sha256": "sha256:b90dab592454baaddcee040200c40aaf49f309b599a81cec3dfefcf3050bdcb8" + "sha256": "sha256:964fc5a5c1ae0a6d9d1d1a181e8214d278aa52f405d4b5f3b45b1bd909fa52c7" } } diff --git a/examples/computation-release/scientific_memory_import_report.json b/examples/computation-release/scientific_memory_import_report.json index c47fd4b..6324892 100644 --- a/examples/computation-release/scientific_memory_import_report.json +++ b/examples/computation-release/scientific_memory_import_report.json @@ -22,5 +22,5 @@ "release_chain_validation_status": "ProofChecked", "release_chain_validator": "pcs-core", "release_chain_checked_at": "2026-05-18T12:00:00Z", - "release_manifest_hash": "sha256:5b92240c3350bda4baf8f0a0610fd62f327b6d12a87659f589202aa74a28d1f7" + "release_manifest_hash": "sha256:112ce429cf60ccaf7c015e17f2710af90523cfa67d13576bf7982375435d9ac4" } diff --git a/examples/labtrust-release/proof_obligation.v0.json b/examples/labtrust-release/proof_obligation.v0.json index eeeb0f3..0ad9a88 100644 --- a/examples/labtrust-release/proof_obligation.v0.json +++ b/examples/labtrust-release/proof_obligation.v0.json @@ -55,8 +55,61 @@ "artifact_type": "ScienceClaimBundle.v0" } }, + "pcs_projection_manifest": { + "schema_version": "v0", + "artifact_type": "PCSProjectionManifest.v0", + "projection_id": "pcs-projection-release-pcs-v0.1-labtrust-qc", + "release_id": "release-pcs-v0.1-labtrust-qc", + "workflow_id": "labtrust.qc_release_v0.1", + "entries": [ + { + "artifact_path": "trace_certificate.json", + "artifact_digest": "sha256:1fb39e4677c3a7838ec09079bf3d69684cd8fdb1d3e2f234ff333270920aaef7", + "json_pointer": "/certificate_id", + "normalized_value": "cert-trace-a1b8ff9d-7d5f-489c-98b1-a3a630cb87d7", + "lean_identifier": "concreteCertificate.certificateId" + }, + { + "artifact_path": "trace_certificate.json", + "artifact_digest": "sha256:1fb39e4677c3a7838ec09079bf3d69684cd8fdb1d3e2f234ff333270920aaef7", + "json_pointer": "/trace_hash", + "normalized_value": "sha256:c3e8a3dc4ad86d533de1dfa4ae7fe2a338c2cff3c945404c96a75216524d58cd", + "lean_identifier": "concreteCertificate.traceHash" + }, + { + "artifact_path": "runtime_receipt.json", + "artifact_digest": "sha256:0a421a44a1d003d4e39bee298edc329995165878bee27f5d28a9529ae8b6c027", + "json_pointer": "/trace_hash", + "normalized_value": "sha256:c3e8a3dc4ad86d533de1dfa4ae7fe2a338c2cff3c945404c96a75216524d58cd", + "lean_identifier": "concreteRuntimeReceipt.traceHash" + }, + { + "artifact_path": "verification_result.json", + "artifact_digest": "sha256:56bbe08d69049b9a254c3e25da4abefacba71312a5ddd5fbdd0e9cbb1f598ec1", + "json_pointer": "/verified_input/bundle_hash", + "normalized_value": "sha256:bb740698a01c4e918ca0f346e5bfaed83e6665da8df84e931c0d50e03ce82ffe", + "lean_identifier": "concreteVerification.verifiedInputBundleHash" + }, + { + "artifact_path": "#resolved/certified_bundle_hash", + "artifact_digest": "sha256:fff9a1b0327ca720db7d58be1d0e9c543579465bc50b59d171cc1c638983e363", + "json_pointer": "/#resolved/certified_bundle_hash", + "normalized_value": "sha256:bb740698a01c4e918ca0f346e5bfaed83e6665da8df84e931c0d50e03ce82ffe", + "lean_identifier": "concreteCertifiedBundleHash" + }, + { + "artifact_path": "signed_science_claim_bundle.json", + "artifact_digest": "sha256:68e6752de71212161bb6bf7ce1ecfa91532b03315896a6f89ecdd61fd81d6fe1", + "json_pointer": "/signed_input_bundle_hash", + "normalized_value": "sha256:bb740698a01c4e918ca0f346e5bfaed83e6665da8df84e931c0d50e03ce82ffe", + "lean_identifier": "concreteSignedInputHash" + } + ], + "signature_or_digest": "sha256:4f0af951482773af4649775ec7a14d0e5334b99c8926c90c9125d164ba8cfc0e" + }, + "pcs_projection_manifest_hash": "sha256:4f0af951482773af4649775ec7a14d0e5334b99c8926c90c9125d164ba8cfc0e", "lean_module": "PCS.Theorems", "source_repo": "https://github.com/SentinelOps-CI/pcs-core", "source_commit": "17e414501b3e1c58e8fbde1fe89a828440a945d9", - "signature_or_digest": "sha256:982d2f2f70e678eb4ac35f74e180ed1b352ab52d9119c3700e160378c7949394" + "signature_or_digest": "sha256:65cb48ad046fcab638bdce30f61d98b4abfe28819a3dcc5fc74f9f983f9f21a3" } diff --git a/examples/proof_obligation.valid.json b/examples/proof_obligation.valid.json index ad11447..6c838b1 100644 --- a/examples/proof_obligation.valid.json +++ b/examples/proof_obligation.valid.json @@ -8,7 +8,7 @@ "obligation_id": "trace_hash_alignment", "kind": "CertificateMatchesRuntime", "inputs": { - "certificate_id": "cert-trace-02b3a7c1-35f7-4d23-85c2-dfd60aff7693", + "certificate_id": "cert-trace-a1b8ff9d-7d5f-489c-98b1-a3a630cb87d7", "certificate_trace_hash": "sha256:c3e8a3dc4ad86d533de1dfa4ae7fe2a338c2cff3c945404c96a75216524d58cd", "runtime_trace_hash": "sha256:c3e8a3dc4ad86d533de1dfa4ae7fe2a338c2cff3c945404c96a75216524d58cd", "certificate_status": "CertificateChecked" @@ -55,8 +55,61 @@ "artifact_type": "ScienceClaimBundle.v0" } }, + "pcs_projection_manifest": { + "schema_version": "v0", + "artifact_type": "PCSProjectionManifest.v0", + "projection_id": "pcs-projection-release-pcs-v0.1-labtrust-qc", + "release_id": "release-pcs-v0.1-labtrust-qc", + "workflow_id": "labtrust.qc_release_v0.1", + "entries": [ + { + "artifact_path": "trace_certificate.json", + "artifact_digest": "sha256:1fb39e4677c3a7838ec09079bf3d69684cd8fdb1d3e2f234ff333270920aaef7", + "json_pointer": "/certificate_id", + "normalized_value": "cert-trace-a1b8ff9d-7d5f-489c-98b1-a3a630cb87d7", + "lean_identifier": "concreteCertificate.certificateId" + }, + { + "artifact_path": "trace_certificate.json", + "artifact_digest": "sha256:1fb39e4677c3a7838ec09079bf3d69684cd8fdb1d3e2f234ff333270920aaef7", + "json_pointer": "/trace_hash", + "normalized_value": "sha256:c3e8a3dc4ad86d533de1dfa4ae7fe2a338c2cff3c945404c96a75216524d58cd", + "lean_identifier": "concreteCertificate.traceHash" + }, + { + "artifact_path": "runtime_receipt.json", + "artifact_digest": "sha256:0a421a44a1d003d4e39bee298edc329995165878bee27f5d28a9529ae8b6c027", + "json_pointer": "/trace_hash", + "normalized_value": "sha256:c3e8a3dc4ad86d533de1dfa4ae7fe2a338c2cff3c945404c96a75216524d58cd", + "lean_identifier": "concreteRuntimeReceipt.traceHash" + }, + { + "artifact_path": "verification_result.json", + "artifact_digest": "sha256:56bbe08d69049b9a254c3e25da4abefacba71312a5ddd5fbdd0e9cbb1f598ec1", + "json_pointer": "/verified_input/bundle_hash", + "normalized_value": "sha256:bb740698a01c4e918ca0f346e5bfaed83e6665da8df84e931c0d50e03ce82ffe", + "lean_identifier": "concreteVerification.verifiedInputBundleHash" + }, + { + "artifact_path": "#resolved/certified_bundle_hash", + "artifact_digest": "sha256:fff9a1b0327ca720db7d58be1d0e9c543579465bc50b59d171cc1c638983e363", + "json_pointer": "/#resolved/certified_bundle_hash", + "normalized_value": "sha256:bb740698a01c4e918ca0f346e5bfaed83e6665da8df84e931c0d50e03ce82ffe", + "lean_identifier": "concreteCertifiedBundleHash" + }, + { + "artifact_path": "signed_science_claim_bundle.json", + "artifact_digest": "sha256:68e6752de71212161bb6bf7ce1ecfa91532b03315896a6f89ecdd61fd81d6fe1", + "json_pointer": "/signed_input_bundle_hash", + "normalized_value": "sha256:bb740698a01c4e918ca0f346e5bfaed83e6665da8df84e931c0d50e03ce82ffe", + "lean_identifier": "concreteSignedInputHash" + } + ], + "signature_or_digest": "sha256:4f0af951482773af4649775ec7a14d0e5334b99c8926c90c9125d164ba8cfc0e" + }, + "pcs_projection_manifest_hash": "sha256:4f0af951482773af4649775ec7a14d0e5334b99c8926c90c9125d164ba8cfc0e", "lean_module": "PCS.Theorems", "source_repo": "https://github.com/SentinelOps-CI/pcs-core", "source_commit": "d444444444444444444444444444444444444444", - "signature_or_digest": "sha256:c45ea66774e4090942b0aa73a2d025906022390aecb231a9a743d9651d537f4b" + "signature_or_digest": "sha256:bf78327732346bcfaf066fca287cc830dda0eebe22de702972ebc2a76ef33349" } diff --git a/examples/semantic_check_execution.valid.json b/examples/semantic_check_execution.valid.json index 05a75bf..008d02c 100644 --- a/examples/semantic_check_execution.valid.json +++ b/examples/semantic_check_execution.valid.json @@ -320,6 +320,16 @@ "allowed_to_skip": false, "enforcement_layer": "release_chain" }, + { + "registry_ref": "PFCoreBundleVerificationResult.v0.schema_valid", + "artifact_type": "PFCoreBundleVerificationResult.v0", + "check_id": "schema_valid", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false, + "enforcement_layer": "release_chain" + }, { "registry_ref": "PFCoreCapability.v0.explicit_artifact_type", "artifact_type": "PFCoreCapability.v0", @@ -410,6 +420,26 @@ "allowed_to_skip": false, "enforcement_layer": "release_chain" }, + { + "registry_ref": "PFCoreEffectFrame.v0.explicit_artifact_type", + "artifact_type": "PFCoreEffectFrame.v0", + "check_id": "explicit_artifact_type", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false, + "enforcement_layer": "release_chain" + }, + { + "registry_ref": "PFCoreEffectFrame.v0.schema_valid", + "artifact_type": "PFCoreEffectFrame.v0", + "check_id": "schema_valid", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false, + "enforcement_layer": "release_chain" + }, { "registry_ref": "PFCoreEvent.v0.explicit_artifact_type", "artifact_type": "PFCoreEvent.v0", @@ -430,6 +460,26 @@ "allowed_to_skip": false, "enforcement_layer": "release_chain" }, + { + "registry_ref": "PFCoreEvidenceManifest.v0.manifest_digest_matches", + "artifact_type": "PFCoreEvidenceManifest.v0", + "check_id": "manifest_digest_matches", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false, + "enforcement_layer": "release_chain" + }, + { + "registry_ref": "PFCoreEvidenceManifest.v0.schema_valid", + "artifact_type": "PFCoreEvidenceManifest.v0", + "check_id": "schema_valid", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false, + "enforcement_layer": "release_chain" + }, { "registry_ref": "PFCoreHandoff.v0.explicit_artifact_type", "artifact_type": "PFCoreHandoff.v0", @@ -490,6 +540,16 @@ "allowed_to_skip": false, "enforcement_layer": "release_chain" }, + { + "registry_ref": "PFCoreReleaseBundleManifest.v0.closed_evidence_digests", + "artifact_type": "PFCoreReleaseBundleManifest.v0", + "check_id": "closed_evidence_digests", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false, + "enforcement_layer": "release_chain" + }, { "registry_ref": "PFCoreReleaseBundleManifest.v0.explicit_artifact_type", "artifact_type": "PFCoreReleaseBundleManifest.v0", @@ -580,6 +640,26 @@ "allowed_to_skip": false, "enforcement_layer": "release_chain" }, + { + "registry_ref": "PFCoreTheoremManifest.v0.manifest_digest_matches", + "artifact_type": "PFCoreTheoremManifest.v0", + "check_id": "manifest_digest_matches", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false, + "enforcement_layer": "release_chain" + }, + { + "registry_ref": "PFCoreTheoremManifest.v0.schema_valid", + "artifact_type": "PFCoreTheoremManifest.v0", + "check_id": "schema_valid", + "severity": "release_blocking", + "responsible_component": "pcs-core", + "execution_required_in_release_mode": true, + "allowed_to_skip": false, + "enforcement_layer": "release_chain" + }, { "registry_ref": "PFCoreTrace.v0.claim_class_matches_assurance", "artifact_type": "PFCoreTrace.v0", @@ -901,5 +981,5 @@ "enforcement_layer": "registry_metadata" } ], - "signature_or_digest": "sha256:f12bfa9e0707e3782eea0b978b389506b77cb5dac179bfdc05302e8a22d8e43e" + "signature_or_digest": "sha256:7b9c38850357c43e738cb5f876367f5a7fbc68e1c51d7b6077d8a218199cb700" } diff --git a/examples/tool-use-release/proof_obligation.v0.json b/examples/tool-use-release/proof_obligation.v0.json index b3de603..894c47c 100644 --- a/examples/tool-use-release/proof_obligation.v0.json +++ b/examples/tool-use-release/proof_obligation.v0.json @@ -66,8 +66,82 @@ "artifact_type": "SignedScienceClaimBundle.v0" } }, + "pcs_projection_manifest": { + "schema_version": "v0", + "artifact_type": "PCSProjectionManifest.v0", + "projection_id": "pcs-projection-release-pcs-v0.1-tool-use-safety", + "release_id": "release-pcs-v0.1-tool-use-safety", + "workflow_id": "agent_tool_use.safety_v0", + "entries": [ + { + "artifact_path": "tool_use_certificate.valid.json", + "artifact_digest": "sha256:3e517b1d66dcd475d4d69f01f9b5790cf55610250c356ec3fddd96a868e02cb6", + "json_pointer": "/certificate_id", + "normalized_value": "cert-tool-use-safety-v0", + "lean_identifier": "concreteCertificate.certificateId" + }, + { + "artifact_path": "tool_use_trace.valid.json", + "artifact_digest": "sha256:a7bfd0dd83b149ef7cba53ea11ccdc04a23226a2ad39d77e125e8825dc00f253", + "json_pointer": "/trace_hash", + "normalized_value": "sha256:42ce47fca8ec10a9c65c8d9b9384c8be52094c93d46aa9705ce7c2fa2b8c89e4", + "lean_identifier": "concreteToolUseTrace.traceHash" + }, + { + "artifact_path": "tool_use_certificate.valid.json", + "artifact_digest": "sha256:3e517b1d66dcd475d4d69f01f9b5790cf55610250c356ec3fddd96a868e02cb6", + "json_pointer": "/trace_hash", + "normalized_value": "sha256:42ce47fca8ec10a9c65c8d9b9384c8be52094c93d46aa9705ce7c2fa2b8c89e4", + "lean_identifier": "concreteToolUseCertificate.traceHash" + }, + { + "artifact_path": "tool_use_trace.valid.json", + "artifact_digest": "sha256:a7bfd0dd83b149ef7cba53ea11ccdc04a23226a2ad39d77e125e8825dc00f253", + "json_pointer": "/policy_hash", + "normalized_value": "sha256:76d4443f09c6fb0d6cc7bebc5c80eae53bd008e4a212ab61d0d1844ac773b5cd", + "lean_identifier": "concreteToolUseTrace.policyHash" + }, + { + "artifact_path": "tool_use_certificate.valid.json", + "artifact_digest": "sha256:3e517b1d66dcd475d4d69f01f9b5790cf55610250c356ec3fddd96a868e02cb6", + "json_pointer": "/policy_hash", + "normalized_value": "sha256:76d4443f09c6fb0d6cc7bebc5c80eae53bd008e4a212ab61d0d1844ac773b5cd", + "lean_identifier": "concreteToolUseCertificate.policyHash" + }, + { + "artifact_path": "runtime_receipt.json", + "artifact_digest": "sha256:f95f7cfe528eb0712d3f876d3766edac6b1b859ff992448c1ca98a780e598da7", + "json_pointer": "/trace_hash", + "normalized_value": "sha256:42ce47fca8ec10a9c65c8d9b9384c8be52094c93d46aa9705ce7c2fa2b8c89e4", + "lean_identifier": "concreteRuntimeReceipt.traceHash" + }, + { + "artifact_path": "verification_result.json", + "artifact_digest": "sha256:07a5b4077f7207965f4f37ddb7bf0940394a91a433dcfc0e0de35cdf257d7366", + "json_pointer": "/verified_input/bundle_hash", + "normalized_value": "sha256:8ec0f90d0af828db78c5ada9299daea96128c4737328d40d6d6c473046d4780d", + "lean_identifier": "concreteVerification.verifiedInputBundleHash" + }, + { + "artifact_path": "#resolved/certified_bundle_hash", + "artifact_digest": "sha256:0f48b7c1d8d5436c7efd16574f0f1b88ee7e813baa6665f1cf1d884a47fe8b2e", + "json_pointer": "/#resolved/certified_bundle_hash", + "normalized_value": "sha256:8ec0f90d0af828db78c5ada9299daea96128c4737328d40d6d6c473046d4780d", + "lean_identifier": "concreteCertifiedBundleHash" + }, + { + "artifact_path": "signed_science_claim_bundle.json", + "artifact_digest": "sha256:b530e409f16527c66cdb6d824af399cbe45d38d78fb2c401bba3518c02ce977a", + "json_pointer": "/signed_input_bundle_hash", + "normalized_value": "sha256:8ec0f90d0af828db78c5ada9299daea96128c4737328d40d6d6c473046d4780d", + "lean_identifier": "concreteSignedInputHash" + } + ], + "signature_or_digest": "sha256:ff82279a183c8783c5fc0f63a3847202b1dc8b95eaf8da816ccf62d2d8cda354" + }, + "pcs_projection_manifest_hash": "sha256:ff82279a183c8783c5fc0f63a3847202b1dc8b95eaf8da816ccf62d2d8cda354", "lean_module": "PCS.Theorems", "source_repo": "https://github.com/SentinelOps-CI/pcs-core", "source_commit": "d444444444444444444444444444444444444444", - "signature_or_digest": "sha256:69335e62a2eb10f6475b5716c00137af4f691b906731807eb9eafe8658d32f5c" + "signature_or_digest": "sha256:251a6ee6ca8d547e4e7d47f25649c470fcd37ffaff23470aa8a6cfae35b06b6a" } diff --git a/python/pcs_core/computation_release_chain.py b/python/pcs_core/computation_release_chain.py index bcc5399..aac4cd1 100644 --- a/python/pcs_core/computation_release_chain.py +++ b/python/pcs_core/computation_release_chain.py @@ -274,6 +274,30 @@ def _validate_computation_release_chain_impl(directory: Path) -> list[ReleaseCha else: issues.append(_issue("schema_validation_failed", msg)) + from pcs_core.computation_validate import ( + DUPLICATE_RESULT_DECLARATION, + PAYLOAD_DIGEST_MISMATCH, + PAYLOAD_MISSING, + PAYLOAD_PATH_UNSAFE, + PAYLOAD_SIZE_MISMATCH, + validate_result_payloads_in_release, + ) + + for msg in validate_result_payloads_in_release(base): + if DUPLICATE_RESULT_DECLARATION in msg: + code = DUPLICATE_RESULT_DECLARATION + elif PAYLOAD_DIGEST_MISMATCH in msg: + code = PAYLOAD_DIGEST_MISMATCH + elif PAYLOAD_SIZE_MISMATCH in msg: + code = PAYLOAD_SIZE_MISMATCH + elif PAYLOAD_PATH_UNSAFE in msg: + code = PAYLOAD_PATH_UNSAFE + elif PAYLOAD_MISSING in msg: + code = PAYLOAD_MISSING + else: + code = "schema_validation_failed" + issues.append(_issue(code, msg, artifact=RESULT_ARTIFACT_FILE)) + for name in COMPUTATION_RELEASE_PCS_ARTIFACTS: path = base / name if not path.is_file(): diff --git a/python/pcs_core/release_chain_report.py b/python/pcs_core/release_chain_report.py index c58a2f5..37589d0 100644 --- a/python/pcs_core/release_chain_report.py +++ b/python/pcs_core/release_chain_report.py @@ -97,15 +97,16 @@ def build_release_chain_validation_result( if not issues: result_path = base / "release_chain_validation_result.v0.json" profile_matches_on_disk = ( - is_tool_use_release_directory(base) and profile_id == TOOL_USE_WORKFLOW_PROFILE_ID - ) or ( - is_computation_release_directory(base) and profile_id == COMPUTATION_WORKFLOW_PROFILE_ID - ) - if ( profile_id == LABTRUST_WORKFLOW_PROFILE_ID - and profile_matches_on_disk - and result_path.is_file() - ): + or ( + is_tool_use_release_directory(base) and profile_id == TOOL_USE_WORKFLOW_PROFILE_ID + ) + or ( + is_computation_release_directory(base) + and profile_id == COMPUTATION_WORKFLOW_PROFILE_ID + ) + ) + if profile_matches_on_disk and result_path.is_file(): try: on_disk = json.loads(result_path.read_text(encoding="utf-8")) except json.JSONDecodeError: diff --git a/python/tests/test_phase1_protocol_hardening.py b/python/tests/test_phase1_protocol_hardening.py index a568a1c..3796ad0 100644 --- a/python/tests/test_phase1_protocol_hardening.py +++ b/python/tests/test_phase1_protocol_hardening.py @@ -142,10 +142,12 @@ def test_canonical_json_v1_shared_vectors() -> None: def test_number_policy_rejects_floats_and_unsafe_ints() -> None: - with pytest.raises(CanonicalizationError): + with pytest.raises(CanonicalizationError) as float_exc: assert_canonical_number_policy({"x": 1.5}) - with pytest.raises(CanonicalizationError): + assert float_exc.value.code == "float_prohibited" + with pytest.raises(CanonicalizationError) as int_exc: assert_canonical_number_policy({"x": SAFE_INTEGER_MAX + 1}) + assert int_exc.value.code == "integer_out_of_range" assert_canonical_number_policy({"x": SAFE_INTEGER_MAX, "y": 0}) From ad086a7e3b8f8cb565a443214b3a6ed5d22a2748 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:03:13 -0700 Subject: [PATCH 23/24] Update trust docs and gap audit for evidence hardening. Document claim boundaries, runtime semantics, and remaining gaps so release operators can map new gates to the trust model without reading code diffs. --- README.md | 11 +- docs/pf-core/certifyedge-ci.md | 12 +- docs/pf-core/certifyedge.md | 7 + docs/pf-core/claim-boundary.md | 26 +++ docs/pf-core/current-gap-audit.md | 160 +++++++++++++++++- docs/pf-core/non-interference.md | 4 +- docs/pf-core/runtime-semantics.md | 66 +++++--- docs/pf-core/trusted-boundary.md | 2 +- docs/security-governance.md | 88 ++++++++-- docs/trust-model.md | 17 +- .../tool_use_trace_compiled/README.md | 6 +- 11 files changed, 345 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index fbb9354..0f60cea 100644 --- a/README.md +++ b/README.md @@ -175,10 +175,11 @@ PF-Core is the trace-safety kernel in `pcs-core`: Python deciders aligned with L | Topic | Detail | |-------|--------| -| Tool-use certificate mode | **`TraceSafeRCertificate`** is the sole release-grade `LeanKernelChecked` path for tool-use traces: set `required_certificate_mode` on the trace, resolve via catalog `workflow_certificate_modes`, or `WorkflowProfile` policy. Release-grade mode resolution skips the sibling-file heuristic; base `TraceSafeCertificate` is legacy/non–tool-use only. | +| Public claim surface | Machine-readable status in [`schemas/pf_core.certificate_mode_status.json`](schemas/pf_core.certificate_mode_status.json): **`TraceSafeRCertificate`** = sole `release_candidate` for tool-use; **`TraceSafeCertificate`** = `legacy` (non–tool-use); **HandoffSafe / ContractChecked / EffectFrame / FramePreserved** = `disabled` (fail closed on public CLI and `--release-grade`; handoff/contract/effect-frame/transition evidence repaired but public enablement deferred); **CompositionalExtensionCertificate** = `experimental` (A6 `CompositionalSafeExtension`); scaffolded **TracePrefixSafeCertificate** / **DenyClosedCertificate** are not issuable; external **CertificateChecked** = `preview`. | +| Tool-use certificate mode | Release-grade tool-use must resolve to `TraceSafeRCertificate` via `required_certificate_mode`, catalog `workflow_certificate_modes`, or `WorkflowProfile`. Sibling-file heuristic is skipped under `--release-grade`. | | Capability catalog | `catalog/pf_core.catalog.json` is the single source; `python/scripts/gen_pf_core_catalog.py` generates Python, Rust, TypeScript, and Lean maps (no manual `TOOL_NAME_MAP` drift). | -| Release bundles | `pcs pf-core bundle-release` copies trace, certificate, proof, **`lean-toolchain`**, **`lean/lakefile.lean`**, **`lean/lake-manifest.json`**, and a **`kernel/`** tree; `kernel_manifest.json` lists per-file `sha256:` digests. `validate-bundle` checks the bundle alone (hashes from bundled contents only). | -| CertifyEdge | Three attestation classes: **live** (release gate), **stub** (`stub://`, format validation / staging with explicit flag), **mock** (`mock://`, dev CI only). Release path rejects mock; stub requires `PF_CORE_CERTIFYEDGE_ALLOW_STUB=1` when `require_live`. | +| Release bundles | `pcs pf-core bundle-release` copies trace, certificate, optional **LeanCheckResult** (`--lean-check-result`), proof, **semantic projection**, **theorem manifest**, selected **evidence/** artifacts + `evidence_manifest.json`, **`lean-toolchain`**, **`lean/lakefile.lean`**, **`lean/lake-manifest.json`**, and a **`kernel/`** tree; `kernel_manifest.json` lists per-file `sha256:` digests. `validate-bundle` is the lower-cost structural check; **stable releases must run `verify-bundle`** (projection replay, theorem reconstruction, Lean compile against the bundled kernel). Preview/release workflows pass `--result-out` from lean-check into `--lean-check-result`. | +| CertifyEdge | Three attestation classes: **live** (release gate), **stub** (`stub://`, format validation / staging with explicit flag), **mock** (`mock://`, dev CI only). Release path rejects mock; stub requires `PF_CORE_CERTIFYEDGE_ALLOW_STUB=1` when `require_live`. External `CertificateChecked` remains **preview** until CertifyEdge is pinned. | ### PF-Core quick start @@ -194,13 +195,15 @@ pcs pf-core bundle-release \ --cert /tmp/pfcore-cert.json \ --out /tmp/pfcore-bundle pcs pf-core validate-bundle /tmp/pfcore-bundle +# Stable releases must also independently verify: +pcs pf-core verify-bundle /tmp/pfcore-bundle # Full local release-grade matrix (pytest, lake PFCore+PCS, conformance, CertifyEdge mock+stub) powershell -File scripts/pf-core-release-grade-local.ps1 # Windows bash scripts/pf-core-release-grade-local.sh # Linux / macOS ``` -Further PF-Core docs: [docs/pf-core/merge-readiness.md](docs/pf-core/merge-readiness.md), [docs/pf-core/release-checklist.md](docs/pf-core/release-checklist.md), [docs/pf-core/claim-boundary.md](docs/pf-core/claim-boundary.md). +Further PF-Core docs: [docs/pf-core/merge-readiness.md](docs/pf-core/merge-readiness.md), [docs/pf-core/release-checklist.md](docs/pf-core/release-checklist.md), [docs/pf-core/operator-release-gates.md](docs/pf-core/operator-release-gates.md), [docs/pf-core/claim-boundary.md](docs/pf-core/claim-boundary.md). PF-Core provides machine-checkable trace certificates for a bounded, catalog-driven, resource-pattern-scoped subset of agentic tool-use traces. It does not claim global AI safety, full contract discharge in the Lean kernel, or operational guarantees for deployed agents outside the stated catalog and claim class. diff --git a/docs/pf-core/certifyedge-ci.md b/docs/pf-core/certifyedge-ci.md index df62a45..465fbb1 100644 --- a/docs/pf-core/certifyedge-ci.md +++ b/docs/pf-core/certifyedge-ci.md @@ -52,12 +52,20 @@ Preferred production path: pin an immutable CertifyEdge artifact in ```bash export PCS_RELEASE_MODE=release bash scripts/provision-certifyedge.sh -export PF_CORE_CERTIFYEDGE_CLI="$PWD/.tools/certifyedge/certifyedge" +# ALWAYS source the machine-readable env file (do not blank it with an empty secret): +set -a && . .tools/certifyedge/provision.env && set +a +export PF_CORE_CERTIFYEDGE_CLI ``` -Approved strategies: `oci_digest`, `signed_binary`, `source_commit_build`. +`provision.env` records executable path, binary digest, version, pin identity, +provision strategy, and trust grade. Workflows must source this file. + +Approved production strategies: `oci_digest`, `signed_binary`, `source_commit_build`. Release mode fails closed when the pin is `unpinned` — do not invent fake digests. +`dev_fixture` is test/preview only (`trust_grade=untrusted_development`). +Arbitrary executables on PATH that do not match the pin digest are classified +`untrusted_development` even when they exit 0. Fallback (documented staging only): install [CertifyEdge](https://github.com/fraware/CertifyEdge) per upstream instructions and set `PF_CORE_CERTIFYEDGE_CLI`. diff --git a/docs/pf-core/certifyedge.md b/docs/pf-core/certifyedge.md index 899a22f..2d4ed96 100644 --- a/docs/pf-core/certifyedge.md +++ b/docs/pf-core/certifyedge.md @@ -11,9 +11,16 @@ PF-Core integrates the external [CertifyEdge](https://github.com/fraware/Certify | `PF_CORE_CERTIFYEDGE_MOCK` | `1`, `true`, `yes` | unset | Forces mock mode (same as `MODE=mock`) | | `PF_CORE_CERTIFYEDGE_REQUIRE_LIVE` | `1`, `true`, `yes` | unset | Fail when live CLI absent (`--require-live` alias) | | `PF_CORE_CERTIFYEDGE_ALLOW_STUB` | `1`, `true`, `yes` | unset | Allow format stub on `require_live` (staging only) | +| `PCS_CERTIFYEDGE_PROVISION_ENV` | path | `.tools/certifyedge/provision.env` | Machine-readable provision output | Legacy alias: `PCS_CERTIFYEDGE_MOCK=1` (still honored). +Provisioning (`scripts/provision-certifyedge.sh`) always writes `provision.env` with executable +path, binary digest, version, pin identity, strategy, and `trust_grade` +(`pinned` | `untrusted_development` | `unpinned`). Workflows must source that file and must +not overwrite `PF_CORE_CERTIFYEDGE_CLI` with an empty secret. Arbitrary PATH checkers that +do not match the pin digest remain `untrusted_development` even when exit 0. + ### Modes - **auto** — use live CLI when found on PATH (or `PF_CORE_CERTIFYEDGE_CLI`); fail closed when absent. diff --git a/docs/pf-core/claim-boundary.md b/docs/pf-core/claim-boundary.md index 7ffb773..9bc1424 100644 --- a/docs/pf-core/claim-boundary.md +++ b/docs/pf-core/claim-boundary.md @@ -98,6 +98,32 @@ Successful lean-check writes a certificate with matching `claim_class`, `assumpt **Release-grade tool-use policy:** Under `pcs pf-core lean-check --release-grade`, tool-use traces must resolve to `TraceSafeRCertificate` (via `required_certificate_mode`, WorkflowProfile, or catalog `workflow_certificate_modes`). The sibling `tool_use_trace.json` heuristic is disabled. Successful `LeanKernelChecked` certificates require passed `concrete_trace_safe_r` and `concrete_trace_safe_r_prop` obligations; base `traceSafeD` alone is insufficient. Base `TraceSafeCertificate` remains for legacy / non–tool-use traces only. Refinement to base `TraceSafe` is documented via `traceSafeR_implies_traceSafe`. +### Public certificate-mode claim surface (A0) + +Authoritative machine-readable table: [`schemas/pf_core.certificate_mode_status.json`](../../schemas/pf_core.certificate_mode_status.json). + +| Mode / claim | Status | `allowed_issuance` | Notes | +|--------------|--------|--------------------|-------| +| `TraceSafeRCertificate` | `release_candidate` | true | Sole RC for tool-use `LeanKernelChecked` | +| `TraceSafeCertificate` | `legacy` | true | Non–tool-use / legacy only | +| `HandoffSafeCertificate` | `disabled` | false | Fail closed on public CLI and `--release-grade` | +| `ContractCheckedCertificate` | `disabled` | false | Evidence repair landed; public issuance still fail-closed until enablement pass (`--allow-non-public-modes`) | +| `EffectFrameCertificate` | `disabled` | false | Evidence redesign landed; public issuance still fail-closed (`--allow-non-public-modes`) | +| `FramePreservedCertificate` | `disabled` | false | Transition redesign landed; public issuance still fail-closed (`--allow-non-public-modes`) | +| `CompositionalExtensionCertificate` | `experimental` | true | A6 substantive predicate (`CompositionalSafeExtension`); not `--release-grade` | +| External `CertificateChecked` | `preview` | true | Until CertifyEdge pin | + +Scaffolded (not in `CERTIFICATE_MODES` issuance surface; see status table `scaffolded_modes`): + +| Mode / claim | Status | Notes | +|--------------|--------|-------| +| `TracePrefixSafeCertificate` | `experimental` alias | Prefix-only `TracePrefixSafe` / `TraceSafe` chaining — narrower than A6 | +| `DenyClosedCertificate` | `disabled` | Runtime evidence insufficient for post-deny effect closure; use `EventSafeDenyClosed` declared-footprint refinement only | + +Issuance of disabled modes fails closed under the default public `pcs pf-core lean-check` CLI and under `--release-grade`. Codegen/fixture paths may still exercise disabled modes for conformance. + +Non-interference claim boundary: prefer **`TenantProjectionIsolation`** (proved, single-trace observational). Do not use bare “non-interference” without naming the formal predicate; `PairedExecutionNonInterference` remains unproved scaffolding. + Reference: `lean/PFCore/ResourcePattern.lean`, Python `resource_matches_pattern`. ### Mapping guidance for PF-Core certificates diff --git a/docs/pf-core/current-gap-audit.md b/docs/pf-core/current-gap-audit.md index ea450a8..53d4437 100644 --- a/docs/pf-core/current-gap-audit.md +++ b/docs/pf-core/current-gap-audit.md @@ -79,17 +79,18 @@ Summary of gaps between the PF-Core vision and the current `pcs-core` repository | H2 — `TenantIsolation` + `TraceCrossTenantSafe` | Partial | `traceSafe_implies_tenant_isolation`; covert channels / timing open | | H3 — `runtimeRoleMap` Python parity | Done | `RoleMap.lean` + `test_pf_core_research.py` | | H4 — Research catalog tests | Done | `test_pf_core_research.py`, `test_pf_core_research_grade.py`, catalog updates | -| H5 — Effect frames | Done | `EffectFrame.lean`; write exclusion under explicit footprint alignment | +| H5 — Effect frames | Done | `EffectFrame.lean` + independent `PFCoreEffectFrame.v0` certificate binding (PR4); write exclusion under explicit footprint alignment | | H6 — Contract refinement | Done | `ContractRefinement`, `contract_refinement_preserves_trace_safe` | | H7 — Replay claim boundary | Done | `replay_preserves_claim_boundary` in `pf_core_replay.py` | ## Remaining research (deferred) -1. **Paired-execution / full global cross-tenant non-interference** — `TenantProjectionIsolation` proved for single recorded traces; covert channels, timing, scheduler adversaries, and `PairedExecutionNonInterference` remain open (`non-interference.md`, `runtime-semantics.md`). -2. **Write footprint ↔ effect linkage** — `WriteFootprintRequiresWriteEffect` explicit; derived from `ActionAdmissible` + `KnownCapabilityEffect` for catalog capabilities. -3. **Resource-pattern scope in Lean** — Partial: `ResourcePattern.lean` (`TraceSafeR`, `ActionAdmissibleWithResourcePattern`); Python/Rust/TS runtime deciders (`trace_safe_rd` / trace hash-chain `validate_resource_scope`); optional codegen `concrete_trace_safe_r*` when allow events pass pattern scope; base `TraceSafe` kernel unchanged. -4. **Full provability-fabric-core live adapter orchestration** — hash parity covered natively via adapter CI script. -5. **Full agent runtime, MCP, NL policy, model safety** — out of scope. +1. **Paired-execution / full global cross-tenant non-interference** — `TenantProjectionIsolation` proved for single recorded traces; covert channels, timing, scheduler adversaries, and `PairedExecutionNonInterference` remain open (`non-interference.md`, `runtime-semantics.md`). Bare “non-interference” claims must name the formal predicate. +2. **DenyClosedCertificate** — declared-footprint `EventSafeDenyClosed` proved; post-deny runtime effect closure not yet evidenced; mode scaffolded/disabled. +3. **Write footprint ↔ effect linkage** — `WriteFootprintRequiresWriteEffect` explicit; derived from `ActionAdmissible` + `KnownCapabilityEffect` for catalog capabilities. +4. **Resource-pattern scope in Lean** — Done for A11 parity: Lean separates `ActionAdmissible` vs `ActionAdmissibleWithResourcePattern`; Python/Rust/TS base deciders exclude pattern scope; refined `*SafeR` include it. Shared vector: TraceSafe=true, TraceSafeR=false when URI is outside pattern. +5. **Full provability-fabric-core live adapter orchestration** — hash parity covered natively via adapter CI script. +6. **Full agent runtime, MCP, NL policy, model safety** — out of scope. ## External audit remediation (2026-06) @@ -127,7 +128,7 @@ Summary of gaps between the PF-Core vision and the current `pcs-core` repository |------|--------|-------| | I1 — `pfcore_kernel_hash` + full `lean_environment_hash` | Done | PF-Core `*.lean` bytes + toolchain + lake files | | I2 — Event sequence order validator | Done | `validate_event_sequence_order`; wired to validate-trace / lean-check | -| I3 — Release bundle CLI | Done | `bundle-release`, `validate-bundle`, manifest hashes | +| I3 — Release bundle CLI | Done | `bundle-release`, `validate-bundle`, `verify-bundle`; closed projection/evidence manifests | | I4 — Compositional `certificate_mode` | Done | Six modes; `--certificate-mode` on lean-check; codegen obligations | | I5 — Resource pattern Lean subset | Done | `ResourceWithinCapabilityPattern` in `ResourcePattern.lean` | | I6 — Release gates | Done | `pf-core-release-gate.yml`; adapter blocking on main | @@ -237,6 +238,151 @@ Summary of gaps between the PF-Core vision and the current `pcs-core` repository | 6.2 Bundle-bound ExternalAttestation.v0 | Done | Schema + `pcs pf-core attest-bundle`; digests vs ed25519 modes explicit | | 6.3 Unified release/preview gates | Done | `release.yml` + `pf-core-release-gate.yml` + `scripts/release-gate.sh`; preview absence notice | +## PR14 — Authenticated integrity + CertifyEdge pin (B6 + B7) + +| Item | Status | Notes | +|------|--------|-------| +| ArtifactIntegrity.v1 Ed25519 ops | Done | `pcs_core/artifact_integrity.py` (PyNaCl); domain-separated `PCS:::` | +| TrustedKeyRegistry.v0 | Done | Schema + validity intervals + revocation; `PCS_TRUSTED_KEY_REGISTRY` | +| Signature timestamp policy | Done | future skew + max age + key validity window | +| Release-root signature verify | Done | `verify_release_root_signatures` for stable artifact types | +| ExternalAttestation ed25519_signed | Done | Real sign/verify when seed + registry configured; digest-bound remains default | +| CertifyEdge pin machinery | Done | `provision.env` (path/digest/version/pin/strategy/trust grade); workflows source it | +| Fail-closed unpinned release | Done | `pins/certifyedge.json` remains `status=unpinned` (no fake production digest) | +| Dev fixture | Done | `dev_fixture` + `scripts/certifyedge-dev-fixture.py` for preview/tests only | +| Bundle pin record | Done | `certifyedge_pin.json` copied into release bundles | +| Arbitrary checker classification | Done | `trust_grade=untrusted_development` when digest ≠ pin | + +### Remaining honest deferrals (post PR14) + +- Org production ed25519 signing keys / published `TrustedKeyRegistry.v0` allowlist (operators must provision; pcs-core does not ship private keys). +- Real CertifyEdge OCI/binary/source pin (`status=pinned` with immutable digest) — blocked on upstream publishable artifact. +- External `CertificateChecked` remains preview until a production CertifyEdge pin exists. +- SLSA / consumer provenance verification remains PR15. +## PR1 — Release execution + claim-surface policy (B0 + A0) + +| Item | Status | Notes | +|------|--------|-------| +| A0 mode status table | Done | `schemas/pf_core.certificate_mode_status.json`; TraceSafeR=`release_candidate`; TraceSafe=`legacy`; Handoff/Contract/EffectFrame/FramePreserved=`disabled`; Compositional=`experimental`; external CertificateChecked=`preview` | +| Disabled modes fail closed | Done | Public `pcs pf-core lean-check` + `--release-grade` reject `allowed_issuance=false` | +| lean-check artifact paths | Done | Deterministic paths printed/returned for certificate, LeanCheckResult, proof, projection/manifest placeholders | +| Release workflow lean-check-result | Done | `release.yml` + `pf-core-release-gate.yml` pass `--result-out` into `bundle-release --lean-check-result` | +| Preview dispatch path | Done | lean-check → bundle-release → validate-bundle → absence/attest → upload | + +### Remaining honest deferrals (post PR1) + +- Specialized modes remain disabled until evidence-fidelity PRs complete enablement (handoff, contract, effect frame, transitions repaired; public enablement deferred). +- External CertificateChecked remains preview until CertifyEdge pin (PR 14). +- Semantic projection and theorem manifest artifacts are written during lean-check and required in LeanKernelChecked closed bundles (`verify-bundle`). + +## PR2 — Handoff evidence repair (A1 + A2) + +| Item | Status | Notes | +|------|--------|-------| +| A1 `PFCoreResolvedEvidence` | Done | Single resolve in lean-check; threaded into projection/codegen/certificate | +| Explicit `evidence_selection.handoff_ids` | Done | Sibling auto-scan rejected for `HandoffSafeCertificate` | +| Projected `delegated_capabilities` | Done | Required non-empty; catalog-validated; Lean binds projected ID sequence | +| Public status | Disabled | Issuable only with `--allow-non-public-modes` until enablement pass | + +## PR3 — Contract evidence repair (A3) + +| Item | Status | Notes | +|------|--------|-------| +| Projection `semantics_layer` | Done | Replaces `field_semantics`; materialized per-field records after defaults | +| Per-field records | Done | section, field, normalized_value, effective_layer + lean theorem / runtime check id / out-of-scope rationale | +| Explicit `evidence_selection.contract_ids` | Done | Required for `ContractCheckedCertificate`; no sibling auto-pick | +| Certificate binding | Done | selected_contract_ids, contract_source_file_digests, contract_evidence_digest, contract_theorem_names; projection IDs must match | +| Canonical fixture e2e | Done | `examples/pf-core-valid/contract_checked/` via public CLI + `--allow-non-public-modes` + semantic validation | +| Public status | Disabled | Kept disabled for public RC consistency; issuance works with `--allow-non-public-modes` | + +### Remaining honest deferrals (post PR3) + +- `ContractCheckedCertificate` / `HandoffSafeCertificate` remain disabled for public RC until an enablement pass. +- Effect-frame and frame-preserved modes remain disabled for public RC (redesign landed; enablement deferred). +- External CertificateChecked remains preview until CertifyEdge pin (PR 14). + +## PR4 — Effect-frame redesign (A4) + +| Item | Status | Notes | +|------|--------|-------| +| `PFCoreEffectFrame.v0` schema + artifact | Done | frame_id, allowed_effect_kinds, resource_constraints, workflow/contract scope, source_policy_ref, provenance, integrity digest; `frame_scope_policy=global` | +| Non-tautological codegen | Done | `actionEffectsInFrameD concreteAction concreteDeclaredFrame = true`; frame emitted from independent artifact (never `action.effects`) | +| v0 multi-event policy | Done | One global frame per trace (documented on schema + fixture README) | +| Certificate path + digest | Done | `effect_frame_id`, `effect_frame_path`, `effect_frame_digest` on `PFCoreCertificate.v0` | +| Resolved evidence wiring | Done | `evidence_selection.effect_frame_id` required for `EffectFrameCertificate` | +| Adversarial extra-effect fail | Done | Action with undeclared effect omitted from frame → resolution/codegen fail | +| Public status | Disabled | Kept disabled for public RC; issuance works with `--allow-non-public-modes` | + +### Remaining honest deferrals (post PR4) + +- `EffectFrameCertificate` / `ContractCheckedCertificate` / `HandoffSafeCertificate` remain disabled for public RC until an enablement pass. +- Frame-preserved mode remains disabled pending PR5 transition redesign. +- External CertificateChecked remains preview until CertifyEdge pin (PR 14). + +## PR5 — Transition-certificate redesign (A5) + +| Item | Status | Notes | +|------|--------|-------| +| Explicit `stepState` witnesses | Done | Allow: `stepState pre = some post`; deny: identity; no `applyEvent` fallback in codegen | +| FramePreserved obligations | Done | Valid initial; allow applications; deny identity; frameValid at each post-state; resource/active-principal/tenant/capability-frame update equalities | +| Resolved evidence wiring | Done | `initial_state` + `transition_states` + `transition_chain_digest` on certificate | +| Cross-tenant no-op reject | Done | Sequential cross-tenant allow fixture rejected (`stepState` none) | +| Public status | Disabled | Kept disabled for public RC; issuance works with `--allow-non-public-modes` | + +### Remaining honest deferrals (post PR5) + +- Specialized modes remain disabled for public RC until an enablement pass. +- External CertificateChecked remains preview until CertifyEdge pin (PR 14). +- Compositional redesign (A6) landed as experimental: `CompositionalSafeExtension` + codegen operational application; still not `release_candidate`. +- `DenyClosedCertificate` remains scaffolded/disabled (declared-footprint `EventSafeDenyClosed` only). +- `TrustedInstrumentation` is attested-execution (not mere `ObservationsAgree`); authenticity still assumption-discharged. +- Paired-execution NI remains unproved scaffolding (`PairedExecutionNonInterference`). + +## PR6 — Theorem manifest and proof binding (A7 + A8) + +| Item | Status | Notes | +|------|--------|-------| +| `PFCoreTheoremManifest.v0` | Done | Structured IR with normalized propositions; distinct from inventory hash | +| Extended `verify-proof-binding` | Done | Schema/integrity, digests, names, propositions, witness, projection replay, evidence digests | + +## PR7 — Closed semantic-projection bundle (A9 + A10) + +| Item | Status | Notes | +|------|--------|-------| +| Always write `PFCoreSemanticProjection.v0.json` | Done | Written during lean-check whenever codegen produced a projection | +| Closed release manifest fields | Done | `semantic_projection_*`, `theorem_manifest_*`, `evidence_manifest_*`, `lean_check_result_*` path+hash | +| `evidence/` + `PFCoreEvidenceManifest.v0` | Done | Selected contract/handoff/effect-frame/policy artifacts + per-file digests | +| `pcs pf-core verify-bundle` | Done | Manifest/digest checks, projection replay, theorem reconstruct, toolchain select, bundled-kernel compile, attestation, digest-bound result | +| `validate-bundle` vs `verify-bundle` | Done | validate=structural; stable releases require verify-bundle | + +### Remaining honest deferrals (post PR7) + +- Specialized modes remain disabled for public RC until an enablement pass. +- External CertificateChecked remains preview until CertifyEdge pin (PR 14). +- Mandatory PCS projection binding (PR9) remains ahead. + +## PR8 — Base/refined cross-language decider parity (A11) + +| Item | Status | Notes | +|------|--------|-------| +| Base `action_admissible_d` excludes resource pattern | Done | Python/Rust/TS mirrors Lean `ActionAdmissible` | +| Refined `action_admissible_with_resource_pattern_d` combines both | Done | Distinct event/trace deciders (`*Safe` vs `*SafeR`) | +| Shared differential vector | Done | `examples/pf-core-invalid/resource_scope_violation`: TraceSafe=true, TraceSafeR=false in Lean/Python/Rust/TS | + +## PR15 — Real release provenance (B8) + +| Item | Status | Notes | +|------|--------|-------| +| Replace provenance stub | Done | `actions/attest-build-provenance` + `actions/attest-sbom` (SHA-pinned) | +| `ReleaseProvenanceBinding.v0` | Done | Binds commit, workflow/builder, lockfiles, verifier image digest, wheels, SBOM, bundle root | +| Consumer verification job | Done | `release-provenance.yml` + `release.yml` download artifact → `scripts/verify-release-provenance.sh` (+ `gh attestation verify` when signed) | +| Fail-closed gated honesty | Done | `attestation.status=gated` + `PROVENANCE_ATTESTATION_GATED.json` when org/plan blocks signing; stable requires signed unless `PCS_PROVENANCE_ALLOW_GATED` | + +### Remaining honest deferrals (post PR15) + +- Signed attestations still require GitHub artifact-attestation availability (public repos OK on current plans; private needs GHEC). Until org enables them, set `PCS_PROVENANCE_ALLOW_GATED=true` only as a temporary bridge — do not claim SLSA-attested releases while gated. +- OCI cosign image signing remains a separate org-infra gap (see `docs/distribution.md` / `docs/security-governance.md`). + ## Phase 7 — Verification quality (2026-07) | Item | Status | Notes | diff --git a/docs/pf-core/non-interference.md b/docs/pf-core/non-interference.md index 736abf6..bfdac15 100644 --- a/docs/pf-core/non-interference.md +++ b/docs/pf-core/non-interference.md @@ -6,7 +6,9 @@ This document states what PF-Core **proves** about tenant isolation versus what PF-Core does **not** claim global non-interference across tenants, covert channels, or arbitrary compositional invariants. The Lean modules `lean/PFCore/NonInterference.lean` and `lean/PFCore/Observational.lean` formalize **conservative tenant isolation and observational projection** aligned with runtime checks. -**User-facing name:** The proved single-trace observational property is **`TenantProjectionIsolation`**. Prefer that name in documentation and claims. The Lean abbreviation `NonInterference` is a compatibility alias only. Paired-execution **`NonInterference`** is reserved for a future schema/kernel version (`lean/PFCore/PairedExecution.lean`); it is **not proved**. +**User-facing name:** The proved single-trace observational property is **`TenantProjectionIsolation`**. Prefer that name in documentation and claims. The Lean abbreviation `NonInterference` is a compatibility alias only. Paired-execution **`PairedExecutionNonInterference`** is scaffolding only (`lean/PFCore/PairedExecution.lean`); it is **not proved**. + +**Claim boundary (C3):** No stable certificate or public claim may use the bare phrase “non-interference” without naming which formal predicate is meant (`TenantProjectionIsolation` vs `PairedExecutionNonInterference`). **Observational equivalence does not imply covert channels are absent.** Two traces may agree on low projections while differing on denied events, cross-tenant attempts, timing, or side channels not recorded in PF-Core events. diff --git a/docs/pf-core/runtime-semantics.md b/docs/pf-core/runtime-semantics.md index d1a9500..b519300 100644 --- a/docs/pf-core/runtime-semantics.md +++ b/docs/pf-core/runtime-semantics.md @@ -1,21 +1,23 @@ -# PF-Core runtime semantics (Phase 5) +# PF-Core runtime semantics (Phase 5 + Workstream C) -This document states the Phase 5 execution-observation and deny-path model, and -how it relates to proved Lean predicates versus trusted instrumentation. +This document states the execution-observation and deny-path model, and how it +relates to proved Lean predicates versus trusted instrumentation. ## Scope Phases 0–4 establish declared capabilities, declared effects, effect frames, and -trace safety. Phase 5 adds: +trace safety. Phase 5 / Workstream C adds: | Item | Lean | Status | |------|------|--------| -| Observed effects | `ObservedEffect`, `TrustedInstrumentation` | **Proved** undeclared-sensitive observation lemmas under instrumentation assumption | -| Deny-path closedness | `EventSafeDenyClosed` | **Proved** refinement of `EventSafe` (optional) | -| Tenant projection isolation | `TenantProjectionIsolation` | **Proved** (renamed observational property) | +| Observed effects | `ObservedEffect`, separated soundness/completeness/attribution/authenticity | **Proved** undeclared-sensitive observation lemmas under observation soundness | +| Attested execution | `AttestedExecution` / `TrustedInstrumentation` | **Defined**; authenticity is an assumption switch, not proved from producer logs | +| Deny-path closedness | `EventSafeDenyClosed` | **Proved** refinement of `EventSafe` (declared footprint only) | +| `DenyClosedCertificate` | scaffolded | **Disabled** — runtime evidence insufficient for post-deny effect closure | +| Tenant projection isolation | `TenantProjectionIsolation` | **Proved** (single-trace observational) | | Paired-execution NI | `PairedExecutionNonInterference` | **Scaffolding only** — not proved; not a release claim | -## 5.1 Observed effects and instrumentation +## 5.1 Observed effects and instrumentation (C1) ```lean structure ObservedEffect where @@ -24,8 +26,19 @@ structure ObservedEffect where resultDigest : Option Hash ``` -Agreement (`ObservationsAgree` / `TrustedInstrumentation`) requires every -observed kind (and optional resource) to lie in the declared action footprint. +### Separated predicates + +| Predicate | Meaning | +|-----------|---------| +| `ObservationSoundness` / `ObservationsAgree` | Every observation agrees with the declared action footprint | +| `ObservationCompleteness` | Every frame-sensitive *actual* effect appears in observations | +| `EffectAttribution` | Observations are attributed to the given action | +| `InstrumentationAuthenticity` | TCB / attestation assumption (`authenticated = true`) | +| `AttestedExecution` | Conjunction of the four above on an `InstrumentationContext` | +| `TrustedInstrumentation` | **Definitionally** `AttestedExecution` — **not** mere `ObservationsAgree` | + +Agreement alone never establishes trust. Lemma +`observation_soundness_not_trusted_without_authenticity` records that shape. **Trusted-boundary assumption:** Observation faithfulness is **not** proved from untrusted producer logs. Discharge requires: @@ -33,18 +46,21 @@ untrusted producer logs. Discharge requires: - trusted runtime instrumentation in the TCB, or - an external attestation that binds observation digests to the transition. -Documented in `assumptions.md`. Primary lemma: +Documented in `assumptions.md`. Primary lemmas: -`accepted_transition_no_undeclared_sensitive_observation` +- `accepted_transition_no_undeclared_sensitive_observation` (needs soundness) +- `attested_execution_no_undeclared_sensitive_observation` (full trusted context) -Under `TrustedInstrumentation` and `ActionEffectsInFrame`, an accepted allow +Under observation soundness and `ActionEffectsInFrame`, an accepted allow transition cannot carry an observed `write`, `network`, `externalMessage`, `codeExecution`, or `stateChange` absent from the declared frame. Runtime mirror: `pcs_core.pf_core_runtime.validate_observed_effects_agree` -(callers must still attest instrumentation). +mirrors **`ObservationsAgree` / `ObservationSoundness` only**. Callers must still +attest instrumentation authenticity separately before claiming +`TrustedInstrumentation`. -## 5.2 Deny-path closedness +## 5.2 Deny-path closedness (C2) Base `EventSafe` treats deny as vacuously safe. Optional refinement: @@ -64,9 +80,15 @@ Optional bundle properties (`DenyClosedBundle`): `TraceSafeDenyClosed` refines `TraceSafe` (`traceSafeDenyClosed_implies_traceSafe`). Base `EventSafe` / `TraceSafe` remain unchanged. +**`DenyClosedCertificate`:** scaffolded and **disabled**. Declared-footprint +refinement is proved; post-deny runtime closure of tool/mutation/network/message/ +code/release/state/delegation effects is **not** yet supported by runtime evidence. +Do not issue a public deny-closed certificate claim. See +`schemas/pf_core.certificate_mode_status.json` `scaffolded_modes`. + Runtime mirror: `validate_event_safe_deny_closed`. -## 5.3 Naming: TenantProjectionIsolation vs NonInterference +## 5.3 Naming: TenantProjectionIsolation vs NonInterference (C3) | Name | Meaning | Status | |------|---------|--------| @@ -75,15 +97,19 @@ Runtime mirror: `validate_event_safe_deny_closed`. | `PairedExecutionNonInterference` | Paired executions + scheduler + timing assumptions | **Unproved scaffolding** | User-facing material must prefer **TenantProjectionIsolation** for the current -property. Reserve **NonInterference** for a future paired-execution theorem -family. CLI flag `--non-interference` remains for compatibility and checks +property. No stable certificate or public claim may use the bare phrase +“non-interference” without naming which formal predicate is meant. + +CLI flag `--non-interference` remains for compatibility and checks `TenantProjectionIsolation`. See `non-interference.md` and `lean/PFCore/PairedExecution.lean`. ## What is not claimed -- Completeness of observations without trusted instrumentation / attestation +- Completeness of observations without authenticity / attestation +- That `ObservationsAgree` equals `TrustedInstrumentation` - Paired-execution non-interference under adversarial schedulers - Covert channels or timing leaks -- Automatic deny-path suppression without deny-closed certificates +- Automatic deny-path suppression / `DenyClosedCertificate` without runtime evidence +- Full post-deny effect freedom beyond declared footprints diff --git a/docs/pf-core/trusted-boundary.md b/docs/pf-core/trusted-boundary.md index 8cf7171..ae66673 100644 --- a/docs/pf-core/trusted-boundary.md +++ b/docs/pf-core/trusted-boundary.md @@ -20,7 +20,7 @@ This document lists what PCS/PF-Core treats as trusted, untrusted, or assumed wh | Known capability catalog (TypeScript) | `typescript/packages/core/src/pfCoreCatalog.ts` | Generated from catalog JSON | | Known capability catalog (Lean) | `lean/PFCore/Catalog.lean`, `Capability.lean`, `ResourcePattern.lean` | `KnownCapability`, `ResourceWithinCapabilityPattern`; runtime `resource_pattern_scope` on certificates | | PF-Core concrete trace Lean proofs | `lean/PFCore/Generated/` (generated) | `lake env lean` on generated proof; certificate binds `trace_hash`, `proof_term_hash`, `lean_environment_hash`, `pfcore_kernel_hash` | -| PF-Core release bundle | `python/pcs_core/pf_core_bundle.py` | `pcs pf-core bundle-release` / `validate-bundle` with manifest hashes | +| PF-Core release bundle | `python/pcs_core/pf_core_bundle.py` | `pcs pf-core bundle-release` / `validate-bundle` (structural) / `verify-bundle` (replay + Lean compile; required for stable) | | Python PF-Core semantic validation | `python/pcs_core/validate_pf_core.py` | Binds JSON artifacts to closed enums and direct-trace effect/capability rules before Lean codegen | | Rust/TS PF-Core semantic validation | `rust/crates/pcs-core/src/validation.rs`, `typescript/packages/core/src/validate.ts` | Cross-language direct-trace effect/capability semantics aligned with Python (`UnknownEffect`, `UnknownCapability`, `CapabilityEffectMismatch`) | | Tool-use / witness hash alignment theorems | `lean/PCS/ToolUse.lean`, `lean/PCS/ComputationWitness.lean` | Promoted to trusted PCS catalog (Stage 4) | diff --git a/docs/security-governance.md b/docs/security-governance.md index 18ee2d1..c64d558 100644 --- a/docs/security-governance.md +++ b/docs/security-governance.md @@ -17,30 +17,49 @@ branch protection by themselves. | CertifyEdge pin + provision | [pins/certifyedge.json](../pins/certifyedge.json), [scripts/verify-certifyedge-pin.py](../scripts/verify-certifyedge-pin.py), [scripts/provision-certifyedge.sh](../scripts/provision-certifyedge.sh) | | External attestation schema | [schemas/ExternalAttestation.v0.schema.json](../schemas/ExternalAttestation.v0.schema.json) | | Unified release gate | [.github/workflows/release.yml](../.github/workflows/release.yml) (`PCS_RELEASE_MODE=release\|preview`) | +| Org/infra gate checker | [`pcs release check-gates`](pf-core/operator-release-gates.md), [scripts/check-release-gates.py](../scripts/check-release-gates.py) | | Mutation testing (deferred) | [docs/mutation-testing.md](mutation-testing.md) | | Cargo lock enforcement | `cargo … --locked` in CI | | npm lock enforcement | `npm ci` in CI | | Python lock | [python/requirements.lock](../python/requirements.lock) | | SBOM scaffold | [scripts/generate-sbom.sh](../scripts/generate-sbom.sh) | -| SLSA scaffold | [.github/workflows/release-provenance.yml](../.github/workflows/release-provenance.yml) | +| Release provenance (SLSA / attestations) | [.github/workflows/release-provenance.yml](../.github/workflows/release-provenance.yml), [scripts/build-release-provenance.sh](../scripts/build-release-provenance.sh), [scripts/verify-release-provenance.sh](../scripts/verify-release-provenance.sh) | | OCI verifier scaffold | [docker/verifier/Dockerfile](../docker/verifier/Dockerfile) | ## Required status checks (branch protection — org admin) Configure protection on `main` (and release branches) with **require status checks to pass** -and **require branches to be up to date**. Required job names from CI: +and **require branches to be up to date**. Prefer the aggregator checks, or require each matrix job +(see [pf-core/release-checklist.md](pf-core/release-checklist.md) mandatory CI matrix): | Job / workflow | Workflow file | Purpose | |----------------|---------------|---------| -| `python` | `ci.yml` | Schema, semantic, conformance, benchmarks | -| `lean` | `ci.yml` | Lake build + PF-Core lean-check | -| `rust` | `ci.yml` | Rust validator + hash vectors | -| `typescript` | `ci.yml` | TypeScript validator + lint | -| `pf-core-adapter` | `ci.yml` | Adapter parity (required on `main`) | -| `validate-cli-contract` | `ci.yml` | CLI contract smoke | +| `CI matrix gate` | `ci.yml` | Aggregates mandatory PR CI matrix | +| `Distribution matrix gate` | `distribution.yml` | Validator/verifier wheel + OCI clean execution | +| `Python full tests` | `ci.yml` | Full pytest + schemas/conformance/benchmarks | +| `Python full-package typecheck` | `ci.yml` | Full-package pyright | +| `Python branch coverage` | `ci.yml` | Branch coverage (trust-critical fail-under) | +| `Rust fmt/clippy/tests/fuzz-smoke` | `ci.yml` | Rust quality + proptest smoke | +| `TypeScript lint/tests/property-vectors` | `ci.yml` | TS lint/tests/hash vectors | +| `Lean PCS build` / `Lean PF-Core build` | `ci.yml` | Split lake builds + PF-Core lean-check | +| `Certificate-mode end-to-end` | `ci.yml` | All mode evidence e2e suites | +| `Cross-language differential` | `ci.yml` | Python/Rust/TS differential | +| `Semantic-projection replay` | `ci.yml` | Projection replay | +| `Theorem-manifest replay` | `ci.yml` | Theorem manifest replay | +| `Scientific payload mutation` | `ci.yml` | ResultArtifact mutation fixtures | +| `Signature and key-revocation` | `ci.yml` | Ed25519 integrity + revocation | +| `Preview release workflow` | `ci.yml` | Preview lean-check→bundle→absence | +| `Stable release dry-run` | `ci.yml` | Stable dry-run (live checker org-gated) | +| `Provenance verification` | `ci.yml` | Provenance digest binding (signed org-gated) | +| `PF-Core adapter parity` | `ci.yml` | Adapter parity (required on `main`) | +| `Validate CLI contract` | `ci.yml` | CLI contract smoke | | `validate-release-chain` | `release-chain.yml` | LabTrust release-chain gate | | `analyze` | `codeql.yml` | CodeQL | +**Org-gated (not fail-closed on every PR without secrets):** live CertifyEdge +(`secrets.PF_CORE_CERTIFYEDGE_CLI` + `pins/certifyedge.json` status=`pinned`), signed GitHub +attestations (GHEC / OIDC), and published signed OCI verifier images (cosign keys). + Also recommended: require CODEOWNERS review, dismiss stale reviews, and disallow force pushes. ## Signed release tags @@ -66,18 +85,55 @@ credentials; use repository secrets for `PF_CORE_CERTIFYEDGE_CLI` paths only whe Document any longer legal hold in the corresponding GitHub Release notes. -## SLSA provenance (scaffold) +## SLSA provenance (GitHub artifact attestations) + +`release-provenance.yml` builds release subjects (wheels, SBOM, lockfile copies, +verifier image pin, optional PF-Core release-bundle archive) and emits +`ReleaseProvenanceBinding.v0` binding: + +| Binding | Source | +|---------|--------| +| Source commit | `GITHUB_SHA` / git HEAD | +| Workflow identity | `GITHUB_WORKFLOW_REF` + run id | +| Builder identity | Actions run URL + runner metadata | +| Lockfiles | `python/requirements.lock`, `rust/Cargo.lock`, `typescript/package-lock.json` | +| Verifier image digest | `pins/python-base-image.json` index digest | +| Wheel digests | Built `pcs_core-*.whl` | +| SBOM digest | `dist/sbom/pcs-core.cdx.json` | +| Bundle root digest | Archive SHA-256 + manifest `signature_or_digest` when bundle present | + +Signed provenance uses `actions/attest-build-provenance` + `actions/attest-sbom` +(Sigstore keyless via GitHub OIDC). A clean **consumer-verify** job downloads only +the provenance artifact and runs `scripts/verify-release-provenance.sh` (digest +checks + `gh attestation verify` when `attestation.status=signed`). -`release-provenance.yml` attaches provenance scaffolding on version tags. Full SLSA -Build L3 generators (for example official slsa-github-generator) require org trust -setup; until then the workflow emits a provenance statement stub and SBOM alongside -release verification. +### Fail-closed honesty (gated) + +If attestations cannot be created (private repo without GitHub Enterprise Cloud, +missing `id-token`/`attestations` permissions, org OIDC policy), the workflow +sets `attestation.status=gated`, writes `PROVENANCE_ATTESTATION_GATED.json`, and +**does not** claim signed SLSA provenance. Tag / stable-release runs fail unless +repository variable `PCS_PROVENANCE_ALLOW_GATED=true` is set while org setup is +incomplete. + +`release.yml` publishes the same binding into the release artifact upload and +runs the consumer verification job after the unified gate. ## Gaps requiring org admin / external infra +Operator how-to: [pf-core/operator-release-gates.md](pf-core/operator-release-gates.md) +(`pcs release check-gates --mode release`). + 1. Enable branch protection + required checks listed above. 2. Enable secret scanning / push protection. 3. Provision cosign/sigstore keys (or GitHub OIDC) for OCI image signing. -4. Replace `pins/certifyedge.json` placeholders with a real image digest (`status=pinned`, `provision_strategy=oci_digest|signed_binary|source_commit_build`). -5. Optional: attach official SLSA generator once org permissions allow. -6. Provision ed25519 release / CertifyEdge signing keys so `ExternalAttestation.v0` can use `authentication_mode=ed25519_signed` instead of digest-bound integrity. +4. Replace `pins/certifyedge.json` (`status=unpinned`) with a real immutable CertifyEdge + artifact (`status=pinned`, `provision_strategy=oci_digest|signed_binary|source_commit_build`). + Do not invent placeholder digests. Until then, release mode fails closed; preview may use + absence notices or `dev_fixture` (untrusted_development) for machinery tests only. +5. Enable GitHub artifact attestations for private repos (GitHub Enterprise Cloud) if + the repository is private; public repos can attest on current plans. Clear + `PCS_PROVENANCE_ALLOW_GATED` once signed provenance is green on version tags. +6. Provision org ed25519 release / CertifyEdge signing keys and publish a + `TrustedKeyRegistry.v0` allowlist so stable releases can require + `authentication_mode=ed25519_signed` instead of digest-bound integrity. diff --git a/docs/trust-model.md b/docs/trust-model.md index ae057c8..a9ec90f 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -64,10 +64,25 @@ PCS::: | Concern | Policy | |---------|--------| | Trust root | Downstream verifiers pin an allowlist of ed25519 public keys by `key_id` (file or HSM). pcs-core does not ship production private keys. | +| Registry | `TrustedKeyRegistry.v0` (`schemas/TrustedKeyRegistry.v0.schema.json`); load via `PCS_TRUSTED_KEY_REGISTRY` or `pcs_core.artifact_integrity`. | | Rotation | Publish a new `key_id` before retiring the old key. Artifacts must carry the `key_id` used at `signed_at`. Overlap windows are verifier policy. | -| Revocation | Maintain a revocation list of `key_id` values (and optional digest denylist). Revoked keys must not verify new artifacts; historical artifacts signed before revocation may be accepted only under explicit audit policy. | +| Revocation | Set `revoked_at` on the key entry. Revoked keys must not verify signatures with `signed_at >= revoked_at`. | +| Validity | Keys carry `valid_from` / optional `valid_until`; `signed_at` must fall inside the interval. | +| Timestamp policy | Reject future `signed_at` (small skew allowed) and optionally reject signatures older than `max_age`. | | Algorithm agility | v1 fixes `algorithm` to `ed25519`. Future algorithms require a new schema version. | +Operational Python API: `pcs_core.artifact_integrity` (`sign_artifact`, `verify_artifact_signature`, +`verify_release_root_signatures`). Stable releases authenticate PCS/PF-Core manifests, +PF-Core certificates, Lean-check results, external attestations, and publication bundles. +Digest-only integrity remains valid for development and explicitly labeled previews. +Operator steps to publish `TrustedKeyRegistry.v0` and close stable gates: +[pf-core/operator-release-gates.md](pf-core/operator-release-gates.md). + +Signing seed for local/CI experiments (never commit production seeds): + +- `PCS_RELEASE_SIGNING_SEED_B64` — 32-byte ed25519 seed (base64url) +- `PCS_RELEASE_SIGNING_KEY_ID` — matching `key_id` in the trusted registry + ## Staleness Status `Stale` marks artifacts superseded by newer commits, specifications, or traces, and consumers should treat stale certificates as historical evidence only. diff --git a/examples/pf-core-valid/tool_use_trace_compiled/README.md b/examples/pf-core-valid/tool_use_trace_compiled/README.md index e6280d7..56899d2 100644 --- a/examples/pf-core-valid/tool_use_trace_compiled/README.md +++ b/examples/pf-core-valid/tool_use_trace_compiled/README.md @@ -20,16 +20,18 @@ Expect `certificate_mode: TraceSafeRCertificate`, `claim_class: LeanKernelChecke ## Release bundle -After lean-check writes a certificate: +After lean-check writes a certificate (and LeanCheckResult via `--result-out`): ```bash pcs pf-core bundle-release \ --trace examples/pf-core-valid/tool_use_trace_compiled/pfcore_trace.json \ --cert /tmp/pfcore-cert.json \ + --lean-check-result /tmp/lean-check.json \ --out /tmp/pfcore-bundle pcs pf-core validate-bundle /tmp/pfcore-bundle +pcs pf-core verify-bundle /tmp/pfcore-bundle ``` -The bundle includes `lean-toolchain`, `lean/lakefile.lean`, `lean/lake-manifest.json`, `kernel_manifest.json`, and a self-contained `kernel/` copy for hash validation without the source checkout. +`validate-bundle` is the lower-cost structural check. Stable releases must run `verify-bundle` (projection replay, theorem reconstruction, Lean compile against the bundled kernel). The closed bundle includes semantic projection, theorem/evidence manifests, `lean-toolchain`, lake project files, `kernel_manifest.json`, and a self-contained `kernel/` tree. See [docs/pf-core/claim-boundary.md](../../docs/pf-core/claim-boundary.md) for claim classes. From 64f6989ee946a4eca20040a50f9d92e57abc785e Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 10:03:21 -0700 Subject: [PATCH 24/24] Sync local smoke and materialize scripts with new gates. Keep adapter CI, CertifyEdge dry-runs, and protocol materializers aligned with the hardened release verification entrypoints. --- scripts/phase2-smoke.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/phase2-smoke.sh b/scripts/phase2-smoke.sh index dbdfa95..5c99d84 100644 --- a/scripts/phase2-smoke.sh +++ b/scripts/phase2-smoke.sh @@ -9,12 +9,19 @@ import json from pathlib import Path pins = Path("pins") -for name in ("elan.json", "certifyedge.json", "github-actions.json"): +for name in ("elan.json", "certifyedge.json", "github-actions.json", "python-base-image.json"): path = pins / name assert path.is_file(), path data = json.loads(path.read_text(encoding="utf-8")) assert isinstance(data, dict), name +base = json.loads((pins / "python-base-image.json").read_text(encoding="utf-8")) +assert base["index_digest"].startswith("sha256:") +assert base["dockerfile_from"].endswith(base["index_digest"]) +df = Path("docker/verifier/Dockerfile").read_text(encoding="utf-8") +assert base["index_digest"] in df +assert "USER pcs" in df + elan = json.loads((pins / "elan.json").read_text(encoding="utf-8")) assert len(elan["sha256"]) == 64 actions = json.loads((pins / "github-actions.json").read_text(encoding="utf-8"))["actions"] @@ -32,6 +39,10 @@ assert Path(".github/CODEOWNERS").is_file() assert Path(".github/dependabot.yml").is_file() assert Path(".github/workflows/codeql.yml").is_file() assert Path(".github/workflows/release-provenance.yml").is_file() +assert Path("scripts/build-release-provenance.sh").is_file() +assert Path("scripts/verify-release-provenance.sh").is_file() +assert Path("scripts/finalize-provenance-attestation.sh").is_file() +assert Path("schemas/ReleaseProvenanceBinding.v0.schema.json").is_file() print("OK phase2 scaffolding checks") PY