diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc08c3b8..46a6b3e8 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/distribution.yml b/.github/workflows/distribution.yml new file mode 100644 index 00000000..1c8ff0a3 --- /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/.github/workflows/pf-core-release-gate.yml b/.github/workflows/pf-core-release-gate.yml index 2bed1eeb..1969df93 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-provenance.yml b/.github/workflows/release-provenance.yml index 62903cdc..1ac73bba 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 - <> "$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 diff --git a/README.md b/README.md index fbb9354f..0f60ceab 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/docker/verifier/Dockerfile b/docker/verifier/Dockerfile index fbfe027c..46627823 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 946718b1..c8e6816b 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/docs/hash-canonicalization.md b/docs/hash-canonicalization.md index cd6702f8..d61cc545 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/docs/pf-core/certifyedge-ci.md b/docs/pf-core/certifyedge-ci.md index df62a45e..465fbb1c 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 899a22fd..2d4ed96a 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 7ffb773b..9bc1424f 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/compositional-trust-roadmap.md b/docs/pf-core/compositional-trust-roadmap.md index ef45ef6e..f3562a13 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/docs/pf-core/current-gap-audit.md b/docs/pf-core/current-gap-audit.md index ea450a86..53d4437f 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 736abf66..bfdac152 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/operator-release-gates.md b/docs/pf-core/operator-release-gates.md new file mode 100644 index 00000000..2e125c4f --- /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/presentation/theorem-sheet.md b/docs/pf-core/presentation/theorem-sheet.md index ff3ddc2b..1e4226ad 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/docs/pf-core/release-checklist.md b/docs/pf-core/release-checklist.md index d72d3816..fad0a236 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/docs/pf-core/runtime-semantics.md b/docs/pf-core/runtime-semantics.md index d1a95007..b5193009 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/semantic-projection.md b/docs/pf-core/semantic-projection.md index e0e3156e..c9db7fa8 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/docs/pf-core/trusted-boundary.md b/docs/pf-core/trusted-boundary.md index 8cf7171c..ae66673d 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 18ee2d1f..c64d558d 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 ae057c87..a9ec90fe 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/artifact_registry.valid.json b/examples/artifact_registry.valid.json index 9845fd45..13fed606 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-invalid/dataset_hash_mismatch/computation_witness.json b/examples/computation-release-invalid/dataset_hash_mismatch/computation_witness.json index 10e758f1..b5e26ac1 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 00000000..ed227ab3 --- /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 00000000..af6a105c --- /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 00000000..bdb3061d --- /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 00000000..108e59cb --- /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 00000000..1446ba91 --- /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 00000000..19f8f328 --- /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 00000000..c7a5087c --- /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 00000000..57eee326 --- /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 0c7c963b..4747ff91 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 30f7d24c..af6a105c 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 00000000..1446ba91 --- /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 ce8af84a..8a174037 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 dc87ee4c..ae6c1d50 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 fd832873..2eb9cef6 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 8f97c366..2c1b8b58 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 0ef7d532..c2720035 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 bdc852ec..d8061b30 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 fc7481f3..8e95d61b 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 00000000..ed227ab3 --- /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 00000000..538606bf --- /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 00000000..bdb3061d --- /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 00000000..108e59cb --- /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 00000000..1446ba91 --- /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 00000000..7eafb53b --- /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 00000000..ed227ab3 --- /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 00000000..af6a105c --- /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 00000000..bdb3061d --- /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 00000000..108e59cb --- /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 00000000..c7a5087c --- /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 00000000..ed227ab3 --- /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 00000000..af6a105c --- /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 00000000..bdb3061d --- /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 00000000..108e59cb --- /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 00000000..81b0b647 --- /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 00000000..c7a5087c --- /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 00000000..7b27b12b --- /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 00000000..ed227ab3 --- /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 00000000..af6a105c --- /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 00000000..bdb3061d --- /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 00000000..108e59cb --- /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 00000000..1446ba91 --- /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 00000000..c7a5087c --- /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 00000000..ed227ab3 --- /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 00000000..af6a105c --- /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 00000000..bdb3061d --- /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 00000000..108e59cb --- /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 00000000..2c9e0dd3 --- /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 00000000..ed227ab3 --- /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 00000000..af6a105c --- /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 00000000..bdb3061d --- /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 00000000..108e59cb --- /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 00000000..1446ba91 --- /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 00000000..71c3f59c --- /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 b1b13433..20aa634b 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 00000000..1446ba91 --- /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 e53bf651..0034efae 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 01c9a157..78a7d701 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 00000000..1446ba91 --- /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 2e7b7c2e..c7a5087c 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 e9dd0019..62e28008 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 30f7d24c..af6a105c 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/handoff_manifest.certificate_to_bundle.v0.json b/examples/computation-release/handoff_manifest.certificate_to_bundle.v0.json index bf485efe..b00213fd 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 e2ee5089..bad657b1 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 e2ee5089..bad657b1 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/examples/computation-release/lean_check_result.v0.json b/examples/computation-release/lean_check_result.v0.json index aa39fe90..5188eaed 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/examples/computation-release/outputs/metrics.json b/examples/computation-release/outputs/metrics.json new file mode 100644 index 00000000..1446ba91 --- /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/proof_obligation.v0.json b/examples/computation-release/proof_obligation.v0.json index 8ff46b97..847fd295 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 35e748dc..c4f8336a 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 cfa5c125..f729f218 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/result_artifact.json b/examples/computation-release/result_artifact.json index 2e7b7c2e..c7a5087c 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-release/scientific_memory_import_report.json b/examples/computation-release/scientific_memory_import_report.json index c47fd4bd..63248920 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/computation_witness.valid.json b/examples/computation_witness.valid.json index 30f7d24c..af6a105c 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/labtrust-release/proof_obligation.v0.json b/examples/labtrust-release/proof_obligation.v0.json index eeeb0f3c..0ad9a887 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/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 00000000..cfce966d --- /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 00000000..4fd38499 --- /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 00000000..9b91fdf5 --- /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 00000000..861b676c --- /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-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 00000000..a3ff8262 --- /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 00000000..7ed89844 --- /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 00000000..f489c6d4 --- /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-invalid/resource_scope_violation/README.md b/examples/pf-core-invalid/resource_scope_violation/README.md new file mode 100644 index 00000000..46a8b778 --- /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 85e01610..8410d7ab 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/examples/pf-core-valid/certificate_mode_effectframecertificate/README.md b/examples/pf-core-valid/certificate_mode_effectframecertificate/README.md index 6143ead4..9cb4447c 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 00000000..0379d3ef --- /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 33859c0f..a59d3d80 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/examples/pf-core-valid/certificate_mode_framepreservedcertificate/README.md b/examples/pf-core-valid/certificate_mode_framepreservedcertificate/README.md index 362e37c7..373b2a39 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 0b0e79a0..668df442 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 33859c0f..5f8d5549 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 10633245..0aa1287e 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/examples/pf-core-valid/tool_use_trace_compiled/README.md b/examples/pf-core-valid/tool_use_trace_compiled/README.md index e6280d7b..56899d24 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. diff --git a/examples/proof_obligation.valid.json b/examples/proof_obligation.valid.json index ad11447b..6c838b19 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/result_artifact.valid.json b/examples/result_artifact.valid.json index 2e7b7c2e..c7a5087c 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/examples/semantic_check_execution.valid.json b/examples/semantic_check_execution.valid.json index 05a75bf8..008d02c4 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 b3de603c..894c47ce 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/lean/PCS.lean b/lean/PCS.lean index 9cd86036..6f6d7bb5 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 6eedc660..3bfbc740 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 e1506b48..dd9640b7 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 f3f3e7e1..4c8628e8 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 00000000..cae71624 --- /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 65a7109c..02f64cc8 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/lean/PFCore/Compositional.lean b/lean/PFCore/Compositional.lean index dd0aad0a..aa6045e0 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 61a72f24..924f4830 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/Generated/PFCoreSemanticProjection.v0.json b/lean/PFCore/Generated/PFCoreSemanticProjection.v0.json new file mode 100644 index 00000000..f9101ddf --- /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/lean/PFCore/Generated/PFCoreTheoremManifest.v0.json b/lean/PFCore/Generated/PFCoreTheoremManifest.v0.json new file mode 100644 index 00000000..dabc891d --- /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/lean/PFCore/ObservedEffect.lean b/lean/PFCore/ObservedEffect.lean index 3e191676..b0ee0db8 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/lean/PFCore/PairedExecution.lean b/lean/PFCore/PairedExecution.lean index 39c078f0..e52bb808 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 f965327e..a7102649 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/pins/README.md b/pins/README.md index de519bcf..fb27d46e 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 68d10e99..a868e119 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 ae1bca50..91b76c84 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/pins/python-base-image.json b/pins/python-base-image.json new file mode 100644 index 00000000..e56e9d48 --- /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/artifact_integrity.py b/python/pcs_core/artifact_integrity.py new file mode 100644 index 00000000..80d72f97 --- /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/asset_resolver.py b/python/pcs_core/asset_resolver.py new file mode 100644 index 00000000..96113afd --- /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/pcs_core/capabilities.py b/python/pcs_core/capabilities.py index 6eab3730..2741d7aa 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/certifyedge_pin.py b/python/pcs_core/certifyedge_pin.py new file mode 100644 index 00000000..58784e94 --- /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/cli.py b/python/pcs_core/cli.py index 70f4e186..4831e84d 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/computation_release_chain.py b/python/pcs_core/computation_release_chain.py index bcc53997..aac4cd13 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/computation_validate.py b/python/pcs_core/computation_validate.py index 18a54fd2..40df7697 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/conformance.py b/python/pcs_core/conformance.py index 74b07d2d..65a77b4b 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/external_attestation.py b/python/pcs_core/external_attestation.py index b0ce224c..dc123d9c 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/hash.py b/python/pcs_core/hash.py index 576f625b..29e95037 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/pcs_core/lean_catalog.py b/python/pcs_core/lean_catalog.py index e0adb650..7e61b741 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 7eefd42d..0575954d 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 867abda7..4c9c0ff4 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 57b50eb0..64aa47b5 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/python/pcs_core/paths.py b/python/pcs_core/paths.py index 1fc32e13..3cdc66a9 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/pcs_lean_codegen.py b/python/pcs_core/pcs_lean_codegen.py index 615f8b9c..49a971e9 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 1fa39e0a..186a5683 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/pcs_core/pf_core_bundle.py b/python/pcs_core/pf_core_bundle.py index 9afd7f8f..0cc6ba3b 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/pcs_core/pf_core_certificate_mode_status.py b/python/pcs_core/pf_core_certificate_mode_status.py new file mode 100644 index 00000000..fa6e441d --- /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/pcs_core/pf_core_certifyedge.py b/python/pcs_core/pf_core_certifyedge.py index 90560dd5..b66a06ec 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/pcs_core/pf_core_claims.py b/python/pcs_core/pf_core_claims.py index cd941484..83e60037 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/pcs_core/pf_core_contract.py b/python/pcs_core/pf_core_contract.py index 6f88939f..934b80ea 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 458f1bdc..95724429 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_lean_codegen.py b/python/pcs_core/pf_core_lean_codegen.py index 8cf704d6..2b5f3ccf 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/pcs_core/pf_core_proof_binding.py b/python/pcs_core/pf_core_proof_binding.py index 7e968acf..d473f565 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_resolved_evidence.py b/python/pcs_core/pf_core_resolved_evidence.py new file mode 100644 index 00000000..c8d6772f --- /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/python/pcs_core/pf_core_runtime.py b/python/pcs_core/pf_core_runtime.py index 4b84153f..4c9ed137 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/pcs_core/pf_core_semantic_projection.py b/python/pcs_core/pf_core_semantic_projection.py index ae382395..ca2da5fb 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/pcs_core/pf_core_theorem_manifest.py b/python/pcs_core/pf_core_theorem_manifest.py new file mode 100644 index 00000000..5483d564 --- /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/pcs_core/registry_data.py b/python/pcs_core/registry_data.py index b032d92f..ea82b376 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 fa96682b..31a759df 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/release_chain_report.py b/python/pcs_core/release_chain_report.py index c58a2f5e..37589d03 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/pcs_core/release_gates.py b/python/pcs_core/release_gates.py new file mode 100644 index 00000000..4e7b428e --- /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/pcs_core/release_profile_engine.py b/python/pcs_core/release_profile_engine.py index b6cc2ac4..4d9e1eff 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 6c89a5b8..2e67eaa9 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/pcs_core/safe_paths.py b/python/pcs_core/safe_paths.py index 259f0f2e..b83ccc06 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/pcs_core/validate_detect.py b/python/pcs_core/validate_detect.py index 666b1170..794053b1 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_pf_core.py b/python/pcs_core/validate_pf_core.py index ed6b43e2..693fcb59 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/pcs_core/validate_semantics.py b/python/pcs_core/validate_semantics.py index 81c1e456..5599abfe 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 diff --git a/python/pyproject.toml b/python/pyproject.toml index 84d4aa48..cd069ac4 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 b3afd292..e275d31d 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/scripts/materialize_computation_fixtures.py b/python/scripts/materialize_computation_fixtures.py index f57b89e2..5fb00ade 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_artifact_integrity.py b/python/tests/test_artifact_integrity.py new file mode 100644 index 00000000..cc64db07 --- /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_asset_resolver.py b/python/tests/test_asset_resolver.py new file mode 100644 index 00000000..23d6fe84 --- /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/python/tests/test_canonical_hash_release.py b/python/tests/test_canonical_hash_release.py new file mode 100644 index 00000000..8b649435 --- /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/python/tests/test_certifyedge_pin.py b/python/tests/test_certifyedge_pin.py new file mode 100644 index 00000000..e8561f3d --- /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 eb65c0ad..14f8906f 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/python/tests/test_pcs_lean_codegen.py b/python/tests/test_pcs_lean_codegen.py index e39c40cb..9349bb0b 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 00000000..fdf2ee42 --- /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/python/tests/test_pf_core_bundle.py b/python/tests/test_pf_core_bundle.py index 4d651a75..0f648fc4 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/python/tests/test_pf_core_certificate_mode_codegen.py b/python/tests/test_pf_core_certificate_mode_codegen.py index 0d9f4dae..8d3c3268 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_certificate_mode_status.py b/python/tests/test_pf_core_certificate_mode_status.py new file mode 100644 index 00000000..1e4200be --- /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/python/tests/test_pf_core_compositional.py b/python/tests/test_pf_core_compositional.py index c0afa5db..10e62a83 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_contract_evidence.py b/python/tests/test_pf_core_contract_evidence.py new file mode 100644 index 00000000..0162d531 --- /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/python/tests/test_pf_core_cross_language.py b/python/tests/test_pf_core_cross_language.py index 12b561ed..befb1335 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/python/tests/test_pf_core_effect_frame_evidence.py b/python/tests/test_pf_core_effect_frame_evidence.py new file mode 100644 index 00000000..a85e45f1 --- /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/python/tests/test_pf_core_handoff_evidence.py b/python/tests/test_pf_core_handoff_evidence.py new file mode 100644 index 00000000..5e0276d2 --- /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", + ) diff --git a/python/tests/test_pf_core_phase4_differential.py b/python/tests/test_pf_core_phase4_differential.py index e5da8ffb..d2f6df2c 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 7a7fb1f4..41457fb9 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 b37ba9f2..c6a01f95 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 b5fae6a0..bfc13c63 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 dd5bb3f1..d9085bcf 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 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 00000000..df24d362 --- /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/python/tests/test_pf_core_transition_evidence.py b/python/tests/test_pf_core_transition_evidence.py new file mode 100644 index 00000000..1f4c2e27 --- /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/python/tests/test_phase1_protocol_hardening.py b/python/tests/test_phase1_protocol_hardening.py index a568a1c8..3796ad07 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}) diff --git a/python/tests/test_release_gates.py b/python/tests/test_release_gates.py new file mode 100644 index 00000000..f75403c2 --- /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/python/tests/test_release_profile_engine.py b/python/tests/test_release_profile_engine.py new file mode 100644 index 00000000..49c9bb53 --- /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 diff --git a/python/tests/test_release_provenance_binding.py b/python/tests/test_release_provenance_binding.py new file mode 100644 index 00000000..e1b47a9b --- /dev/null +++ b/python/tests/test_release_provenance_binding.py @@ -0,0 +1,168 @@ +"""Release provenance binding schema + digest sealing (PR15 / B8).""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +import pytest + +from pcs_core.validate import ValidationError, validate_artifact + +ROOT = Path(__file__).resolve().parents[2] + + +def _minimal_binding(**overrides: object) -> 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/python/tests/test_result_artifact_payload.py b/python/tests/test_result_artifact_payload.py new file mode 100644 index 00000000..10ec8063 --- /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) diff --git a/rust/crates/pcs-core/src/hash.rs b/rust/crates/pcs-core/src/hash.rs index 8ccfd05c..02cd2899 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/rust/crates/pcs-core/src/lib.rs b/rust/crates/pcs-core/src/lib.rs index b2943eed..952f4949 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 748bb8d9..785cbc64 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/schemas/LeanCheckResult.v0.schema.json b/schemas/LeanCheckResult.v0.schema.json index 3dd14ddc..5c06492f 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" } diff --git a/schemas/PFCoreBundleVerificationResult.v0.schema.json b/schemas/PFCoreBundleVerificationResult.v0.schema.json new file mode 100644 index 00000000..552e7fea --- /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/PFCoreCertificate.v0.schema.json b/schemas/PFCoreCertificate.v0.schema.json index deda124c..18848851 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/PFCoreEffectFrame.v0.schema.json b/schemas/PFCoreEffectFrame.v0.schema.json new file mode 100644 index 00000000..d331a9a9 --- /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" } + } +} diff --git a/schemas/PFCoreEvidenceManifest.v0.schema.json b/schemas/PFCoreEvidenceManifest.v0.schema.json new file mode 100644 index 00000000..7508310c --- /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 + } + } + } + } +} diff --git a/schemas/PFCoreReleaseBundleManifest.v0.schema.json b/schemas/PFCoreReleaseBundleManifest.v0.schema.json index ac5546c8..e3d6ca0d 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" } } } } diff --git a/schemas/PFCoreSemanticProjection.v0.schema.json b/schemas/PFCoreSemanticProjection.v0.schema.json index e442d071..699c773a 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 } + } } } } diff --git a/schemas/PFCoreTheoremManifest.v0.schema.json b/schemas/PFCoreTheoremManifest.v0.schema.json new file mode 100644 index 00000000..a25b96f9 --- /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" + } + } + } + } +} diff --git a/schemas/PFCoreTrace.v0.schema.json b/schemas/PFCoreTrace.v0.schema.json index 4c1279c9..87a2abd9 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" } } } diff --git a/schemas/ProofObligation.v0.schema.json b/schemas/ProofObligation.v0.schema.json index ad3c29d2..27cce473 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", diff --git a/schemas/ReleaseProvenanceBinding.v0.schema.json b/schemas/ReleaseProvenanceBinding.v0.schema.json new file mode 100644 index 00000000..9a9b023e --- /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/schemas/TrustedKeyRegistry.v0.schema.json b/schemas/TrustedKeyRegistry.v0.schema.json new file mode 100644 index 00000000..ae3ac3f4 --- /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/schemas/pf_core.certificate_mode_status.json b/schemas/pf_core.certificate_mode_status.json new file mode 100644 index 00000000..53bcc3a7 --- /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 dbfeab0c..6abcf179 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": { diff --git a/scripts/build-release-provenance.sh b/scripts/build-release-provenance.sh new file mode 100644 index 00000000..4e320905 --- /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/certifyedge-dev-fixture.py b/scripts/certifyedge-dev-fixture.py new file mode 100644 index 00000000..4e48ffbb --- /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/check-release-gates.py b/scripts/check-release-gates.py new file mode 100644 index 00000000..7a30bb76 --- /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/finalize-provenance-attestation.sh b/scripts/finalize-provenance-attestation.sh new file mode 100644 index 00000000..1b0bb0ff --- /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/phase2-smoke.sh b/scripts/phase2-smoke.sh index dbdfa957..5c99d84f 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 diff --git a/scripts/provision-certifyedge.sh b/scripts/provision-certifyedge.sh index c9f0b83c..351a65f7 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/release-gate.sh b/scripts/release-gate.sh index 27a30afa..99b56737 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." diff --git a/scripts/test-validator-wheel.sh b/scripts/test-validator-wheel.sh new file mode 100644 index 00000000..4bdcdc72 --- /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 00000000..013eaad1 --- /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 00000000..8841b5bf --- /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" diff --git a/scripts/verify-certifyedge-pin.py b/scripts/verify-certifyedge-pin.py index 5010b123..254b79a3 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 diff --git a/scripts/verify-release-provenance.sh b/scripts/verify-release-provenance.sh new file mode 100644 index 00000000..dfa47edf --- /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" diff --git a/test_vectors/hash/artifact_registry.vector.json b/test_vectors/hash/artifact_registry.vector.json index 1723a9cd..06bd3f80 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 00000000..078af260 --- /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 00000000..cd4456c8 --- /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 00000000..027fdf28 --- /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 00000000..4efab737 --- /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 00000000..4d6d5cfb --- /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 00000000..ebd9287b --- /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 00000000..e405776c --- /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 00000000..81545fc3 --- /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 00000000..8b998a2e --- /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 00000000..e405776c --- /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 00000000..32b400b8 --- /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 00000000..6845ac40 --- /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 00000000..71bc2602 --- /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 00000000..22869b1e --- /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 00000000..b9d0dfcd --- /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 00000000..71bc2602 --- /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 00000000..f3539da0 --- /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 00000000..79ab7c69 --- /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 00000000..163ff816 --- /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 00000000..63505497 --- /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 00000000..6aa70578 --- /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 00000000..503d65c1 --- /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 00000000..f4aaa626 --- /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 00000000..74799399 --- /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 00000000..71806a9c --- /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 00000000..5179bd32 --- /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 00000000..9a411eba --- /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 00000000..c97a9a6e --- /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 00000000..f5601445 --- /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 00000000..228fbe0f --- /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 f63955f1..a4c1d4dd 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 0c345c25..c6f1e5fd 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 daf157c7..ce96acd2 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 a8ee6fc5..18cf45ab 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 f7f8bf6a..fb3b9f15 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; + } +} diff --git a/typescript/packages/core/src/pfCore.ts b/typescript/packages/core/src/pfCore.ts index ac08a0c5..b8382c31 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 8a77f289..5234ebcc 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); + } +});