diff --git a/BACKLOG.md b/BACKLOG.md index 129cba32..cf90cc43 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -30,11 +30,11 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-508 | ✅ closed 2026-07-25 | executor producer 的 parent-operation control plane:可追溯子 run、受控取消與單一終態;目前納入 gate/ship,task dispatch 保留為後續接入 | arch/gate | 2026-07-21 | pr:#447 | P2 | design | | CC-509 | ✅ closed 2026-07-22 | detached gate launch liveness:對 sandbox parent-death 早期死亡 fail-loud,提供 supervisor readiness/identity evidence | arch/gate | 2026-07-22 | pr:#440 | P2 | hygiene | | CC-510 | ✅ closed 2026-07-23 | Codex detached dispatch continuation:App Server callback、authenticated completion envelope 與 foreground fallback | arch/DX | 2026-07-23 | pr:#443 | P2 | design | -| CC-511 | ⚠️ partial 2026-07-24 | ship publish authorization:Phase A current-tree authoritative full-suite 已交付;Phase B review-closure evidence 仍待 CC-515/CC-517 | release/gate | 2026-07-23 | pr:#446 | P1 | design | +| CC-511 | ⚠️ partial 2026-07-24 | ship publish authorization:Phase A current-tree authoritative full-suite 與 CC-515 shared verifier foundation 已交付;Phase B review-closure evidence 仍待 CC-517 | release/gate | 2026-07-23 | pr:#446 | P1 | design | | CC-512 | ✅ closed 2026-07-27 | Slices A/B/C 已交付:coordinate sources/CLI resolution、machine-owned assurance envelope/evidence capture、shared verifier/parity ratchets;targeted 不再是 tier | ops/gate | 2026-07-23 | pr:#451 | P1 | design | -| CC-513 | 🔵 active | canonical gate policy resolver:minimum tier、required reviewers、mode recommendation 與 downgrade audit | security/gate | 2026-07-23 | — | P1 | design | +| CC-513 | ✅ closed 2026-07-28 | canonical gate policy resolver:minimum tier、required reviewers、mode recommendation 與 downgrade audit | security/gate | 2026-07-23 | pr:#452 | P1 | design | | CC-514 | 🔵 active | orthogonal delivery assurance map、machine-derived tables 與 feature/docs/high-risk recipes | docs/process | 2026-07-23 | — | P2 | design | -| CC-515 | 🔵 active | gate artifact immutable subject、freshness 與 consumer applicability shared verifier | arch/gate | 2026-07-23 | — | P1 | design | +| CC-515 | ✅ closed 2026-07-29 | `gate_assurance_v3` immutable subject 與 artifact/subject/policy 三軸 shared verifier;downstream scope/closure producers 分屬 CC-518/CC-517 | arch/gate | 2026-07-23 | pr:#454 | P1 | design | | CC-516 | ⏸ deferred | evidence-gated thin delivery wrapper 評估;只組合既有 primitives,不建立 workflow engine/FSM | ux/process | 2026-07-23 | — | P3 | spike | | CC-517 | 🔵 active | maintainer `/ship`:primary review、structured remediation closure 與 conditional targeted confirmation | process/gate | 2026-07-23 | — | P1 | design | | CC-518 | 🔵 active | gate scope manifest v1:immutable subject、changed paths、paired tests、signals 與 bounded expansion | ops/gate | 2026-07-23 | — | P1 | design | @@ -42,7 +42,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-520 | 🔵 active | synthesis parity 與 remediation seed:findings union、root-cause grouping、coverage matrix 與 no-silent-drop | ops/gate | 2026-07-23 | — | P1 | design | | CC-521 | 🔵 active | test-gap matrix、protocol recovery 與 live recall evaluation 分層 | ops/test | 2026-07-23 | — | P2 | design | | CC-522 | 🔵 active | 任意 `--test-cmd` 的 opaque/structured capability negotiation、執行失敗分類與外部 evidence recovery | ops/test | 2026-07-27 | feedback:2026-07-27 | P1 | design | -| CC-523 | 🔵 active | `pmctl gate cancel` 必須終止 reviewer 派發前仍在執行的 foreground preflight 與其 process tree | arch/gate | 2026-07-27 | feedback:2026-07-27 | P1 | hygiene | +| CC-523 | ✅ closed 2026-07-28 | `pmctl gate cancel` 必須終止 reviewer 派發前仍在執行的 foreground preflight 與其 process tree | arch/gate | 2026-07-27 | pr:#453 | P1 | hygiene | | CC-524 | 🔵 active | `pmctl artifacts show` 顯示 canonical absolute run root 並提供穩定 machine-readable locator | ux/ops | 2026-07-27 | feedback:2026-07-27 | P2 | hygiene | | CC-525 | 🔵 active | copy-mode verifier fallback 的 generated provenance 必須指向實際 generator,並由 parity ratchet 防止再次漂移 | ops/test | 2026-07-28 | feedback:2026-07-28 | P3 | hygiene | | CC-526 | 🔵 active | reviewer override file 的 symlink trust-boundary hardening 與相容性契約 | security/gate | 2026-07-28 | feedback:2026-07-28 | P2 | hygiene | @@ -1603,20 +1603,21 @@ current-tree full PASS 加上適用 delivery policy 的 valid review authorizati `tests/bin/run-tests.sh --verify-full` 驗證。direct 與 parallel ship path 共用同一 finish 邊界;fresh run、invalid supplied result、relative artifact resolution、suite failure、tree dirtiness 與 post-suite HEAD drift 都有 fail-closed regression coverage。 -本次僅完成 Phase A;Phase B 的 review authorization 與 closure artifact 不在此 PR -範圍,仍待 [[CC-515]]、[[CC-517]]。 +本次僅完成 Phase A;[[CC-515]] shared verifier foundation 已於 pr:#454 交付, +Phase B 的 review authorization 與 closure artifact 不在此 PR 範圍,仍待 +[[CC-517]]。 **Done-when**: 任一官方 ship publish path 都只能在(1)current tree authoritative full-suite PASS 有效;(2)review authorization 對目前 delivery policy 有效; (3)branch、HEAD、tree 與 evidence subject 匹配後 push/開 PR。Phase A 可先獨立 -ship;Phase B 在 [[CC-515]]、[[CC-517]] 完成後收斂。 +ship;Phase B 在 [[CC-517]] 完成後收斂。 **Non-goals**: 不把 full suite 搬進 generic gate;不要求所有 final tree 都 full re-gate;不建立第二套 test-result schema;不把 publish authorization 等同 merge authorization。 -**Dependencies**: Phase A 複用 [[CC-449]]/[[CC-491]],可立即實作;Phase B 依賴 -[[CC-515]]、[[CC-517]]。 +**Dependencies**: Phase A 複用 [[CC-449]]/[[CC-491]],可立即實作;Phase B 的 +[[CC-515]] verifier dependency 已滿足,剩餘依賴為 [[CC-517]]。 **Cross-link**: [[CC-512]]、[[CC-513]]、`docs/test-runner-contract.md`。 @@ -1747,7 +1748,7 @@ current-tree authoritative full suite為97 passed、0 failed、0 skipped。 --- -## CC-513 — canonical gate policy resolver 🔵 active +## CC-513 — canonical gate policy resolver ✅ 2026-07-28 **Problem**: sensitive-path regex、brief `architecture_impact`、tier detection、 reviewer defaults、mode suggestions 與 CLI overrides 分散在不同 branches/文件。 @@ -1795,6 +1796,15 @@ maintainer policy 可獨立測試,full 不再隱含或強制 parallel。 **Cross-link**: [[CC-065]]、[[CC-512]]、[[CC-515]]、[[CC-517]]、[[CC-518]]。 +**Outcome**: Shipped the canonical gate-policy resolver and versioned policy +registries. Generic and maintainer consumers now resolve minimum tier, required +reviewer coverage, recommended mode, explicit user-mode provenance, matched +signals, and bounded downgrade approval once; the machine-owned assurance +envelope carries and verifies that result across foreground, detached, wait, +and ship paths. Explicit sequential/parallel choice remains user-owned. + +**See**: pr:#452 + --- ## CC-514 — orthogonal delivery assurance map 與 recipes 🔵 active @@ -1851,7 +1861,7 @@ lint 阻止 tier/mode/full-suite 順序重新漂移。 --- -## CC-515 — immutable subject、freshness 與 applicability verifier 🔵 active +## CC-515 — immutable subject、freshness 與 applicability verifier ✅ 2026-07-29 **Problem**: preflight tests 已有 repo/base/head/tree evidence,但 final gate artifact 主要依賴 prose `Final:`。外部 consumer 無法分辨 artifact 本身壞掉、subject 已過期, @@ -1889,6 +1899,20 @@ manifest 與 remediation closure 共同依賴,屬 P1 evidence foundation。 並以 stable repo subject 驗證 artifact;沒有 consumer 再以 `Final: GO` 當作 freshness 或 publish authorization。 +**Outcome**:Shipped `gate_assurance_v3`、immutable Git subject、linked preflight +digest,以及 `pmctl gate verify` 的 artifact/subject/policy 三軸 assessment; +gate wait 與 ship finish 都改用同一 shared verifier。Copy/replay、linked worktree、 +different repo、base/head/tree drift、fixed ref、digest 與 policy insufficiency +都有直接回歸。Evidence link contract 對 scope manifest/closure 明確支援 +`unavailable|verified`,verified link 會驗 basename、digest 與 subject fingerprint。 + +本票擁有的 verifier foundation 已完整交付。`gate_scope_manifest_v1` 的內容與 producer +仍由 [[CC-518]] 負責;`remediation_closure_v1` 的 lifecycle 與 producer 仍由 +[[CC-517]] 負責。兩者是依賴 CC-515 的 downstream evidence,不是 CC-515 的未完成 +範圍。 + +**See**: pr:#454 + **Non-goals**: 不以 gate artifact 取代 test result;不把 policy applicable 等同 merge authorization;不要求 worktree path 永久固定。 @@ -2228,7 +2252,7 @@ protocol recovery contract保持正交。P1。 --- -## CC-523 — gate cancel 終止 pre-review foreground producer work 🔵 active +## CC-523 — gate cancel 終止 pre-review foreground producer work ✅ 2026-07-28 **Framing**: 本票是 [[CC-508]] parent-operation cancellation 契約的 regression closure,不重做 operation control plane、`pmctl dispatch cancel` 或 gate workflow。 @@ -2291,6 +2315,14 @@ timeout 預設。 dispatch cancellation 與 [[CC-509]] supervisor identity/liveness evidence。P1, 應先於下一次依賴 foreground gate cancellation 的 maintainer delivery 處理。 +**Outcome**: Gate parent operations now persist verified producer process +identity before pre-review work. Cancellation stops and reaps the foreground +preflight or detached supervisor process tree before terminalizing the +operation, preserves indeterminate on unverifiable termination, and prevents +late reviewer dispatch or terminal overwrite. + +**See**: pr:#453 + --- ## CC-524 — artifacts show canonical absolute run root 🔵 active diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a4a305..86fb7a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Added +- **Immutable gate subject and shared three-axis verification (CC-515).** + Current gate producers emit `gate_assurance_v3` with a stable Git + common-directory repository key, provenance-only observed root, base/head + refs and commits, tree fingerprint, subject kind/dirty policy, timestamps, + and digest-bound evidence links. `pmctl gate verify` now reports + `artifact_valid`, `subject_current`, and `policy_applicable` independently + with reason codes and optional JSON output. Named consumers require all + three axes; default inspection remains compatible with historical + artifact-validity checks. Gate wait and ship finish use the same verifier, + so stale or policy-insufficient GO text cannot authorize continuation. + - **Parent-operation control plane for indirect dispatch (CC-508).** Producers that launch detached children — `pmctl gate run` and `pmctl ship` — now create a durable parent operation record (`core/schema/operation.schema.json`, owned diff --git a/DECISIONS.md b/DECISIONS.md index a843d0c4..728e60b0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -7,6 +7,45 @@ H2 標題格式:## YYYY-MM-DD: <短描述> 與 BACKLOG closure 對應的 entry,內文首行寫:Closes: BACKLOG.md#-NNN --> +## 2026-07-28: gate-assurance-v3-separates-validity-freshness-applicability + +Relates: CC-515 + +**Context**: `gate_assurance_v2` 可以驗證 result/sidecar digest、resolved +coordinates、policy snapshot 與 protected dispatch records,但 repository root +同時被當作 provenance 和 identity。Consumer 因此無法區分 artifact 被竄改、artifact +仍完整但 base/head/tree 已漂移,或 evidence 完整但不符合目前 consumer policy。 +`gate wait` 與 `ship finish` 也各自保留 continuation 判定,容易再次退回只讀 +`Final: GO`。 + +**Decision**: Producer 升級為 `gate_assurance_v3`,加入 `gate_subject_v1`: +repository key 由 Git common-directory identity 與 optional remote identity +組成,observed root 只作 provenance;subject 同時綁定 base/head ref+commit、tree +fingerprint、`committed_head|working_tree|fixed_ref`、對應 dirty policy,以及 +created/finished observation。Evidence 區塊以 digest 和 subject fingerprint 連結 +preflight,並為尚未有 producer 的 scope manifest/closure 明示 `unavailable`。 +Protected attestation 升級並綁定完整 subject digest。 + +Shared verifier 固定輸出 `artifact_valid`、`subject_current`、 +`policy_applicable` 三軸與 reason codes。預設 inspect 只維持舊 artifact-validity exit +相容性;指定 consumer 時三軸都必須 pass。`gate wait` 和 `ship finish` 只消費此 +shared assessment。不同 linked worktree 的 physical path 不造成 stale;copy/replay +可保持 artifact valid,但不攜帶 canonical dispatch authorization。 + +**Alternatives considered**: (a) 把 freshness 混進 artifact validity——否決, +base advance 或 working-tree drift 不會改寫歷史 artifact,不能標成 forged。 +(b) 只強化 `ship finish`——否決,會讓 wait、publish 與未來 consumer 各自重做 +identity/freshness 邏輯。(c) 用 observed absolute root 當唯一 repository +identity——否決,linked worktree 和搬移後的同一 Git subject 會誤判。 + +**Constraints introduced**: 新 consumer 不得從 `Final: GO` 推論 freshness 或 +policy applicability;不得把 physical worktree path 單獨當失效條件。v1/v2 artifact +保留 inspection 相容性,但不能提供 immutable-subject/applicability authorization。 +Policy applicable 不等於 merge authorization;current-tree tests、closure 與 merge +授權仍由各自 consumer contract 決定。 + +--- + ## 2026-07-27: targeted-review-is-a-pass-kind-not-a-tier Relates: CC-512, CC-513, CC-515, CC-517, CC-519 diff --git a/MILESTONES.md b/MILESTONES.md index 16659bb6..be68e05f 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -108,8 +108,8 @@ | 票 | 摘要 | 狀態 | |----|------|------| | CC-512 | Slices A/B/C:coordinate sources/CLI resolution、machine-owned assurance envelope/evidence capture、shared verifier/parity ratchets;targeted 不再是 tier | ✅ pr:#451 | -| CC-513 | canonical resolver:minimum tier、required reviewers、mode recommendation/user-choice provenance、generic vs maintainer policy 與 tier/coverage downgrade audit | 🔵 | -| CC-515 | immutable subject;artifact validity、subject freshness、policy applicability 三軸 shared verifier | 🔵 | +| CC-513 | canonical resolver:minimum tier、required reviewers、mode recommendation/user-choice provenance、generic vs maintainer policy 與 tier/coverage downgrade audit | ✅ pr:#452 | +| CC-515 | immutable subject、三軸 shared verifier 與 downstream evidence link contract;scope/closure producers 分屬 CC-518/CC-517 | ✅ pr:#454 | ### Phase 8 — existing gate structured evidence diff --git a/README.md b/README.md index 9f3acd25..e8bfbf55 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ router and [`cli/commands.tsv`](cli/commands.tsv). - `validate brief` — Validate a dispatch brief. [experimental; JSON: false; mutating: false] - `decision add` — Append a structured decision. [experimental; JSON: true; mutating: true] - `gate run` — Start the pull-request gate. [experimental; JSON: false; mutating: true] -- `gate verify` — Verify a gate result artifact. [experimental; JSON: false; mutating: false] +- `gate verify` — Assess artifact validity, subject freshness, and consumer applicability. [experimental; JSON: true; mutating: false] - `gate wait` — Wait for a detached gate run. [experimental; JSON: false; mutating: true] - `gate cancel` — Stop the verified gate producer process tree, then cancel only its recorded child runs; partial termination stays indeterminate. [experimental; JSON: false; mutating: true] - `gate reconcile` — Reconcile a gate operation from trusted child terminal claims. [experimental; JSON: false; mutating: true] diff --git a/cli/commands.tsv b/cli/commands.tsv index 2ec90e3c..af0c7e83 100644 --- a/cli/commands.tsv +++ b/cli/commands.tsv @@ -39,7 +39,7 @@ safe bash Run a shell command through guard policy. pmctl safe bash --role [options] experimental false false --schema pmctl validate brief /tmp/brief.md decision add Append a structured decision. pmctl decision add --date --title --path <path> [options] experimental true true --date, --title, --path, --closes, --tags, --evidence, --json pmctl decision add --date 2026-01-01 --title "Example" --path DECISIONS.md gate run Start the pull-request gate. pmctl gate run [options] experimental false true --executor, --test-cmd, --cd pmctl gate run --executor codex -gate verify Verify a gate result artifact. pmctl gate verify <result-file> experimental false false none pmctl gate verify .gate-results/result.json +gate verify Assess artifact validity, subject freshness, and consumer applicability. pmctl gate verify <result-file> [--cd <repo>] [--consumer embedded|generic|maintainer|publish] [--json] experimental true false --cd,--consumer,--json pmctl gate verify .gate-results/result.md --cd . --json gate wait Wait for a detached gate run. pmctl gate wait <gate-id> [options] experimental false true --cd, --timeout pmctl gate wait gate-123 --cd . gate cancel Stop the verified gate producer process tree, then cancel only its recorded child runs; partial termination stays indeterminate. pmctl gate cancel <operation-id> --cd <dir> [--grace <seconds>] experimental false true --cd, --grace pmctl gate cancel op-20260724T000000Z-abcdef --cd . gate reconcile Reconcile a gate operation from trusted child terminal claims. pmctl gate reconcile <operation-id> --cd <dir> experimental false true --cd pmctl gate reconcile op-20260724T000000Z-abcdef --cd . diff --git a/commands/pr-gate.md b/commands/pr-gate.md index 65ec0c3d..c8b1d514 100644 --- a/commands/pr-gate.md +++ b/commands/pr-gate.md @@ -296,22 +296,19 @@ When the `pmctl gate wait` background Bash completion notification arrives: brief failure summary: exit code + last ~20 lines of the supervisor log at `pmctl artifacts show <gate_id> --cd "<work_dir>"`). 4. Read `result_file` directly (both executor routes write it in-process). To - re-confirm out of band, run `pmctl gate verify <result_file_path>` from the - repository the gate reviewed (use the literal path parsed in step 2, not a - shell variable; exit 0 = valid). - New results must report `assurance: verified` and point to a sibling - `gate_assurance_v2` JSON file. A legacy result or unbound v1 envelope may report - `assurance: unavailable`; do not treat that as proof of tier/mode/coverage - or reviewer-session independence. For repo-layout results that claim - verified independence, verification also requires the protected producer - attestation, a result under that repository's canonical state partition, - and matching canonical terminal run records. The producer publishes the - sidecar before the v2 result that references it; verification briefly - retries when it observes an in-flight v2 result before its protected - attestation rename completes. Current envelopes also carry the shell-owned - policy resolution (classification, matched signals, floors, resolved - coordinates, and override provenance); earlier v2 envelopes without that - optional block remain readable. + re-confirm out of band, run + `pmctl gate verify <result_file_path> --cd "<work_dir>" --consumer embedded --json` + using the literal path parsed in step 2. Exit 0 means all three named-consumer + axes pass: `artifact_valid`, `subject_current`, and `policy_applicable`. + Each axis carries reason codes, so stale subject, malformed artifact, and + insufficient consumer policy remain distinct. New results point to a sibling + `gate_assurance_v3` JSON file containing immutable repository/base/head/tree + subject data, resolved policy/coordinates, and linked evidence digests. + A legacy result or v1/v2 assurance may remain structurally readable but + cannot prove subject freshness or consumer applicability. Repo-layout + artifacts that claim verified independence additionally require their + protected producer attestation and matching canonical terminal run records. + Copying a valid result/sidecar pair does not copy that authorization. 5. Prepend `PR-gate complete.` to completion relay and include the full gate result (including `Final: GO` / `Final: NO-GO`) unchanged. 6. On failure, avoid collapsing findings; relay the actual stderr summary and diff --git a/commands/ship.md b/commands/ship.md index b50e6b4c..c6364c2e 100644 --- a/commands/ship.md +++ b/commands/ship.md @@ -153,11 +153,18 @@ background — reading `Final:` right after that call would read a stale or missing result. `/ship` is already a long-running autonomous loop with nothing else for the main thread to do while it waits, so there is no reason to pay the detached/`pmctl gate wait` two-call complexity that `/pr-gate` -uses to keep the main thread free for other work; run `foreground` and read -the resulting `Final: GO|NO-GO` verdict directly from the gate result file -once the call returns. - -- **GO** → go to Step 3.5. +uses to keep the main thread free for other work; run `foreground`, parse its +literal `result: <path>`, then run +`pmctl gate verify <result_path> --cd "<work_dir>" --consumer maintainer --json`. +The result file's `Final: GO|NO-GO` remains the review verdict, but it is not +freshness or continuation authorization by itself. A GO may continue only +when the named-consumer verification exits 0 and reports `artifact_valid`, +`subject_current`, and `policy_applicable` all `pass`. + +- **GO + all three axes pass** → go to Step 3.5. +- **GO + any axis not pass** → stop before full-suite or publication work. + Report the failed axis and reason codes; regenerate evidence against the + current subject rather than treating the old GO text as reusable. - **NO-GO** → invoke the `project-pm` agent to synthesize the gate result against the verdict table and Rules A/B in `agents/project-pm.md` (source-first read of every cited diff file, discovery sweep of all call sites of a diff --git a/core/README.md b/core/README.md index a3d54842..17d5b13f 100644 --- a/core/README.md +++ b/core/README.md @@ -20,7 +20,10 @@ Gate assurance definitions are split deliberately: approval for a tier or reviewer-coverage downgrade; mode remains a direct user choice. - `schema/gate-assurance.schema.json` defines the portable envelope that records - both resolved coordinates and the policy resolution that produced them. + resolved coordinates, policy resolution, immutable subject, and linked + evidence. +- `schema/gate-verification.schema.json` defines the shared three-axis + assessment returned to gate consumers. ## Invariants @@ -30,8 +33,8 @@ Gate assurance definitions are split deliberately: appears as a hard-coded field name, path segment, or directory name. CLI-agnostic by construction. 3. **Schema is locked.** Subsequent changes are versioned breaking events: - bump `schema_version` (each payload carries it as a required `const` - field) + add a `CHANGELOG.md` entry + record the migration in + bump `schema_version` (each payload carries a required positive integer + `const` or a closed compatibility `enum`) + add a `CHANGELOG.md` entry + record the migration in `DECISIONS.md`. No directory versioning under `core/schema/`. ## Dependency graph (acyclic, downward) @@ -49,7 +52,8 @@ The designated writer module in `runtime/lib/state-writer.sh` is the sole manage ## Schema versioning -Every payload schema includes `schema_version` as a required integer `const` -field. Breaking changes bump that integer; old payloads remain valid against -the schema version that defines them. `jq '.schema_version'` is the -bash-readable discriminator. No `$id` URLs. +Every payload schema includes `schema_version` as a required positive integer. +A single-version schema uses `const`; a compatibility schema may use a closed +`enum` with kind/version pairing. Breaking changes bump that integer; old +payloads remain valid against the schema version that defines them. +`jq '.schema_version'` is the bash-readable discriminator. No `$id` URLs. diff --git a/core/schema/gate-assurance.schema.json b/core/schema/gate-assurance.schema.json index 99cb59ef..0271ba57 100644 --- a/core/schema/gate-assurance.schema.json +++ b/core/schema/gate-assurance.schema.json @@ -3,9 +3,30 @@ "description": "Machine-owned portable gate assurance envelope.", "type": "object", "required": ["kind", "schema_version", "result", "bindings", "coordinates", "dispatch", "provenance"], + "allOf": [ + { + "if": { + "properties": { "kind": { "const": "gate_assurance_v2" } }, + "required": ["kind"] + }, + "then": { + "properties": { "schema_version": { "const": 2 } }, + "not": { + "anyOf": [ + { "required": ["subject"] }, + { "required": ["evidence"] } + ] + } + }, + "else": { + "properties": { "schema_version": { "const": 3 } }, + "required": ["subject", "evidence"] + } + } + ], "properties": { - "kind": { "const": "gate_assurance_v2" }, - "schema_version": { "const": 2 }, + "kind": { "enum": ["gate_assurance_v2", "gate_assurance_v3"] }, + "schema_version": { "enum": [2, 3] }, "result": { "type": "object", "required": ["final"], @@ -32,6 +53,12 @@ }, "additionalProperties": false }, + "subject": { + "$ref": "#/definitions/gateSubject" + }, + "evidence": { + "$ref": "#/definitions/linkedEvidence" + }, "coordinates": { "type": "object", "required": ["tier", "mode", "pass", "coverage", "independence"], @@ -145,6 +172,231 @@ } }, "definitions": { + "gateSubject": { + "type": "object", + "required": [ + "kind", + "schema_version", + "repository", + "observed", + "base", + "head", + "tree_fingerprint", + "subject_kind", + "dirty_policy", + "created_at", + "finished_at", + "observed_at_finish" + ], + "properties": { + "kind": { "const": "gate_subject_v1" }, + "schema_version": { "const": 1 }, + "repository": { + "type": "object", + "required": ["key", "git_common_dir_identity", "remote_identity"], + "properties": { + "key": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "git_common_dir_identity": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "remote_identity": { + "type": ["string", "null"], + "pattern": "^[a-f0-9]{64}$" + } + }, + "additionalProperties": false + }, + "observed": { + "type": "object", + "required": ["root", "git_common_dir"], + "properties": { + "root": { "type": "string", "pattern": "^/" }, + "git_common_dir": { "type": "string", "pattern": "^/" } + }, + "additionalProperties": false + }, + "base": { "$ref": "#/definitions/gitRef" }, + "head": { "$ref": "#/definitions/gitRef" }, + "tree_fingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "subject_kind": { + "enum": ["committed_head", "working_tree", "fixed_ref"] + }, + "dirty_policy": { + "enum": ["require_clean", "include_working_tree", "ignore_working_tree"] + }, + "created_at": { "$ref": "#/definitions/timestamp" }, + "finished_at": { "$ref": "#/definitions/timestamp" }, + "observed_at_finish": { + "type": "object", + "required": [ + "repository_key", + "base_commit", + "head_commit", + "tree_fingerprint" + ], + "properties": { + "repository_key": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "base_commit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "head_commit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "tree_fingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "additionalProperties": false + } + }, + "allOf": [ + { + "if": { + "properties": { "subject_kind": { "const": "committed_head" } }, + "required": ["subject_kind"] + }, + "then": { + "properties": { "dirty_policy": { "const": "require_clean" } } + } + }, + { + "if": { + "properties": { "subject_kind": { "const": "working_tree" } }, + "required": ["subject_kind"] + }, + "then": { + "properties": { "dirty_policy": { "const": "include_working_tree" } } + } + }, + { + "if": { + "properties": { "subject_kind": { "const": "fixed_ref" } }, + "required": ["subject_kind"] + }, + "then": { + "properties": { "dirty_policy": { "const": "ignore_working_tree" } } + } + } + ], + "additionalProperties": false + }, + "gitRef": { + "type": "object", + "required": ["ref", "commit"], + "properties": { + "ref": { "type": "string", "minLength": 1 }, + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" } + }, + "additionalProperties": false + }, + "timestamp": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "linkedEvidence": { + "type": "object", + "required": ["preflight", "scope_manifest", "closure"], + "properties": { + "preflight": { + "oneOf": [ + { + "type": "object", + "required": [ + "status", + "outcome", + "artifact", + "sha256", + "subject_fingerprint" + ], + "properties": { + "status": { "const": "not_run" }, + "outcome": { "type": "null" }, + "artifact": { "type": "null" }, + "sha256": { "type": "null" }, + "subject_fingerprint": { "type": "null" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "status", + "outcome", + "artifact", + "sha256", + "subject_fingerprint" + ], + "properties": { + "status": { "const": "linked" }, + "outcome": { "enum": ["pass", "fail"] }, + "artifact": { + "type": "string", + "pattern": "^preflight-evidence-[0-9]{8}-[0-9]{6}\\.json$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "subject_fingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "additionalProperties": false + } + ] + }, + "scope_manifest": { "$ref": "#/definitions/optionalEvidenceLink" }, + "closure": { "$ref": "#/definitions/optionalEvidenceLink" } + }, + "additionalProperties": false + }, + "optionalEvidenceLink": { + "oneOf": [ + { + "type": "object", + "required": ["status", "artifact", "sha256", "subject_fingerprint"], + "properties": { + "status": { "const": "unavailable" }, + "artifact": { "type": "null" }, + "sha256": { "type": "null" }, + "subject_fingerprint": { "type": "null" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["status", "artifact", "sha256", "subject_fingerprint"], + "properties": { + "status": { "const": "verified" }, + "artifact": { + "type": "string", + "minLength": 1, + "pattern": "^[^/]+$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "subject_fingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "additionalProperties": false + } + ] + }, "policyResolution": { "type": "object", "required": [ diff --git a/core/schema/gate-verification.schema.json b/core/schema/gate-verification.schema.json new file mode 100644 index 00000000..6049af72 --- /dev/null +++ b/core/schema/gate-verification.schema.json @@ -0,0 +1,137 @@ +{ + "title": "Gate artifact verification", + "description": "Three-axis assessment of gate artifact integrity, subject freshness, and consumer applicability.", + "type": "object", + "required": [ + "kind", + "schema_version", + "result_file", + "verdict", + "assurance", + "consumer", + "axes" + ], + "properties": { + "kind": { "const": "gate_verification_v1" }, + "schema_version": { "const": 1 }, + "result_file": { "type": "string", "pattern": "^/" }, + "verdict": { "enum": ["GO", "NO-GO", "unknown"] }, + "assurance": { + "type": "object", + "required": ["status", "kind", "file"], + "properties": { + "status": { "enum": ["verified", "unavailable", "invalid"] }, + "kind": { + "type": ["string", "null"], + "enum": [ + "gate_assurance_v1", + "gate_assurance_v2", + "gate_assurance_v3", + null + ] + }, + "file": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^/" } + ] + } + }, + "additionalProperties": false + }, + "consumer": { + "enum": ["inspect", "embedded", "generic", "maintainer", "publish"] + }, + "axes": { + "type": "object", + "required": ["artifact_valid", "subject_current", "policy_applicable"], + "properties": { + "artifact_valid": { "$ref": "#/definitions/axis" }, + "subject_current": { "$ref": "#/definitions/subjectAxis" }, + "policy_applicable": { "$ref": "#/definitions/policyAxis" } + }, + "additionalProperties": false + } + }, + "definitions": { + "axis": { + "type": "object", + "required": ["status", "reason_codes"], + "properties": { + "status": { "enum": ["pass", "fail", "unavailable"] }, + "reason_codes": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + }, + "additionalProperties": false + }, + "subjectAxis": { + "type": "object", + "required": ["status", "reason_codes"], + "properties": { + "status": { "enum": ["pass", "fail", "unavailable"] }, + "reason_codes": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "current": { + "type": "object", + "required": [ + "repository_key", + "base_commit", + "head_commit", + "tree_fingerprint", + "observed_root" + ], + "properties": { + "repository_key": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "base_commit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "head_commit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "tree_fingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "observed_root": { "type": "string", "pattern": "^/" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "policyAxis": { + "type": "object", + "required": ["status", "reason_codes"], + "properties": { + "status": { "enum": ["pass", "fail", "unavailable"] }, + "reason_codes": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "consumer": { + "enum": ["embedded", "generic", "maintainer", "publish"] + }, + "required_policy": { + "enum": ["generic", "maintainer"] + }, + "embedded_policy": { + "enum": ["generic", "maintainer"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/docs/architecture/script-variable-consumers.tsv b/docs/architecture/script-variable-consumers.tsv index 92ae7f02..3922ebdb 100644 --- a/docs/architecture/script-variable-consumers.tsv +++ b/docs/architecture/script-variable-consumers.tsv @@ -356,8 +356,10 @@ TMPDIR TMPDIR hosts/codex/bin/continue-dispatch.sh production TMPDIR TMPDIR hosts/opencode/bin/install.sh production TMPDIR TMPDIR ops/release/upgrade-smoke-v0.8-v0.9.sh production TMPDIR TMPDIR runtime/bin/pr-gate.sh production +TMPDIR TMPDIR runtime/lib/gate-result-verify.sh production TMPDIR TMPDIR runtime/lib/handover-validate.sh production TMPDIR TMPDIR runtime/lib/pmctl-artifacts.sh production +TMPDIR TMPDIR runtime/lib/pmctl-gate.sh production TMPDIR TMPDIR runtime/lib/pmctl-pm.sh production TMPDIR TMPDIR runtime/lib/portable.sh production XAI_API_KEY XAI_API_KEY hosts/grok/lib/doctor.sh production diff --git a/docs/executor-contract.md b/docs/executor-contract.md index 2728870c..4e8b94a9 100644 --- a/docs/executor-contract.md +++ b/docs/executor-contract.md @@ -152,7 +152,7 @@ Model B is the canonical dispatch topology: `pmctl dispatch run` lands the brief | Invoker | PM writes the brief to a file and runs `pmctl dispatch run --adapter codex --brief-file <path>`, which validates + guards before invoking the `adapters/codex/dispatch.sh` adapter (codex CLI performs the execution step). | `pmctl dispatch run --adapter claude --brief-file <path>` invokes the `adapters/claude/dispatch.sh` adapter, which runs headless `claude --print` as a CLI subprocess — host-independent, so codex-as-PM can drive it. | | Sandbox model | Isolation intent comes from the required handover `isolation_level:` field; the codex adapter translates it to codex-native `--sandbox`/`--approval`/`-c` flags (workspace-write default). | Headless `claude --print` subprocess: `isolation_level:` translates to a `--permission-mode`; the claude adapter tolerates any stray codex-native `--sandbox`/`--approval` CLI flag as a no-op. | | Write/Bash mechanism | codex CLI drives edits and command execution. | The headless `claude --print` adapter subprocess drives edits and command execution via the Claude CLI (`runner_kind: cli-subprocess`); claude self-governs Bash via `--permission-mode`. | -| Reviewer pipeline trigger | `/pr-gate` dispatches the codex reviewer session via `pmctl dispatch run` and integrity-checks the result in-process. | `/pr-gate --executor claude` dispatches an **independent headless `claude --print` subprocess** (`adapters/claude/dispatch.sh`), symmetric with codex, and integrity-checks the result via `gate_result_verify` / `pmctl gate verify`. The former `pr-gate-handover_v1` in-session fan-out was retired. | +| Reviewer pipeline trigger | `/pr-gate` dispatches the codex reviewer session via `pmctl dispatch run` and verifies `artifact_valid`, `subject_current`, and `policy_applicable` in-process (`pmctl gate verify <result> --cd <repo> --consumer embedded --json`). | `/pr-gate --executor claude` dispatches an **independent headless `claude --print` subprocess** (`adapters/claude/dispatch.sh`), symmetric with codex, and applies the same three-axis verification. The former `pr-gate-handover_v1` in-session fan-out was retired. | | Install requirement | `codex` install profile (current operational mode). | `claude` install profile (lightweight; no codex binary workflow dependency). | | Suitable scope | Repo edits that are already in codex dispatch envelope. | Any dispatch where a headless `claude --print` subprocess is the executor (host-independent; codex-as-PM can drive it). | | Status | Implemented (primary route). | Implemented — `adapters/claude/` + `executor: claude` enum + `install.sh --profile minimal\|full`. | diff --git a/docs/pr-gate-handover-schema.md b/docs/pr-gate-handover-schema.md index 960d96ec..f704a6b1 100644 --- a/docs/pr-gate-handover-schema.md +++ b/docs/pr-gate-handover-schema.md @@ -56,11 +56,14 @@ The behavior below describes the retired handover route and no longer occurs; - The caller must continue writing final output to the shared `output_file` path that the gate script also prints as `result:`. - After the route writes `output_file`, the caller must confirm it with - `pmctl gate verify <output_file>` (exit 0 = structurally valid result). The - codex route runs this same contract in-process after its dispatch; the claude - route's write happens out-of-process, so this explicit verify is what makes - the host-native result trackable and is the gate's authority on whether a - result was actually produced. The gate script prints the exact command to run. + `pmctl gate verify <output_file> --cd <reviewed-repo> --consumer embedded --json`. + A named consumer requires `artifact_valid`, `subject_current`, and + `policy_applicable` all to pass; the default single-argument inspection form + preserves only the historical artifact-validity exit contract. The codex + route runs this verification in-process after dispatch; the retired claude + route's write happened out-of-process, so explicit verification made the + host-native result trackable. Legacy results without immutable subject or + consumer-applicability evidence cannot authorize continuation. Implementation reference: `commands/pr-gate.md` and `runtime/bin/pr-gate.sh`. diff --git a/docs/review-model.md b/docs/review-model.md index f9a95a42..a5e81b96 100644 --- a/docs/review-model.md +++ b/docs/review-model.md @@ -204,30 +204,66 @@ architecture, security, and risk coverage, so the governance tables cannot quietly lower their own future review floor through a small edit. The final producer writes `pr_gate_result_v2` Markdown plus a sibling -`gate_assurance_v2` JSON envelope. The Markdown contains human findings and a +`gate_assurance_v3` JSON envelope. The Markdown contains human findings and a bounded relative `gate_assurance` pointer; the shell-owned envelope records requested/resolved coordinates, selected/skipped coverage, actual dispatch -outcomes, run IDs, subject commits/fingerprint, and the evidence status behind -independence claims. New envelopes also embed the canonical policy result: +outcomes, run IDs, and the evidence status behind independence claims. The v3 +envelope adds an immutable subject: stable Git common-directory repository +identity, optional remote identity, provenance-only observed root, base/head +refs and commits, tree fingerprint, subject kind, dirty policy, and +created/finished observations. It links preflight evidence by digest and leaves +future scope-manifest or closure evidence explicitly unavailable until those +producers exist. Envelopes also embed the canonical policy result: classification facts, every matched signal and path, minimum tier, required coverage, recommended mode, whether policy or the user selected the mode, recommendation divergence, enforcement status, and both policy-override and reviewer-override provenance. Repo-layout results with verified independence also carry -a shell-owned attestation in the protected gate run directory. `pmctl gate -verify` validates result/sidecar digests and resolves every claimed run ID -against the canonical terminal records for the invoking repository and rejects -self-consistent artifacts outside that repository's resolved state partition. +a shell-owned attestation in the protected gate run directory. + +`pmctl gate verify <result> [--cd <repo>] [--consumer <name>] [--json]` +returns three independent axes: + +- `artifact_valid`: result/sidecar schema, digest, and content parity; +- `subject_current`: repository, base, head, and tree freshness; +- `policy_applicable`: resolved coordinates, review evidence, and any + consumer-required closure evidence. + +Every axis includes stable reason codes. A copied artifact can remain valid +while lacking canonical dispatch authorization; a base advance or tree change +makes the subject stale without reclassifying the artifact as forged. Linked +worktree paths remain current when their stable Git subject is unchanged. +Default inspection preserves the historical artifact-validity exit contract; +passing `--consumer` is authorizing and requires all three axes to pass. +`gate wait` and `ship finish` consume this shared assessment rather than +grepping `Final: GO`. + +Subject verification intentionally fingerprints the complete included tree, +not only the diff. Its cost is therefore linear in tracked and included +untracked files each time a subject is finalized or verified. This is bounded +and acceptable for the current repository, but callers operating on large +monorepos should expect verification latency to scale with repository size. + +Do not mutate the reviewed tree between gate finalization and `gate wait` or +`ship finish`. A formatter, test artifact, commit, or other included file write +correctly changes `subject_current` to `fail`; the consumer refuses the result +and reports the drift reason instead of silently authorizing a different tree. +Legacy v1 results retain historical detached-wait completion behavior. A v2 GO +cannot satisfy the wait path's named embedded consumer, and neither v1 nor v2 +is publication authorization; `ship finish` requires a current, applicable v3 +assessment. + The producer publishes the sidecar before atomically replacing the self-contained v1 result with the v2 result that references it, so interruption cannot strand a v2 result with a missing sidecar. The protected attestation is published afterward; verification uses a bounded retry when it observes that -in-flight canonical v2 finalization. Legacy +in-flight canonical finalization. Legacy `pr_gate_result_v1` and unbound `gate_assurance_v1` artifacts remain structurally readable, but verification reports `assurance: unavailable`; consumers must not infer mode, coverage, or independence from them. Earlier `gate_assurance_v2` envelopes without the optional policy block remain -readable, while current producers always emit it. +readable for artifact inspection, but they cannot supply immutable-subject or +consumer-applicability evidence. --- diff --git a/runtime/bin/pr-gate.sh b/runtime/bin/pr-gate.sh index b6aac1a1..e8063734 100755 --- a/runtime/bin/pr-gate.sh +++ b/runtime/bin/pr-gate.sh @@ -1314,6 +1314,17 @@ else ' "$result_file" } + _gate_result_sha256_stream() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + else + printf 'Error: no sha256sum or shasum found -- cannot identify gate subject\n' >&2 + return 2 + fi + } + _gate_result_sha256_file() { local file="$1" digest="" if command -v sha256sum >/dev/null 2>&1 \ @@ -1332,6 +1343,249 @@ else return 2 } + _gate_subject_common_dir() { + local repo_root="$1" common_dir common_parent + common_dir="$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null)" || { + printf 'Error: gate subject is not a Git worktree: %s\n' "$repo_root" >&2 + return 2 + } + if [[ "$common_dir" != /* ]]; then + common_dir="$repo_root/$common_dir" + fi + common_parent="$(cd "$(dirname "$common_dir")" 2>/dev/null && pwd -P)" || return 2 + common_dir="$common_parent/$(basename "$common_dir")" + [[ -d "$common_dir" ]] || { + printf 'Error: gate subject Git common directory is unavailable: %s\n' "$common_dir" >&2 + return 2 + } + printf '%s\n' "$common_dir" + } + + _gate_subject_tree_fingerprint() { + local repo_root="$1" subject_kind="$2" head_commit="$3" + local manifest path quoted kind executable digest + local entry metadata mode object target + manifest="$(mktemp "${TMPDIR:-/tmp}/gate-subject-tree.XXXXXX")" || return 2 + case "$subject_kind" in + fixed_ref) + while IFS= read -r -d '' entry; do + metadata="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + mode="${metadata%% *}" + object="${metadata##* }" + quoted="$(printf '%q' "$path")" + case "$mode" in + 120000) + kind=symlink + executable=false + target="$(git -C "$repo_root" cat-file blob "$object" 2>/dev/null)" || { + rm -f -- "$manifest" + return 2 + } + digest="$(printf '%s' "$target" | _gate_result_sha256_stream)" || { + rm -f -- "$manifest" + return 2 + } + ;; + 100644|100755) + kind="file" + [[ "$mode" == 100755 ]] && executable=true || executable=false + digest="$(git -C "$repo_root" cat-file blob "$object" 2>/dev/null \ + | _gate_result_sha256_stream)" || { + rm -f -- "$manifest" + return 2 + } + ;; + *) + # Keep gitlinks and other non-file entries visible without + # claiming local file content, matching the workspace manifest. + kind=missing + executable=false + digest=- + ;; + esac + printf '%s\t%s\t%s\t%s\n' "$quoted" "$kind" "$executable" "$digest" \ + >> "$manifest" + done < <(git -C "$repo_root" ls-tree -r -z --full-tree "$head_commit" 2>/dev/null) + ;; + committed_head|working_tree) + while IFS= read -r -d '' path; do + case "$path" in + .agent-trace|.agent-trace/*|.gate-briefs|.gate-briefs/*|.gate-results|.gate-results/*) + continue + ;; + esac + quoted="$(printf '%q' "$path")" + if [[ -L "$repo_root/$path" ]]; then + kind=symlink + executable=false + digest="$(printf '%s' "$(readlink "$repo_root/$path")" \ + | _gate_result_sha256_stream)" || { + rm -f -- "$manifest" + return 2 + } + elif [[ -f "$repo_root/$path" ]]; then + kind="file" + [[ -x "$repo_root/$path" ]] && executable=true || executable=false + digest="$(_gate_result_sha256_file "$repo_root/$path")" || { + rm -f -- "$manifest" + return 2 + } + else + kind=missing + executable=false + digest=- + fi + printf '%s\t%s\t%s\t%s\n' "$quoted" "$kind" "$executable" "$digest" \ + >> "$manifest" + done < <(git -C "$repo_root" ls-files --cached --others --exclude-standard -z) + ;; + *) + printf 'Error: unsupported gate subject kind: %s\n' "$subject_kind" >&2 + rm -f -- "$manifest" + return 2 + ;; + esac + LC_ALL=C sort "$manifest" | _gate_result_sha256_stream + local rc=$? + rm -f -- "$manifest" + return "$rc" + } + + gate_subject_snapshot() { + local repo_root="$1" base_ref="$2" head_ref="$3" subject_kind="$4" + local dirty_policy="$5" captured_at="$6" + local observed_root common_dir common_identity remote remote_identity repository_key + local base_commit head_commit tree_fingerprint fingerprint_kind + observed_root="$(cd "$repo_root" 2>/dev/null && pwd -P)" || return 2 + common_dir="$(_gate_subject_common_dir "$observed_root")" || return $? + common_identity="$(printf '%s' "$common_dir" | _gate_result_sha256_stream)" || return $? + remote="$(git -C "$observed_root" config --get remote.origin.url 2>/dev/null || true)" + remote_identity="" + if [[ -n "$remote" ]]; then + remote_identity="$(printf '%s' "$remote" | _gate_result_sha256_stream)" || return $? + fi + repository_key="$( + printf 'common:%s\nremote:%s\n' "$common_identity" "${remote_identity:-absent}" \ + | _gate_result_sha256_stream + )" || return $? + base_commit="$(git -C "$observed_root" rev-parse "${base_ref}^{commit}" 2>/dev/null)" \ + || return 2 + head_commit="$(git -C "$observed_root" rev-parse "${head_ref}^{commit}" 2>/dev/null)" \ + || return 2 + fingerprint_kind="$subject_kind" + if [[ "$subject_kind" == committed_head ]] \ + && { ! git -C "$observed_root" diff --quiet HEAD 2>/dev/null \ + || [[ -n "$(git -C "$observed_root" ls-files --others --exclude-standard)" ]]; }; then + fingerprint_kind=working_tree + fi + tree_fingerprint="$( + _gate_subject_tree_fingerprint "$observed_root" "$fingerprint_kind" "$head_commit" + )" || return $? + + jq -nc \ + --arg repository_key "$repository_key" \ + --arg common_identity "$common_identity" \ + --arg remote_identity "$remote_identity" \ + --arg observed_root "$observed_root" \ + --arg common_dir "$common_dir" \ + --arg base_ref "$base_ref" --arg base_commit "$base_commit" \ + --arg head_ref "$head_ref" --arg head_commit "$head_commit" \ + --arg tree_fingerprint "$tree_fingerprint" \ + --arg subject_kind "$subject_kind" --arg dirty_policy "$dirty_policy" \ + --arg captured_at "$captured_at" '{ + repository:{ + key:$repository_key, + git_common_dir_identity:$common_identity, + remote_identity:(if $remote_identity == "" then null else $remote_identity end) + }, + observed:{ + root:$observed_root, + git_common_dir:$common_dir + }, + base:{ref:$base_ref,commit:$base_commit}, + head:{ref:$head_ref,commit:$head_commit}, + tree_fingerprint:$tree_fingerprint, + subject_kind:$subject_kind, + dirty_policy:$dirty_policy, + captured_at:$captured_at + }' + } + + _gate_assurance_linked_evidence_verify() { + local assurance_file="$1" assurance_dir label artifact expected_sha + local linked_subject subject_fingerprint artifact_path actual_sha + local artifact_subject artifact_outcome linked_outcome + assurance_dir="$(cd "$(dirname "$assurance_file")" 2>/dev/null && pwd -P)" \ + || return 1 + subject_fingerprint="$(jq -r '.subject.tree_fingerprint' "$assurance_file")" + while IFS=$'\t' read -r label artifact expected_sha linked_subject; do + [[ -n "$label" ]] || continue + artifact_path="$assurance_dir/$artifact" + if [[ ! -f "$artifact_path" || -L "$artifact_path" ]]; then + printf 'Error: gate assurance linked %s evidence is missing or unsafe: %s\n' \ + "$label" "$artifact_path" >&2 + return 1 + fi + actual_sha="$(_gate_result_sha256_file "$artifact_path")" || return $? + if [[ "$actual_sha" != "$expected_sha" ]]; then + printf 'Error: gate assurance linked %s evidence digest mismatch: %s\n' \ + "$label" "$artifact_path" >&2 + return 1 + fi + if [[ "$linked_subject" != "$subject_fingerprint" ]]; then + printf 'Error: gate assurance linked %s evidence subject mismatch: %s\n' \ + "$label" "$artifact_path" >&2 + return 1 + fi + if [[ "$label" == preflight ]]; then + if ! artifact_subject="$( + jq -er '.subject.fingerprint_before | + select(type == "string" and test("^[a-f0-9]{64}$"))' \ + "$artifact_path" 2>/dev/null + )" || ! artifact_outcome="$( + jq -er '.status | + select(. == "pass" or . == "fail" or . == "timeout" or + . == "stale" or . == "invalid")' "$artifact_path" 2>/dev/null + )"; then + printf 'Error: gate assurance linked preflight evidence claim is malformed: %s\n' \ + "$artifact_path" >&2 + return 1 + fi + linked_outcome="$(jq -r '.evidence.preflight.outcome' "$assurance_file")" + if [[ "$artifact_subject" != "$linked_subject" ]]; then + printf 'Error: gate assurance linked preflight evidence subject claim mismatch: %s\n' \ + "$artifact_path" >&2 + return 1 + fi + if [[ ( "$linked_outcome" == pass && "$artifact_outcome" != pass ) \ + || ( "$linked_outcome" == fail && "$artifact_outcome" == pass ) ]]; then + printf 'Error: gate assurance linked preflight evidence outcome mismatch: %s\n' \ + "$artifact_path" >&2 + return 1 + fi + fi + done < <( + jq -r ' + [ + (if .evidence.preflight.status == "linked" then + ["preflight",.evidence.preflight.artifact, + .evidence.preflight.sha256,.evidence.preflight.subject_fingerprint] + else empty end), + (if .evidence.scope_manifest.status == "verified" then + ["scope_manifest",.evidence.scope_manifest.artifact, + .evidence.scope_manifest.sha256, + .evidence.scope_manifest.subject_fingerprint] + else empty end), + (if .evidence.closure.status == "verified" then + ["closure",.evidence.closure.artifact, + .evidence.closure.sha256,.evidence.closure.subject_fingerprint] + else empty end) + ][] | @tsv + ' "$assurance_file" + ) + } + gate_assurance_verify() { local result_file="$1" assurance_file="$2" body_final="$3" local markdown_tier markdown_mode result_sha assurance_kind @@ -1372,11 +1626,91 @@ else type == "array" and all(.[]; type == "string" and length > 0) and (length == (unique | length)); def same_set($a; $b): ($a | sort) == ($b | sort); - only_keys(["kind","schema_version","result","bindings","coordinates", - "policy","dispatch","provenance"]) and + only_keys(["kind","schema_version","result","bindings","subject","evidence", + "coordinates","policy","dispatch","provenance"]) and (.result | only_keys(["final"])) and (.bindings | only_keys(["result_sha256","repo_root","repo_identity", "base_commit","head_commit","subject_fingerprint"])) and + (if .kind == "gate_assurance_v3" then + (.subject | + only_keys(["kind","schema_version","repository","observed","base","head", + "tree_fingerprint","subject_kind","dirty_policy","created_at", + "finished_at","observed_at_finish"])) and + .subject.kind == "gate_subject_v1" and .subject.schema_version == 1 and + (.subject.repository | + only_keys(["key","git_common_dir_identity","remote_identity"])) and + (.subject.repository.key | test("^[a-f0-9]{64}$")) and + (.subject.repository.git_common_dir_identity | test("^[a-f0-9]{64}$")) and + (.subject.repository.remote_identity == null or + (.subject.repository.remote_identity | test("^[a-f0-9]{64}$"))) and + (.subject.observed | only_keys(["root","git_common_dir"])) and + (.subject.observed.root | type == "string" and startswith("/")) and + (.subject.observed.git_common_dir | type == "string" and startswith("/")) and + (.subject.base | only_keys(["ref","commit"])) and + (.subject.base.ref | type == "string" and length > 0) and + (.subject.base.commit | test("^[a-f0-9]{40}$")) and + (.subject.head | only_keys(["ref","commit"])) and + (.subject.head.ref | type == "string" and length > 0) and + (.subject.head.commit | test("^[a-f0-9]{40}$")) and + (.subject.tree_fingerprint | test("^[a-f0-9]{64}$")) and + (.subject.subject_kind | IN("committed_head","working_tree","fixed_ref")) and + (.subject.dirty_policy | + IN("require_clean","include_working_tree","ignore_working_tree")) and + ((.subject.subject_kind == "committed_head" and + .subject.dirty_policy == "require_clean") or + (.subject.subject_kind == "working_tree" and + .subject.dirty_policy == "include_working_tree") or + (.subject.subject_kind == "fixed_ref" and + .subject.dirty_policy == "ignore_working_tree")) and + (.subject.created_at | + test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and + (.subject.finished_at | + test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and + (.subject.observed_at_finish | + only_keys(["repository_key","base_commit","head_commit", + "tree_fingerprint"])) and + (.subject.observed_at_finish.repository_key | test("^[a-f0-9]{64}$")) and + (.subject.observed_at_finish.base_commit | test("^[a-f0-9]{40}$")) and + (.subject.observed_at_finish.head_commit | test("^[a-f0-9]{40}$")) and + (.subject.observed_at_finish.tree_fingerprint | test("^[a-f0-9]{64}$")) and + (.evidence | only_keys(["preflight","scope_manifest","closure"])) and + (.evidence.preflight | + only_keys(["status","outcome","artifact","sha256", + "subject_fingerprint"])) and + (.evidence.preflight.status | IN("not_run","linked")) and + (if .evidence.preflight.status == "linked" then + (.evidence.preflight.outcome | IN("pass","fail")) and + (.evidence.preflight.artifact | + type == "string" and + test("^preflight-evidence-[0-9]{8}-[0-9]{6}\\.json$")) and + (.evidence.preflight.sha256 | test("^[a-f0-9]{64}$")) and + (.evidence.preflight.subject_fingerprint | test("^[a-f0-9]{64}$")) + else + .evidence.preflight.outcome == null and + .evidence.preflight.artifact == null and + .evidence.preflight.sha256 == null and + .evidence.preflight.subject_fingerprint == null + end) and + (all([.evidence.scope_manifest,.evidence.closure][]; + only_keys(["status","artifact","sha256","subject_fingerprint"]) and + (.status | IN("unavailable","verified")) and + (if .status == "verified" then + (.artifact | + type == "string" and length > 0 and (contains("/") | not)) and + (.sha256 | test("^[a-f0-9]{64}$")) and + (.subject_fingerprint | test("^[a-f0-9]{64}$")) + else + .artifact == null and .sha256 == null and .subject_fingerprint == null + end))) and + .bindings.repo_root == .subject.observed.root and + .bindings.repo_identity == .subject.repository.key and + .bindings.base_commit == .subject.base.commit and + .bindings.head_commit == .subject.head.commit and + .bindings.subject_fingerprint == .subject.tree_fingerprint and + .subject.created_at <= .subject.finished_at + else + (has("subject") | not) and (has("evidence") | not) + end) and (.coordinates | only_keys(["tier","mode","pass","coverage","independence"])) and (.coordinates.tier | only_keys(["requested","resolved","evidence_floor"])) and (.coordinates.mode | only_keys(["requested","resolved","topology","synthesis"])) and @@ -1511,7 +1845,8 @@ else (all(.dispatch.outcomes[]; only_keys(["role","reviewer","status","run_id","evidence_status"]))) and (.provenance | only_keys(["producer","policy_source","attestation"])) and - .kind == "gate_assurance_v2" and .schema_version == 2 and + ((.kind == "gate_assurance_v2" and .schema_version == 2) or + (.kind == "gate_assurance_v3" and .schema_version == 3)) and .result.final == $final and .bindings.result_sha256 == $result_sha and (.bindings.repo_root | type == "string" and startswith("/")) and @@ -1618,6 +1953,9 @@ else "$assurance_file" >&2 return 1 } + if [[ "$assurance_kind" == gate_assurance_v3 ]]; then + _gate_assurance_linked_evidence_verify "$assurance_file" || return $? + fi GATE_ASSURANCE_BOUND=true export GATE_ASSURANCE_BOUND } @@ -2896,7 +3234,9 @@ gate_finalize_assurance() { local result_file="$1" assurance_file="$2" local final requested_json outcomes_json independence_status implementation_isolated local per_reviewer_independent expected_count capture_count assurance_tmp result_tmp - local result_sha assurance_sha attestation_tmp run_ids_json attestation_pointer + local result_sha assurance_sha subject_sha attestation_tmp run_ids_json attestation_pointer + local finished_at subject_finish subject_json preflight_json evidence_json + local evidence_destination evidence_destination_sha evidence_tmp local -a capture_files=() final="$(grep -E '^Final: (GO|NO-GO)$' "$result_file" | awk '{print $2}')" @@ -2957,6 +3297,83 @@ gate_finalize_assurance() { if [[ "$independence_status" == verified ]]; then attestation_pointer="$ASSURANCE_ATTESTATION_POINTER" fi + finished_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ + || date +'%Y-%m-%dT%H:%M:%SZ')" + subject_finish="$( + gate_subject_snapshot "$WORK_DIR" "$BASE" "$HEAD_REF" "$GATE_SUBJECT_KIND" \ + "$GATE_SUBJECT_DIRTY_POLICY" "$finished_at" + )" || { + printf 'Error: unable to capture final gate subject\n' >&2 + return 1 + } + subject_json="$(jq -nc \ + --argjson initial "$GATE_SUBJECT_INITIAL" \ + --argjson finish "$subject_finish" '{ + kind:"gate_subject_v1", + schema_version:1, + repository:$initial.repository, + observed:$initial.observed, + base:$initial.base, + head:$initial.head, + tree_fingerprint:$initial.tree_fingerprint, + subject_kind:$initial.subject_kind, + dirty_policy:$initial.dirty_policy, + created_at:$initial.captured_at, + finished_at:$finish.captured_at, + observed_at_finish:{ + repository_key:$finish.repository.key, + base_commit:$finish.base.commit, + head_commit:$finish.head.commit, + tree_fingerprint:$finish.tree_fingerprint + } + }')" || return 1 + if [[ "$PREFLIGHT_STATUS" == skipped ]]; then + preflight_json='{"status":"not_run","outcome":null,"artifact":null,"sha256":null,"subject_fingerprint":null}' + else + evidence_destination="$(dirname "$assurance_file")/$(basename "$PREFLIGHT_EVIDENCE_PATH")" + if [[ "$PREFLIGHT_EVIDENCE_PATH" != "$evidence_destination" ]]; then + _gate_assurance_destination_check "$evidence_destination" || return 1 + if [[ -e "$evidence_destination" ]]; then + evidence_destination_sha="$( + _gate_result_sha256_file "$evidence_destination" + )" || return $? + if [[ "$evidence_destination_sha" != "$PREFLIGHT_EVIDENCE_DIGEST" ]]; then + printf 'Error: linked preflight evidence destination already exists with different content: %s\n' \ + "$evidence_destination" >&2 + return 1 + fi + else + evidence_tmp="$(mktemp "${evidence_destination}.tmp.XXXXXX")" || return 1 + if ! cp -- "$PREFLIGHT_EVIDENCE_PATH" "$evidence_tmp" \ + || ! mv -- "$evidence_tmp" "$evidence_destination"; then + rm -f -- "$evidence_tmp" + return 1 + fi + fi + PREFLIGHT_EVIDENCE_PATH="$evidence_destination" + fi + preflight_json="$(jq -nc \ + --arg outcome "$PREFLIGHT_STATUS" \ + --arg artifact "$(basename "$PREFLIGHT_EVIDENCE_PATH")" \ + --arg sha256 "$PREFLIGHT_EVIDENCE_DIGEST" \ + --arg subject_fingerprint \ + "$(jq -r '.tree_fingerprint' <<<"$GATE_SUBJECT_INITIAL")" '{ + status:"linked", + outcome:$outcome, + artifact:$artifact, + sha256:$sha256, + subject_fingerprint:$subject_fingerprint + }')" || return 1 + fi + evidence_json="$(jq -nc --argjson preflight "$preflight_json" '{ + preflight:$preflight, + scope_manifest:{ + status:"unavailable",artifact:null,sha256:null,subject_fingerprint:null + }, + closure:{ + status:"unavailable",artifact:null,sha256:null,subject_fingerprint:null + } + }')" || return 1 result_tmp="$(mktemp "${result_file}.assurance-tmp.XXXXXX")" || { printf 'Error: unable to create gate result temporary file beside: %s\n' \ @@ -2994,7 +3411,7 @@ gate_finalize_assurance() { if ! jq -n \ --arg final "$final" \ --arg result_sha "$result_sha" \ - --arg repo_root "$WORK_DIR" --arg repo_identity "$GATE_BINDING_REPO_IDENTITY" \ + --arg repo_root "$WORK_DIR" --arg repo_identity "$GATE_SUBJECT_REPOSITORY_KEY" \ --arg base_commit "$GATE_BINDING_BASE_COMMIT" \ --arg head_commit "$GATE_BINDING_HEAD_COMMIT" \ --arg subject_fingerprint "$GATE_BINDING_SUBJECT_FINGERPRINT" \ @@ -3011,11 +3428,12 @@ gate_finalize_assurance() { --arg policy_source "$GATE_ASSURANCE_POLICY_SOURCE" \ --arg attestation "$attestation_pointer" \ --argjson requested "$requested_json" --argjson outcomes "$outcomes_json" \ + --argjson subject "$subject_json" --argjson evidence "$evidence_json" \ --argjson policy_resolution "$GATE_POLICY_RESOLUTION" \ --argjson implementation_isolated "$implementation_isolated" \ --argjson per_reviewer_independent "$per_reviewer_independent" ' { - kind:"gate_assurance_v2",schema_version:2, + kind:"gate_assurance_v3",schema_version:3, result:{final:$final}, bindings:{ result_sha256:$result_sha, @@ -3025,6 +3443,8 @@ gate_finalize_assurance() { head_commit:$head_commit, subject_fingerprint:$subject_fingerprint }, + subject:$subject, + evidence:$evidence, coordinates:{ tier:{requested:$tier_requested,resolved:$tier_resolved, evidence_floor:$evidence_floor}, @@ -3061,7 +3481,7 @@ gate_finalize_assurance() { rm -f -- "$assurance_tmp" "$result_tmp" return 1 } - # Publish the sidecar before the v2 result that references it. A verifier + # Publish the sidecar before the v2 Markdown result that references it. A verifier # racing this boundary sees either the original self-contained v1 result or # the complete v2 pair; a host failure cannot strand a v2 result with a # permanently missing sidecar. @@ -3077,6 +3497,8 @@ gate_finalize_assurance() { if [[ "$independence_status" == verified ]]; then assurance_sha="$(_gate_result_sha256_file "$assurance_file")" || return $? + subject_sha="$(jq -cS '.subject' "$assurance_file" \ + | _gate_result_sha256_stream)" || return $? run_ids_json="$(jq -c '[.[].run_id]' <<<"$outcomes_json")" || return 1 _gate_assurance_destination_check "$ASSURANCE_ATTESTATION_FILE" || return 1 attestation_tmp="$(mktemp "${ASSURANCE_ATTESTATION_FILE}.tmp.XXXXXX")" || { @@ -3085,13 +3507,15 @@ gate_finalize_assurance() { } if ! jq -n \ --arg result_sha "$result_sha" --arg assurance_sha "$assurance_sha" \ - --arg repo_root "$WORK_DIR" --arg repo_identity "$GATE_BINDING_REPO_IDENTITY" \ + --arg repo_root "$WORK_DIR" --arg repo_identity "$GATE_SUBJECT_REPOSITORY_KEY" \ --arg base_commit "$GATE_BINDING_BASE_COMMIT" \ --arg head_commit "$GATE_BINDING_HEAD_COMMIT" \ --arg subject_fingerprint "$GATE_BINDING_SUBJECT_FINGERPRINT" \ + --arg repository_key "$GATE_SUBJECT_REPOSITORY_KEY" \ + --arg subject_sha "$subject_sha" \ --argjson run_ids "$run_ids_json" '{ - kind:"gate_assurance_attestation_v1", - schema_version:1, + kind:"gate_assurance_attestation_v2", + schema_version:2, result_sha256:$result_sha, assurance_sha256:$assurance_sha, repo_root:$repo_root, @@ -3099,6 +3523,8 @@ gate_finalize_assurance() { base_commit:$base_commit, head_commit:$head_commit, subject_fingerprint:$subject_fingerprint, + repository_key:$repository_key, + subject_sha256:$subject_sha, run_ids:$run_ids }' > "$attestation_tmp"; then rm -f -- "$attestation_tmp" @@ -3331,28 +3757,8 @@ _preflight_sha256_file() { # untracked content (including executable bits and symlink targets), while # excluding only gate-owned runtime artifacts created by this invocation. _preflight_tree_fingerprint() { - local manifest path quoted kind executable digest - manifest="$(mktemp "${TMPDIR:-/tmp}/pr-gate-tree.XXXXXX")" || return 2 - while IFS= read -r -d '' path; do - case "$path" in - .agent-trace|.agent-trace/*|.gate-briefs|.gate-briefs/*|.gate-results|.gate-results/*) continue ;; - esac - quoted="$(printf '%q' "$path")" - if [[ -L "$WORK_DIR/$path" ]]; then - kind=symlink; executable=false - digest="$(printf '%s' "$(readlink "$WORK_DIR/$path")" | _gate_sha256_stream)" || { rm -f "$manifest"; return 2; } - elif [[ -f "$WORK_DIR/$path" ]]; then - kind=file; [[ -x "$WORK_DIR/$path" ]] && executable=true || executable=false - digest="$(_preflight_sha256_file "$WORK_DIR/$path")" || { rm -f "$manifest"; return 2; } - else - kind=missing; executable=false; digest=- - fi - printf '%s\t%s\t%s\t%s\n' "$quoted" "$kind" "$executable" "$digest" >> "$manifest" - done < <(git -C "$WORK_DIR" ls-files --cached --others --exclude-standard -z) - LC_ALL=C sort "$manifest" | _gate_sha256_stream - local rc=$? - rm -f "$manifest" - return "$rc" + _gate_subject_tree_fingerprint "$WORK_DIR" working_tree \ + "$(git -C "$WORK_DIR" rev-parse HEAD 2>/dev/null)" } _preflight_repo_identity() { @@ -3361,10 +3767,32 @@ _preflight_repo_identity() { printf '%s\n%s\n' "$WORK_DIR" "$remote" | _gate_sha256_stream } -GATE_BINDING_SUBJECT_FINGERPRINT="$(_preflight_tree_fingerprint)" || exit 2 GATE_BINDING_REPO_IDENTITY="$(_preflight_repo_identity)" || exit 2 GATE_BINDING_BASE_COMMIT="$(git rev-parse "${BASE}^{commit}")" || exit 2 GATE_BINDING_HEAD_COMMIT="$(git rev-parse "${HEAD_REF}^{commit}")" || exit 2 +GATE_SUBJECT_CREATED_AT="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ + || date +'%Y-%m-%dT%H:%M:%SZ')" +if [[ "$HEAD_REF" != "HEAD" ]]; then + GATE_SUBJECT_KIND=fixed_ref + GATE_SUBJECT_DIRTY_POLICY=ignore_working_tree +elif _worktree_is_dirty; then + GATE_SUBJECT_KIND=working_tree + GATE_SUBJECT_DIRTY_POLICY=include_working_tree +else + GATE_SUBJECT_KIND=committed_head + GATE_SUBJECT_DIRTY_POLICY=require_clean +fi +GATE_SUBJECT_INITIAL="$( + gate_subject_snapshot "$WORK_DIR" "$BASE" "$HEAD_REF" "$GATE_SUBJECT_KIND" \ + "$GATE_SUBJECT_DIRTY_POLICY" "$GATE_SUBJECT_CREATED_AT" +)" || { + printf 'Error: unable to capture immutable gate subject\n' >&2 + exit 2 +} +GATE_SUBJECT_REPOSITORY_KEY="$(jq -r '.repository.key' <<<"$GATE_SUBJECT_INITIAL")" +GATE_BINDING_SUBJECT_FINGERPRINT="$( + jq -r '.tree_fingerprint' <<<"$GATE_SUBJECT_INITIAL" +)" if [[ "$SKIP_PREFLIGHT_TESTS" != "true" && -n "$TEST_CMD_OVERRIDE" ]]; then # pr-gate.sh is designed to be copied standalone into any repo (copy-mode -- @@ -3380,7 +3808,7 @@ if [[ "$SKIP_PREFLIGHT_TESTS" != "true" && -n "$TEST_CMD_OVERRIDE" ]]; then PREFLIGHT_EVIDENCE_PATH="$WORK_DIR/.gate-results/preflight-evidence-${TIMESTAMP}.json" PREFLIGHT_RICH_RESULT_PATH="$WORK_DIR/.gate-results/preflight-rich-result-${TIMESTAMP}.json" _preflight_command_digest="$(printf '%s' "$TEST_CMD_OVERRIDE" | _gate_sha256_stream)" || exit 2 - _preflight_before="$GATE_BINDING_SUBJECT_FINGERPRINT" + _preflight_before="$(_preflight_tree_fingerprint)" || exit 2 _preflight_repo_id="$GATE_BINDING_REPO_IDENTITY" _preflight_base_commit="$GATE_BINDING_BASE_COMMIT" _preflight_head_commit="$GATE_BINDING_HEAD_COMMIT" diff --git a/runtime/lib/gate-result-verify.sh b/runtime/lib/gate-result-verify.sh index 706500b4..d5b45045 100644 --- a/runtime/lib/gate-result-verify.sh +++ b/runtime/lib/gate-result-verify.sh @@ -5,7 +5,8 @@ # frontmatter/body verdict parity and is reported as assurance=unavailable. # pr_gate_result_v2 points at a sibling assurance JSON envelope owned by the # gate shell. Legacy gate_assurance_v1 remains readable but non-authorizing; -# gate_assurance_v2 adds subject/result bindings and protected attestation. +# gate_assurance_v2 adds result bindings and protected dispatch attestation. +# gate_assurance_v3 adds an immutable subject plus digest-bound evidence links. # gate_result_verdict_verify <result_file> [expected_final] [route_label] gate_result_verdict_verify() { @@ -54,6 +55,378 @@ _gate_result_frontmatter_value() { ' "$result_file" } +_gate_result_sha256_stream() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + else + printf 'Error: no sha256sum or shasum found -- cannot identify gate subject\n' >&2 + return 2 + fi +} + +_gate_subject_common_dir() { + local repo_root="$1" common_dir common_parent + common_dir="$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null)" || { + printf 'Error: gate subject is not a Git worktree: %s\n' "$repo_root" >&2 + return 2 + } + if [[ "$common_dir" != /* ]]; then + common_dir="$repo_root/$common_dir" + fi + common_parent="$(cd "$(dirname "$common_dir")" 2>/dev/null && pwd -P)" || return 2 + common_dir="$common_parent/$(basename "$common_dir")" + [[ -d "$common_dir" ]] || { + printf 'Error: gate subject Git common directory is unavailable: %s\n' "$common_dir" >&2 + return 2 + } + printf '%s\n' "$common_dir" +} + +_gate_subject_tree_fingerprint() { + local repo_root="$1" subject_kind="$2" head_commit="$3" + local manifest path quoted kind executable digest + local entry metadata mode object target + manifest="$(mktemp "${TMPDIR:-/tmp}/gate-subject-tree.XXXXXX")" || return 2 + case "$subject_kind" in + fixed_ref) + while IFS= read -r -d '' entry; do + metadata="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + mode="${metadata%% *}" + object="${metadata##* }" + quoted="$(printf '%q' "$path")" + case "$mode" in + 120000) + kind=symlink + executable=false + target="$(git -C "$repo_root" cat-file blob "$object" 2>/dev/null)" || { + rm -f -- "$manifest" + return 2 + } + digest="$(printf '%s' "$target" | _gate_result_sha256_stream)" || { + rm -f -- "$manifest" + return 2 + } + ;; + 100644|100755) + kind="file" + [[ "$mode" == 100755 ]] && executable=true || executable=false + digest="$(git -C "$repo_root" cat-file blob "$object" 2>/dev/null \ + | _gate_result_sha256_stream)" || { + rm -f -- "$manifest" + return 2 + } + ;; + *) + # Keep gitlinks and other non-file entries visible without + # claiming local file content, matching the workspace manifest. + kind=missing + executable=false + digest=- + ;; + esac + printf '%s\t%s\t%s\t%s\n' "$quoted" "$kind" "$executable" "$digest" \ + >> "$manifest" + done < <(git -C "$repo_root" ls-tree -r -z --full-tree "$head_commit" 2>/dev/null) + ;; + committed_head|working_tree) + while IFS= read -r -d '' path; do + case "$path" in + .agent-trace|.agent-trace/*|.gate-briefs|.gate-briefs/*|.gate-results|.gate-results/*) + continue + ;; + esac + quoted="$(printf '%q' "$path")" + if [[ -L "$repo_root/$path" ]]; then + kind=symlink + executable=false + digest="$(printf '%s' "$(readlink "$repo_root/$path")" \ + | _gate_result_sha256_stream)" || { + rm -f -- "$manifest" + return 2 + } + elif [[ -f "$repo_root/$path" ]]; then + kind="file" + [[ -x "$repo_root/$path" ]] && executable=true || executable=false + digest="$(_gate_result_sha256_file "$repo_root/$path")" || { + rm -f -- "$manifest" + return 2 + } + else + kind=missing + executable=false + digest=- + fi + printf '%s\t%s\t%s\t%s\n' "$quoted" "$kind" "$executable" "$digest" \ + >> "$manifest" + done < <(git -C "$repo_root" ls-files --cached --others --exclude-standard -z) + ;; + *) + printf 'Error: unsupported gate subject kind: %s\n' "$subject_kind" >&2 + rm -f -- "$manifest" + return 2 + ;; + esac + LC_ALL=C sort "$manifest" | _gate_result_sha256_stream + local rc=$? + rm -f -- "$manifest" + return "$rc" +} + +# gate_subject_snapshot <repo> <base-ref> <head-ref> <subject-kind> +# <dirty-policy> <captured-at> +# Prints one immutable-subject observation. Repository identity is stable across +# linked worktrees because it is rooted in Git's common directory; the observed +# worktree path remains provenance only. +gate_subject_snapshot() { + local repo_root="$1" base_ref="$2" head_ref="$3" subject_kind="$4" + local dirty_policy="$5" captured_at="$6" + local observed_root common_dir common_identity remote remote_identity repository_key + local base_commit head_commit tree_fingerprint fingerprint_kind + observed_root="$(cd "$repo_root" 2>/dev/null && pwd -P)" || return 2 + common_dir="$(_gate_subject_common_dir "$observed_root")" || return $? + common_identity="$(printf '%s' "$common_dir" | _gate_result_sha256_stream)" || return $? + remote="$(git -C "$observed_root" config --get remote.origin.url 2>/dev/null || true)" + remote_identity="" + if [[ -n "$remote" ]]; then + remote_identity="$(printf '%s' "$remote" | _gate_result_sha256_stream)" || return $? + fi + repository_key="$( + printf 'common:%s\nremote:%s\n' "$common_identity" "${remote_identity:-absent}" \ + | _gate_result_sha256_stream + )" || return $? + base_commit="$(git -C "$observed_root" rev-parse "${base_ref}^{commit}" 2>/dev/null)" \ + || return 2 + head_commit="$(git -C "$observed_root" rev-parse "${head_ref}^{commit}" 2>/dev/null)" \ + || return 2 + fingerprint_kind="$subject_kind" + if [[ "$subject_kind" == committed_head ]] \ + && { ! git -C "$observed_root" diff --quiet HEAD 2>/dev/null \ + || [[ -n "$(git -C "$observed_root" ls-files --others --exclude-standard)" ]]; }; then + fingerprint_kind=working_tree + fi + tree_fingerprint="$( + _gate_subject_tree_fingerprint "$observed_root" "$fingerprint_kind" "$head_commit" + )" || return $? + + jq -nc \ + --arg repository_key "$repository_key" \ + --arg common_identity "$common_identity" \ + --arg remote_identity "$remote_identity" \ + --arg observed_root "$observed_root" \ + --arg common_dir "$common_dir" \ + --arg base_ref "$base_ref" --arg base_commit "$base_commit" \ + --arg head_ref "$head_ref" --arg head_commit "$head_commit" \ + --arg tree_fingerprint "$tree_fingerprint" \ + --arg subject_kind "$subject_kind" --arg dirty_policy "$dirty_policy" \ + --arg captured_at "$captured_at" '{ + repository:{ + key:$repository_key, + git_common_dir_identity:$common_identity, + remote_identity:(if $remote_identity == "" then null else $remote_identity end) + }, + observed:{ + root:$observed_root, + git_common_dir:$common_dir + }, + base:{ref:$base_ref,commit:$base_commit}, + head:{ref:$head_ref,commit:$head_commit}, + tree_fingerprint:$tree_fingerprint, + subject_kind:$subject_kind, + dirty_policy:$dirty_policy, + captured_at:$captured_at + }' +} + +# gate_subject_assess <assurance-file> <current-repo> +# Always prints a structured axis object. An older assurance envelope remains a +# valid historical artifact but cannot claim current-subject evidence. +gate_subject_assess() { + local assurance_file="$1" repo_root="$2" + local kind current current_at + kind="$(jq -r '.kind // empty' "$assurance_file" 2>/dev/null)" + if [[ "$kind" != gate_assurance_v3 ]]; then + jq -nc '{ + status:"unavailable", + reason_codes:["immutable_subject_unavailable"] + }' + return 0 + fi + current_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ + || date +'%Y-%m-%dT%H:%M:%SZ')" + current="$( + gate_subject_snapshot "$repo_root" \ + "$(jq -r '.subject.base.ref' "$assurance_file")" \ + "$(jq -r '.subject.head.ref' "$assurance_file")" \ + "$(jq -r '.subject.subject_kind' "$assurance_file")" \ + "$(jq -r '.subject.dirty_policy' "$assurance_file")" \ + "$current_at" + )" || { + jq -nc '{ + status:"unavailable", + reason_codes:["current_subject_unresolvable"] + }' + return 0 + } + jq -nc --slurpfile assurance "$assurance_file" --argjson current "$current" ' + $assurance[0] as $a | + ([ + if $a.subject.repository.key != $a.subject.observed_at_finish.repository_key + then "producer_repository_drift" else empty end, + if $a.subject.base.commit != $a.subject.observed_at_finish.base_commit + then "producer_base_drift" else empty end, + if $a.subject.head.commit != $a.subject.observed_at_finish.head_commit + then "producer_head_drift" else empty end, + if $a.subject.tree_fingerprint != $a.subject.observed_at_finish.tree_fingerprint + then "producer_tree_drift" else empty end, + if $a.subject.repository.key != $current.repository.key + then "repository_mismatch" else empty end, + if $a.subject.base.commit != $current.base.commit + then "base_advanced" else empty end, + if $a.subject.head.commit != $current.head.commit + then "head_moved" else empty end, + if $a.subject.tree_fingerprint != $current.tree_fingerprint + then "tree_drift" else empty end + ] | unique) as $reasons | + { + status:(if ($reasons | length) == 0 then "pass" else "fail" end), + reason_codes:$reasons, + current:{ + repository_key:$current.repository.key, + base_commit:$current.base.commit, + head_commit:$current.head.commit, + tree_fingerprint:$current.tree_fingerprint, + observed_root:$current.observed.root + } + }' +} + +_gate_assurance_linked_evidence_verify() { + local assurance_file="$1" assurance_dir label artifact expected_sha + local linked_subject subject_fingerprint artifact_path actual_sha + local artifact_subject artifact_outcome linked_outcome + assurance_dir="$(cd "$(dirname "$assurance_file")" 2>/dev/null && pwd -P)" \ + || return 1 + subject_fingerprint="$(jq -r '.subject.tree_fingerprint' "$assurance_file")" + while IFS=$'\t' read -r label artifact expected_sha linked_subject; do + [[ -n "$label" ]] || continue + artifact_path="$assurance_dir/$artifact" + if [[ ! -f "$artifact_path" || -L "$artifact_path" ]]; then + printf 'Error: gate assurance linked %s evidence is missing or unsafe: %s\n' \ + "$label" "$artifact_path" >&2 + return 1 + fi + actual_sha="$(_gate_result_sha256_file "$artifact_path")" || return $? + if [[ "$actual_sha" != "$expected_sha" ]]; then + printf 'Error: gate assurance linked %s evidence digest mismatch: %s\n' \ + "$label" "$artifact_path" >&2 + return 1 + fi + if [[ "$linked_subject" != "$subject_fingerprint" ]]; then + printf 'Error: gate assurance linked %s evidence subject mismatch: %s\n' \ + "$label" "$artifact_path" >&2 + return 1 + fi + if [[ "$label" == preflight ]]; then + if ! artifact_subject="$( + jq -er '.subject.fingerprint_before | + select(type == "string" and test("^[a-f0-9]{64}$"))' \ + "$artifact_path" 2>/dev/null + )" || ! artifact_outcome="$( + jq -er '.status | + select(. == "pass" or . == "fail" or . == "timeout" or + . == "stale" or . == "invalid")' "$artifact_path" 2>/dev/null + )"; then + printf 'Error: gate assurance linked preflight evidence claim is malformed: %s\n' \ + "$artifact_path" >&2 + return 1 + fi + linked_outcome="$(jq -r '.evidence.preflight.outcome' "$assurance_file")" + if [[ "$artifact_subject" != "$linked_subject" ]]; then + printf 'Error: gate assurance linked preflight evidence subject claim mismatch: %s\n' \ + "$artifact_path" >&2 + return 1 + fi + if [[ ( "$linked_outcome" == pass && "$artifact_outcome" != pass ) \ + || ( "$linked_outcome" == fail && "$artifact_outcome" == pass ) ]]; then + printf 'Error: gate assurance linked preflight evidence outcome mismatch: %s\n' \ + "$artifact_path" >&2 + return 1 + fi + fi + done < <( + jq -r ' + [ + (if .evidence.preflight.status == "linked" then + ["preflight",.evidence.preflight.artifact, + .evidence.preflight.sha256,.evidence.preflight.subject_fingerprint] + else empty end), + (if .evidence.scope_manifest.status == "verified" then + ["scope_manifest",.evidence.scope_manifest.artifact, + .evidence.scope_manifest.sha256, + .evidence.scope_manifest.subject_fingerprint] + else empty end), + (if .evidence.closure.status == "verified" then + ["closure",.evidence.closure.artifact, + .evidence.closure.sha256,.evidence.closure.subject_fingerprint] + else empty end) + ][] | @tsv + ' "$assurance_file" + ) +} + +# gate_policy_applicability_assess <assurance-file> +# <embedded|generic|maintainer|publish> +# <verified|unavailable|invalid> +# [authorization-reason] +gate_policy_applicability_assess() { + local assurance_file="$1" consumer="$2" authorization_status="$3" + local authorization_reason="${4:-dispatch_authorization_unavailable}" + local kind + kind="$(jq -r '.kind // empty' "$assurance_file" 2>/dev/null)" + if [[ "$kind" != gate_assurance_v3 ]]; then + jq -nc '{ + status:"unavailable", + reason_codes:["consumer_applicability_unavailable"] + }' + return 0 + fi + jq -nc --slurpfile assurance "$assurance_file" \ + --arg consumer "$consumer" \ + --arg authorization_status "$authorization_status" \ + --arg authorization_reason "$authorization_reason" ' + $assurance[0] as $a | + (if $consumer == "embedded" then $a.policy.consumer_policy + elif $consumer == "publish" then "maintainer" + else $consumer end) as $required_policy | + ([ + if $a.result.final != "GO" then "verdict_not_go" else empty end, + if ($a | has("policy") | not) then "policy_resolution_unavailable" else empty end, + if $a.policy.consumer_policy != $required_policy + then "consumer_policy_mismatch" else empty end, + if $a.policy.enforcement.status != "pass" + then "policy_enforcement_failed" else empty end, + if $a.coordinates.independence.evidence_status != "verified" + then "review_independence_unverified" else empty end, + if any($a.dispatch.outcomes[]; + .status != "passed" or .evidence_status != "verified" or .run_id == null) + then "review_dispatch_evidence_incomplete" else empty end, + if $authorization_status != "verified" + then $authorization_reason else empty end, + if $consumer == "publish" and $a.evidence.closure.status != "verified" + then "closure_evidence_unavailable" else empty end + ] | unique) as $reasons | + { + status:(if ($reasons | length) == 0 then "pass" else "fail" end), + reason_codes:$reasons, + consumer:$consumer, + required_policy:$required_policy, + embedded_policy:$a.policy.consumer_policy + }' +} + # The core JSON Schema owns portable envelope shape. This runtime predicate owns # cross-artifact and semantic claim consistency that JSON Schema cannot establish # from the Markdown result. The standalone fallback is an exact generated-style @@ -100,11 +473,91 @@ gate_assurance_verify() { type == "array" and all(.[]; type == "string" and length > 0) and (length == (unique | length)); def same_set($a; $b): ($a | sort) == ($b | sort); - only_keys(["kind","schema_version","result","bindings","coordinates", - "policy","dispatch","provenance"]) and + only_keys(["kind","schema_version","result","bindings","subject","evidence", + "coordinates","policy","dispatch","provenance"]) and (.result | only_keys(["final"])) and (.bindings | only_keys(["result_sha256","repo_root","repo_identity", "base_commit","head_commit","subject_fingerprint"])) and + (if .kind == "gate_assurance_v3" then + (.subject | + only_keys(["kind","schema_version","repository","observed","base","head", + "tree_fingerprint","subject_kind","dirty_policy","created_at", + "finished_at","observed_at_finish"])) and + .subject.kind == "gate_subject_v1" and .subject.schema_version == 1 and + (.subject.repository | + only_keys(["key","git_common_dir_identity","remote_identity"])) and + (.subject.repository.key | test("^[a-f0-9]{64}$")) and + (.subject.repository.git_common_dir_identity | test("^[a-f0-9]{64}$")) and + (.subject.repository.remote_identity == null or + (.subject.repository.remote_identity | test("^[a-f0-9]{64}$"))) and + (.subject.observed | only_keys(["root","git_common_dir"])) and + (.subject.observed.root | type == "string" and startswith("/")) and + (.subject.observed.git_common_dir | type == "string" and startswith("/")) and + (.subject.base | only_keys(["ref","commit"])) and + (.subject.base.ref | type == "string" and length > 0) and + (.subject.base.commit | test("^[a-f0-9]{40}$")) and + (.subject.head | only_keys(["ref","commit"])) and + (.subject.head.ref | type == "string" and length > 0) and + (.subject.head.commit | test("^[a-f0-9]{40}$")) and + (.subject.tree_fingerprint | test("^[a-f0-9]{64}$")) and + (.subject.subject_kind | IN("committed_head","working_tree","fixed_ref")) and + (.subject.dirty_policy | + IN("require_clean","include_working_tree","ignore_working_tree")) and + ((.subject.subject_kind == "committed_head" and + .subject.dirty_policy == "require_clean") or + (.subject.subject_kind == "working_tree" and + .subject.dirty_policy == "include_working_tree") or + (.subject.subject_kind == "fixed_ref" and + .subject.dirty_policy == "ignore_working_tree")) and + (.subject.created_at | + test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and + (.subject.finished_at | + test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and + (.subject.observed_at_finish | + only_keys(["repository_key","base_commit","head_commit", + "tree_fingerprint"])) and + (.subject.observed_at_finish.repository_key | test("^[a-f0-9]{64}$")) and + (.subject.observed_at_finish.base_commit | test("^[a-f0-9]{40}$")) and + (.subject.observed_at_finish.head_commit | test("^[a-f0-9]{40}$")) and + (.subject.observed_at_finish.tree_fingerprint | test("^[a-f0-9]{64}$")) and + (.evidence | only_keys(["preflight","scope_manifest","closure"])) and + (.evidence.preflight | + only_keys(["status","outcome","artifact","sha256", + "subject_fingerprint"])) and + (.evidence.preflight.status | IN("not_run","linked")) and + (if .evidence.preflight.status == "linked" then + (.evidence.preflight.outcome | IN("pass","fail")) and + (.evidence.preflight.artifact | + type == "string" and + test("^preflight-evidence-[0-9]{8}-[0-9]{6}\\.json$")) and + (.evidence.preflight.sha256 | test("^[a-f0-9]{64}$")) and + (.evidence.preflight.subject_fingerprint | test("^[a-f0-9]{64}$")) + else + .evidence.preflight.outcome == null and + .evidence.preflight.artifact == null and + .evidence.preflight.sha256 == null and + .evidence.preflight.subject_fingerprint == null + end) and + (all([.evidence.scope_manifest,.evidence.closure][]; + only_keys(["status","artifact","sha256","subject_fingerprint"]) and + (.status | IN("unavailable","verified")) and + (if .status == "verified" then + (.artifact | + type == "string" and length > 0 and (contains("/") | not)) and + (.sha256 | test("^[a-f0-9]{64}$")) and + (.subject_fingerprint | test("^[a-f0-9]{64}$")) + else + .artifact == null and .sha256 == null and .subject_fingerprint == null + end))) and + .bindings.repo_root == .subject.observed.root and + .bindings.repo_identity == .subject.repository.key and + .bindings.base_commit == .subject.base.commit and + .bindings.head_commit == .subject.head.commit and + .bindings.subject_fingerprint == .subject.tree_fingerprint and + .subject.created_at <= .subject.finished_at + else + (has("subject") | not) and (has("evidence") | not) + end) and (.coordinates | only_keys(["tier","mode","pass","coverage","independence"])) and (.coordinates.tier | only_keys(["requested","resolved","evidence_floor"])) and (.coordinates.mode | only_keys(["requested","resolved","topology","synthesis"])) and @@ -239,7 +692,8 @@ gate_assurance_verify() { (all(.dispatch.outcomes[]; only_keys(["role","reviewer","status","run_id","evidence_status"]))) and (.provenance | only_keys(["producer","policy_source","attestation"])) and - .kind == "gate_assurance_v2" and .schema_version == 2 and + ((.kind == "gate_assurance_v2" and .schema_version == 2) or + (.kind == "gate_assurance_v3" and .schema_version == 3)) and .result.final == $final and .bindings.result_sha256 == $result_sha and (.bindings.repo_root | type == "string" and startswith("/")) and @@ -346,6 +800,9 @@ gate_assurance_verify() { "$assurance_file" >&2 return 1 } + if [[ "$assurance_kind" == gate_assurance_v3 ]]; then + _gate_assurance_linked_evidence_verify "$assurance_file" || return $? + fi GATE_ASSURANCE_BOUND=true export GATE_ASSURANCE_BOUND } @@ -373,24 +830,39 @@ _gate_result_sha256_file() { # to the latest canonical terminal record for the same gate run and repository. gate_assurance_authorization_verify() { local result_file="$1" assurance_file="$2" attestation_file="$3" runs_file="$4" - local result_sha assurance_sha run_root + local result_sha assurance_sha subject_sha="" run_root assurance_kind [[ -s "$attestation_file" && -s "$runs_file" ]] || { printf 'Error: verified gate assurance requires protected attestation and canonical run records\n' >&2 return 1 } result_sha="$(_gate_result_sha256_file "$result_file")" || return $? assurance_sha="$(_gate_result_sha256_file "$assurance_file")" || return $? + assurance_kind="$(jq -r '.kind // empty' "$assurance_file" 2>/dev/null)" + if [[ "$assurance_kind" == gate_assurance_v3 ]]; then + subject_sha="$(jq -cS '.subject' "$assurance_file" \ + | _gate_result_sha256_stream)" || return $? + fi run_root="$(cd "$(dirname "$attestation_file")" && pwd -P)" || return 1 jq -e --arg result_sha "$result_sha" --arg assurance_sha "$assurance_sha" \ + --arg subject_sha "$subject_sha" \ --slurpfile assurance "$assurance_file" ' $assurance[0] as $a | - .kind == "gate_assurance_attestation_v1" and .schema_version == 1 and + ((($a.kind == "gate_assurance_v2") and + .kind == "gate_assurance_attestation_v1" and .schema_version == 1) or + (($a.kind == "gate_assurance_v3") and + .kind == "gate_assurance_attestation_v2" and .schema_version == 2)) and .result_sha256 == $result_sha and .assurance_sha256 == $assurance_sha and .repo_root == $a.bindings.repo_root and .repo_identity == $a.bindings.repo_identity and .base_commit == $a.bindings.base_commit and .head_commit == $a.bindings.head_commit and .subject_fingerprint == $a.bindings.subject_fingerprint and + (if $a.kind == "gate_assurance_v3" then + .repository_key == $a.subject.repository.key and + .subject_sha256 == $subject_sha + else + has("repository_key") == false and has("subject_sha256") == false + end) and ([.run_ids[]] | sort) == ([$a.dispatch.outcomes[].run_id] | sort) ' "$attestation_file" >/dev/null || { diff --git a/runtime/lib/pmctl-gate.sh b/runtime/lib/pmctl-gate.sh index 8820d963..6f9bda85 100644 --- a/runtime/lib/pmctl-gate.sh +++ b/runtime/lib/pmctl-gate.sh @@ -110,7 +110,9 @@ _pmctl_gate_wait_for_assurance_publication() { evidence_status="$(jq -r \ '.coordinates.independence.evidence_status // "unavailable"' \ "$assurance_file" 2>/dev/null || printf 'unavailable')" - if [[ "$assurance_kind" != gate_assurance_v2 || "$evidence_status" != verified ]]; then + if [[ "$assurance_kind" != gate_assurance_v2 \ + && "$assurance_kind" != gate_assurance_v3 ]] \ + || [[ "$evidence_status" != verified ]]; then return 0 fi attestation_pointer="$(jq -r '.provenance.attestation // empty' \ @@ -688,12 +690,34 @@ pmctl_gate_wait() { return 2 fi fi - if ! _pmctl_gate_result_verifier_load "$repo_root"; then - printf 'pmctl gate wait: FAIL: gate_result_verify unavailable -- cannot confirm result integrity for %s, treating as failed wait\n' "$_result" >&2 + local _assessment _assessment_rc=0 _artifact_axis _subject_axis + local _policy_axis _assurance_kind + _assessment="$( + pmctl_gate_verify "$repo_root" "$_result" --cd "$work_dir" \ + --consumer embedded --json 2>/dev/null + )" || _assessment_rc=$? + if ! jq -e '.kind == "gate_verification_v1"' \ + <<<"$_assessment" >/dev/null 2>&1; then + printf 'pmctl gate wait: FAIL: shared gate assessment could not evaluate %s -- treating as failed wait\n' "$_result" >&2 return 2 fi - if ! gate_result_verify "$_result" >/dev/null 2>&1; then - printf 'pmctl gate wait: FAIL: gate_result_verify rejected %s -- result is missing/corrupt/unparsable, treating as failed wait\n' "$_result" >&2 + _artifact_axis="$(jq -r '.axes.artifact_valid.status' <<<"$_assessment")" + _subject_axis="$(jq -r '.axes.subject_current.status' <<<"$_assessment")" + _policy_axis="$(jq -r '.axes.policy_applicable.status' <<<"$_assessment")" + _assurance_kind="$(jq -r '.assurance.kind // "legacy"' <<<"$_assessment")" + if [[ "$_artifact_axis" != pass \ + || ( "$_assurance_kind" == gate_assurance_v3 \ + && "$_subject_axis" != pass ) ]]; then + printf 'pmctl gate wait: FAIL: gate artifact is invalid or stale for --cd %s (%s) -- treating as failed wait\n' \ + "$work_dir" "$(jq -c '.axes' <<<"$_assessment")" >&2 + return 2 + fi + if [[ "$_state" == GO \ + && ( "$_assurance_kind" == gate_assurance_v2 \ + || "$_assurance_kind" == gate_assurance_v3 ) \ + && ( "$_assessment_rc" -ne 0 || "$_policy_axis" != pass ) ]]; then + printf 'pmctl gate wait: FAIL: GO artifact is not applicable to its embedded consumer policy (%s) -- treating as failed wait\n' \ + "$(jq -c '.axes.policy_applicable' <<<"$_assessment")" >&2 return 2 fi # Verdict summary: echo the result file's `Final:` line verbatim — @@ -756,24 +780,79 @@ pmctl_gate_wait() { return 124 } -# pmctl gate verify <result_file> -# Confirm a gate result file is structurally complete using the SAME contract -# the synchronous gate route enforces in-process (gate_result_verify). This is -# how an out-of-process gate result -- one written outside pr-gate.sh's own -# in-process check -- becomes confirmable/trackable via pmctl, symmetric to the -# codex route's built-in post-dispatch check. Exit 0 = valid; 1 = invalid (diagnostic on stderr); -# 2 = usage/library error. +# pmctl gate verify <result_file> [--cd <repo>] +# [--consumer embedded|generic|maintainer|publish] [--json] +# The default inspection mode preserves the historical "is this artifact +# structurally valid?" exit contract while also reporting subject and policy +# axes. A named consumer is authorizing: all three axes must pass. pmctl_gate_verify() { local repo_root="$1"; shift - - if [[ $# -ne 1 || -z "${1:-}" ]]; then - printf 'pmctl gate verify: usage: pmctl gate verify <result_file>\n' >&2 + local result_file="" work_dir="" consumer=inspect json_output=false + while [[ $# -gt 0 ]]; do + case "$1" in + --cd) + [[ $# -ge 2 && -n "$2" ]] || { + printf 'pmctl gate verify: --cd requires a repository path\n' >&2 + return 2 + } + work_dir="$2" + shift 2 + ;; + --consumer) + [[ $# -ge 2 && "$2" =~ ^(embedded|generic|maintainer|publish)$ ]] || { + printf 'pmctl gate verify: --consumer requires embedded, generic, maintainer, or publish\n' >&2 + return 2 + } + consumer="$2" + shift 2 + ;; + --json) + json_output=true + shift + ;; + --*) + printf 'pmctl gate verify: unknown option %s\n' "$1" >&2 + return 2 + ;; + *) + if [[ -n "$result_file" ]]; then + printf 'pmctl gate verify: unexpected argument %s\n' "$1" >&2 + return 2 + fi + result_file="$1" + shift + ;; + esac + done + if [[ -z "$result_file" ]]; then + printf 'pmctl gate verify: usage: pmctl gate verify <result_file> [--cd <repo>] [--consumer embedded|generic|maintainer|publish] [--json]\n' >&2 return 2 fi - local result_file="$1" - local evidence_status attestation_pointer result_parent run_root run_id + local result_parent result_name result_abs + result_parent="$(cd "$(dirname "$result_file")" 2>/dev/null && pwd -P)" || { + printf 'pmctl gate verify: result parent directory is unavailable: %s\n' \ + "$(dirname "$result_file")" >&2 + return 1 + } + result_name="$(basename "$result_file")" + result_abs="$result_parent/$result_name" + if [[ -n "$work_dir" ]]; then + work_dir="$(cd "$work_dir" 2>/dev/null && pwd -P)" || { + printf 'pmctl gate verify: --cd repository is unavailable: %s\n' "$work_dir" >&2 + return 2 + } + elif work_dir="$(git rev-parse --show-toplevel 2>/dev/null)" && [[ -n "$work_dir" ]]; then + work_dir="$(cd "$work_dir" && pwd -P)" || return 2 + else + work_dir="$PWD" + fi + + local evidence_status attestation_pointer run_root run_id assurance_kind local attestation_file runs_file canonical_repo_root assurance_repo_root - local canonical_run_root + local canonical_run_root authorization_status=unavailable + local authorization_reason=dispatch_authorization_unavailable + local artifact_axis subject_axis policy_axis report verdict=unknown + local assurance_status=unavailable assurance_file="" verify_diag verify_rc=0 if ! _pmctl_gate_result_verifier_load "$repo_root"; then printf 'pmctl gate verify: required verifier library is missing or incomplete: %s\n' \ @@ -781,62 +860,164 @@ pmctl_gate_verify() { return 2 fi - _pmctl_gate_wait_for_assurance_publication "$result_file" - if gate_result_verify "$result_file"; then + _pmctl_gate_wait_for_assurance_publication "$result_abs" + verify_diag="$(mktemp "${TMPDIR:-/tmp}/pm-gate-verify.XXXXXX")" || return 2 + gate_result_verify "$result_abs" 2>"$verify_diag" || verify_rc=$? + verdict="$(grep -m1 -E '^Final: (GO|NO-GO)$' "$result_abs" 2>/dev/null \ + | awk '{print $2}' || true)" + [[ "$verdict" == GO || "$verdict" == NO-GO ]] || verdict=unknown + if [[ "$verify_rc" -ne 0 ]]; then + artifact_axis='{"status":"fail","reason_codes":["artifact_integrity_failed"]}' + subject_axis='{"status":"unavailable","reason_codes":["artifact_invalid"]}' + policy_axis='{"status":"unavailable","reason_codes":["artifact_invalid"]}' + assurance_status=invalid + if [[ -s "$verify_diag" ]]; then + cat "$verify_diag" >&2 + fi + else + artifact_axis='{"status":"pass","reason_codes":[]}' + assurance_status="${GATE_RESULT_ASSURANCE:-unavailable}" + assurance_file="${GATE_RESULT_ASSURANCE_FILE:-}" + assurance_kind="$(jq -r '.kind // empty' "${assurance_file:-/dev/null}" \ + 2>/dev/null || true)" evidence_status="$(jq -r '.coordinates.independence.evidence_status // "unavailable"' \ - "${GATE_RESULT_ASSURANCE_FILE:-/dev/null}" 2>/dev/null || printf 'unavailable')" - if [[ "${GATE_RESULT_ASSURANCE:-unavailable}" == verified \ - && "$evidence_status" == verified ]]; then + "${assurance_file:-/dev/null}" 2>/dev/null || printf 'unavailable')" + + if [[ "$assurance_status" == verified && "$evidence_status" == verified ]]; then attestation_pointer="$(jq -r '.provenance.attestation // empty' \ - "$GATE_RESULT_ASSURANCE_FILE" 2>/dev/null)" - if [[ -z "$attestation_pointer" || "$attestation_pointer" == */* \ - || ! "$attestation_pointer" =~ ^gate-assurance-[0-9]{8}-[0-9]{6}\.attestation\.json$ ]]; then - printf 'Error: verified gate assurance requires a bounded protected attestation pointer\n' >&2 - return 1 - fi - result_parent="$(cd "$(dirname "$result_file")" && pwd -P)" || return 1 - if [[ "$(basename "$result_parent")" != ".gate-results" ]]; then - printf 'Error: verified gate assurance result is outside a canonical gate run directory\n' >&2 - return 1 - fi + "$assurance_file" 2>/dev/null)" run_root="$(dirname "$result_parent")" run_id="$(basename "$run_root")" - canonical_repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - printf 'Error: verified gate assurance requires invocation from its repository\n' >&2 - return 1 - } - canonical_repo_root="$(cd "$canonical_repo_root" && pwd -P)" || return 1 + canonical_repo_root="$(git -C "$work_dir" rev-parse --show-toplevel 2>/dev/null \ + || true)" + [[ -n "$canonical_repo_root" ]] \ + && canonical_repo_root="$(cd "$canonical_repo_root" && pwd -P)" assurance_repo_root="$(jq -r '.bindings.repo_root // empty' \ - "$GATE_RESULT_ASSURANCE_FILE" 2>/dev/null)" - if [[ -z "$assurance_repo_root" || ! -d "$assurance_repo_root" \ - || "$(cd "$assurance_repo_root" && pwd -P)" != "$canonical_repo_root" ]]; then - printf 'Error: verified gate assurance repository binding does not match the invoking repository\n' >&2 - return 1 - fi - if ! _pmctl_gate_ensure_run_dir_fn "$repo_root"; then + "$assurance_file" 2>/dev/null)" + if [[ -z "$attestation_pointer" || "$attestation_pointer" == */* \ + || ! "$attestation_pointer" =~ ^gate-assurance-[0-9]{8}-[0-9]{6}\.attestation\.json$ ]]; then + authorization_status=invalid + authorization_reason=protected_attestation_pointer_invalid + elif [[ "$(basename "$result_parent")" != ".gate-results" \ + || -z "$canonical_repo_root" ]]; then + authorization_reason=canonical_dispatch_evidence_unavailable + elif ! _pmctl_gate_ensure_run_dir_fn "$repo_root"; then + rm -f -- "$verify_diag" printf 'Error: verified gate assurance requires canonical state-path resolution\n' >&2 return 2 + else + canonical_run_root="$( + cd "$canonical_repo_root" || exit 1 + _SW_REPO_ROOT="$canonical_repo_root" sw_project_run_dir "$run_id" + )" || canonical_run_root="" + if [[ -z "$canonical_run_root" || ! -d "$canonical_run_root" \ + || "$(cd "$canonical_run_root" && pwd -P)" != "$run_root" ]]; then + authorization_reason=canonical_state_partition_mismatch + if [[ "$assurance_kind" == gate_assurance_v2 ]]; then + authorization_status=invalid + fi + elif [[ "$assurance_kind" == gate_assurance_v2 \ + && ( -z "$assurance_repo_root" || ! -d "$assurance_repo_root" \ + || "$(cd "$assurance_repo_root" && pwd -P)" != "$canonical_repo_root" ) ]]; then + authorization_status=invalid + authorization_reason=repository_binding_mismatch + else + attestation_file="$run_root/$attestation_pointer" + runs_file="$(dirname "$(dirname "$canonical_run_root")")/runs.jsonl" + if gate_assurance_authorization_verify "$result_abs" "$assurance_file" \ + "$attestation_file" "$runs_file"; then + authorization_status=verified + authorization_reason="" + else + authorization_status=invalid + authorization_reason=protected_attestation_invalid + fi + fi fi - canonical_run_root="$( - cd "$canonical_repo_root" || exit 1 - _SW_REPO_ROOT="$canonical_repo_root" sw_project_run_dir "$run_id" - )" || return 1 - if [[ ! -d "$canonical_run_root" \ - || "$(cd "$canonical_run_root" && pwd -P)" != "$run_root" ]]; then - printf 'Error: verified gate assurance result is outside the invoking repository canonical state partition\n' >&2 - return 1 + fi + + if [[ "$authorization_status" == invalid ]]; then + artifact_axis="$(jq -nc --arg reason "$authorization_reason" \ + '{status:"fail",reason_codes:[$reason]}')" + assurance_status=invalid + if [[ "$assurance_kind" == gate_assurance_v2 ]]; then + case "$authorization_reason" in + canonical_state_partition_mismatch) + printf 'Error: verified gate assurance result is outside the invoking repository canonical state partition\n' >&2 + ;; + repository_binding_mismatch) + printf 'Error: verified gate assurance repository binding does not match the invoking repository\n' >&2 + ;; + esac fi - attestation_file="$run_root/$attestation_pointer" - runs_file="$(dirname "$(dirname "$canonical_run_root")")/runs.jsonl" - gate_assurance_authorization_verify "$result_file" "$GATE_RESULT_ASSURANCE_FILE" \ - "$attestation_file" "$runs_file" || return $? + [[ -s "$verify_diag" ]] && cat "$verify_diag" >&2 + fi + if [[ -n "$assurance_file" ]]; then + subject_axis="$(gate_subject_assess "$assurance_file" "$work_dir")" + policy_axis="$( + gate_policy_applicability_assess "$assurance_file" \ + "$(if [[ "$consumer" == inspect ]]; then printf embedded; else printf '%s' "$consumer"; fi)" \ + "$authorization_status" "${authorization_reason:-dispatch_authorization_unavailable}" + )" + else + subject_axis='{"status":"unavailable","reason_codes":["immutable_subject_unavailable"]}' + policy_axis='{"status":"unavailable","reason_codes":["consumer_applicability_unavailable"]}' fi - printf 'gate result OK: %s\n' "$result_file" - printf 'assurance: %s\n' "${GATE_RESULT_ASSURANCE:-unavailable}" - if [[ -n "${GATE_RESULT_ASSURANCE_FILE:-}" ]]; then - printf 'assurance file: %s\n' "$GATE_RESULT_ASSURANCE_FILE" + fi + rm -f -- "$verify_diag" + + report="$(jq -nc \ + --arg result_file "$result_abs" --arg verdict "$verdict" \ + --arg assurance_status "$assurance_status" \ + --arg assurance_kind "${assurance_kind:-}" \ + --arg assurance_file "$assurance_file" \ + --arg consumer "$consumer" \ + --argjson artifact "$artifact_axis" \ + --argjson subject "$subject_axis" \ + --argjson policy "$policy_axis" '{ + kind:"gate_verification_v1", + schema_version:1, + result_file:$result_file, + verdict:$verdict, + assurance:{ + status:$assurance_status, + kind:(if $assurance_kind == "" then null else $assurance_kind end), + file:(if $assurance_file == "" then null else $assurance_file end) + }, + consumer:$consumer, + axes:{ + artifact_valid:$artifact, + subject_current:$subject, + policy_applicable:$policy + } + }')" || return 2 + + if [[ "$json_output" == true ]]; then + printf '%s\n' "$report" + else + if [[ "$(jq -r '.axes.artifact_valid.status' <<<"$report")" == pass ]]; then + printf 'gate result OK: %s\n' "$result_abs" fi - return 0 + printf 'assurance: %s\n' "$(jq -r '.assurance.status' <<<"$report")" + [[ -n "$assurance_file" ]] && printf 'assurance file: %s\n' "$assurance_file" + printf 'artifact_valid: %s\n' \ + "$(jq -r '.axes.artifact_valid.status' <<<"$report")" + printf 'subject_current: %s\n' \ + "$(jq -r '.axes.subject_current.status' <<<"$report")" + printf 'policy_applicable: %s (consumer=%s)\n' \ + "$(jq -r '.axes.policy_applicable.status' <<<"$report")" "$consumer" + jq -r '.axes | to_entries[] | + select(.value.reason_codes | length > 0) | + "\(.key) reasons: \(.value.reason_codes | join(","))"' <<<"$report" + fi + + if [[ "$consumer" == inspect ]]; then + [[ "$(jq -r '.axes.artifact_valid.status' <<<"$report")" == pass ]] + else + jq -e ' + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "pass" and + .axes.policy_applicable.status == "pass" + ' <<<"$report" >/dev/null fi - return 1 } diff --git a/runtime/lib/pmctl-ship.sh b/runtime/lib/pmctl-ship.sh index 0cc5ad25..c9d7cb7f 100644 --- a/runtime/lib/pmctl-ship.sh +++ b/runtime/lib/pmctl-ship.sh @@ -195,21 +195,39 @@ pmctl_ship_finish() { gate_out="$(pmctl_gate_run "$repo_root" "${gate_args[@]}" 2>&1)" || gate_status=$? printf '%s\n' "$gate_out" - # Source of truth is the RESULT FILE's `Final:` line, not the captured - # exit code or any stdout text -- pr-gate.sh prints `result: <path>` near - # the end of its run; read that file directly rather than trust output - # that could be truncated/reordered by capture. - local result_path final_verdict + # Source of truth is the shared three-axis gate assessment, not the captured + # exit code, stdout prose, or a local grep of `Final:`. pr-gate.sh prints the + # result path; the verifier binds it to the current repository subject and + # the maintainer consumer policy before publication can continue. + local result_path final_verdict gate_verification gate_verification_status=0 result_path="$(printf '%s\n' "$gate_out" | grep -m1 '^result: ' | sed 's/^result: *//')" if [[ -z "$result_path" || ! -f "$result_path" ]]; then printf 'pmctl ship finish: could not locate gate result file (gate exit %s) -- see output above\n' "$gate_status" >&2 return 1 fi - final_verdict="$(grep -m1 '^Final: ' "$result_path" 2>/dev/null | awk '{print $2}')" + if ! declare -F pmctl_gate_verify >/dev/null 2>&1; then + printf 'pmctl ship finish: shared gate verifier is unavailable; refusing publication\n' >&2 + return 2 + fi + gate_verification="$( + pmctl_gate_verify "$repo_root" "$result_path" --cd "$work_dir" \ + --consumer maintainer --json + )" || gate_verification_status=$? + if ! jq -e '.kind == "gate_verification_v1"' \ + <<<"$gate_verification" >/dev/null 2>&1; then + printf 'pmctl ship finish: shared gate verifier returned no structured assessment; refusing publication\n' >&2 + return 1 + fi + final_verdict="$(jq -r '.verdict' <<<"$gate_verification")" if [[ "$final_verdict" != "GO" ]]; then printf 'pmctl ship finish: %s -- fix findings and re-run finish. Result: %s\n' "${final_verdict:-NO VERDICT}" "$result_path" >&2 return 1 fi + if [[ "$gate_verification_status" -ne 0 ]]; then + printf 'pmctl ship finish: GO artifact is invalid, stale, or not applicable to maintainer policy; refusing publication: %s\n' \ + "$(jq -c '.axes' <<<"$gate_verification")" >&2 + return 1 + fi # Committed-diff guard: GO only proves the gate reviewed SOME state of # work_dir -- prove that state is exactly what is about to be pushed diff --git a/skills/pr-gate-review/SKILL.md b/skills/pr-gate-review/SKILL.md index 03c6815d..ec772b21 100644 --- a/skills/pr-gate-review/SKILL.md +++ b/skills/pr-gate-review/SKILL.md @@ -39,20 +39,20 @@ implement → pr-gate → fix NO-GO → push → PR). The result file carries `pr_gate_result_v2` frontmatter with `final: GO|NO-GO`, per-reviewer verdicts, and a bounded pointer to its sibling -`gate_assurance_v2` JSON envelope. The `Final: GO|NO-GO` line is the -parser-significant one (plain text, exact shape). Run -`pmctl gate verify <result-file>` from the reviewed repository before consuming -assurance claims; legacy -`pr_gate_result_v1` files and unbound v1 envelopes verify only as -`assurance: unavailable`. A standalone -copy-mode v2 result may also carry `evidence_status: unavailable` inside its -valid envelope; treat its verdict as structurally valid without inferring -implementation isolation or independent reviewer sessions. Repo-layout -independence is authoritative only when verification also confirms the -protected producer attestation, the invoking repository's canonical state -partition, and every claimed canonical run record. Current v2 envelopes also -record the shell-owned policy classification, matched signals, floors, resolved -coordinates, and any scope-bound user override. +`gate_assurance_v3` JSON envelope. The `Final: GO|NO-GO` line is +parser-significant (plain text, exact shape), but is not freshness or +authorization evidence. Run +`pmctl gate verify <result-file> --cd <reviewed-repo> --consumer embedded --json` +before consuming assurance claims. Named-consumer success requires all three +axes to pass: `artifact_valid`, `subject_current`, and `policy_applicable`. +Legacy `pr_gate_result_v1` and v1/v2 assurance artifacts remain readable for +historical inspection but cannot prove immutable-subject freshness or consumer +applicability. Repo-layout authorization is authoritative only when +verification also confirms the protected producer attestation, the invoking +repository's canonical state partition, and every claimed canonical run +record. Current v3 envelopes also record the shell-owned policy classification, +matched signals, resolved coordinates, linked evidence, and any scope-bound +user override. - **NO-GO** (a reviewer returned `block`): fix the blocking finding. Per project convention, clear **every** finding (high/med/low/advise) on a NO-GO, not just diff --git a/tests/bin/run-tests.sh b/tests/bin/run-tests.sh index abb221b3..8b4f1d1d 100755 --- a/tests/bin/run-tests.sh +++ b/tests/bin/run-tests.sh @@ -217,7 +217,7 @@ map_path() { add_suite test-pmctl-task; behavioral=1 ;; core/schema/preflight-evidence.schema.json) add_suite test-pr-gate; behavioral=1 ;; - core/schema/gate-assurance.schema.json|core/schema/gate-policy-override.schema.json) + core/schema/gate-assurance.schema.json|core/schema/gate-policy-override.schema.json|core/schema/gate-verification.schema.json) add_suite test-core-schemas; add_suite test-pr-gate add_suite test-pmctl-gate; behavioral=1 ;; runtime/lib/gate-result-verify.sh) diff --git a/tests/shell/test-core-schemas.sh b/tests/shell/test-core-schemas.sh index 092d7c2a..3c5248b3 100755 --- a/tests/shell/test-core-schemas.sh +++ b/tests/shell/test-core-schemas.sh @@ -854,6 +854,93 @@ _gate_assurance_valid_instance() { }' } +_gate_assurance_v3_valid_instance() { + _gate_assurance_valid_instance | + jq ' + .kind = "gate_assurance_v3" | + .schema_version = 3 | + .subject = { + kind:"gate_subject_v1", + schema_version:1, + repository:{ + key:("b" * 64), + git_common_dir_identity:("1" * 64), + remote_identity:null + }, + observed:{root:"/tmp/repo",git_common_dir:"/tmp/repo/.git"}, + base:{ref:"main",commit:("c" * 40)}, + head:{ref:"HEAD",commit:("d" * 40)}, + tree_fingerprint:("e" * 64), + subject_kind:"committed_head", + dirty_policy:"require_clean", + created_at:"2026-07-28T00:00:00Z", + finished_at:"2026-07-28T00:01:00Z", + observed_at_finish:{ + repository_key:("b" * 64), + base_commit:("c" * 40), + head_commit:("d" * 40), + tree_fingerprint:("e" * 64) + } + } | + .evidence = { + preflight:{ + status:"linked", + outcome:"pass", + artifact:"preflight-evidence-20260728-000000.json", + sha256:("2" * 64), + subject_fingerprint:("e" * 64) + }, + scope_manifest:{ + status:"unavailable", + artifact:null, + sha256:null, + subject_fingerprint:null + }, + closure:{ + status:"unavailable", + artifact:null, + sha256:null, + subject_fingerprint:null + } + } + ' +} + +_gate_verification_valid_instance() { + jq -n '{ + kind:"gate_verification_v1", + schema_version:1, + result_file:"/tmp/result.md", + verdict:"GO", + assurance:{ + status:"verified", + kind:"gate_assurance_v3", + file:"/tmp/result.md.assurance.json" + }, + consumer:"maintainer", + axes:{ + artifact_valid:{status:"pass",reason_codes:[]}, + subject_current:{ + status:"pass", + reason_codes:[], + current:{ + repository_key:("a" * 64), + base_commit:("b" * 40), + head_commit:("c" * 40), + tree_fingerprint:("d" * 64), + observed_root:"/tmp/repo" + } + }, + policy_applicable:{ + status:"pass", + reason_codes:[], + consumer:"maintainer", + embedded_policy:"maintainer" + } + } + }' +} + case_gate_assurance_valid_instance() { local name="gate-assurance: canonical sequential envelope validates" should_run "$name" || return 0 @@ -868,6 +955,117 @@ case_gate_assurance_valid_instance() { rm -f "$tmpf" } +case_gate_assurance_v3_valid_instance() { + local name="gate-assurance: v3 subject and evidence envelope validates" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-assurance.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-assurance-v3-valid-XXXXXX.json)" + _gate_assurance_v3_valid_instance > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + pass "$name" + else + fail "$name" "schema rejected a canonical v3 assurance envelope" + fi + rm -f "$tmpf" +} + +case_gate_assurance_v2_rejects_v3_fields() { + local name="gate-assurance: v2 cannot carry unversioned v3 subject fields" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-assurance.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-assurance-v2-v3-fields-XXXXXX.json)" + _gate_assurance_v3_valid_instance | + jq '.kind = "gate_assurance_v2" | .schema_version = 2' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + fail "$name" "schema accepted v3 subject/evidence fields under v2" + else + pass "$name" + fi + rm -f "$tmpf" +} + +case_gate_assurance_v3_invalid_dirty_pair_rejected() { + local name="gate-assurance: subject kind and dirty policy must agree" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-assurance.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-assurance-v3-dirty-pair-XXXXXX.json)" + _gate_assurance_v3_valid_instance | + jq '.subject.dirty_policy = "ignore_working_tree"' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + fail "$name" "schema accepted committed_head with ignore_working_tree" + else + pass "$name" + fi + rm -f "$tmpf" +} + +case_gate_assurance_v3_evidence_path_rejected() { + local name="gate-assurance: linked evidence artifact must be a basename" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-assurance.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-assurance-v3-evidence-path-XXXXXX.json)" + _gate_assurance_v3_valid_instance | + jq ' + .evidence.scope_manifest = { + status:"verified", + artifact:"../scope.json", + sha256:("3" * 64), + subject_fingerprint:("e" * 64) + } + ' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + fail "$name" "schema accepted escaping linked-evidence path" + else + pass "$name" + fi + rm -f "$tmpf" +} + +case_gate_verification_valid_instance() { + local name="gate-verification: canonical three-axis report validates" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-verification.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-verification-valid-XXXXXX.json)" + _gate_verification_valid_instance > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + pass "$name" + else + fail "$name" "schema rejected a canonical three-axis report" + fi + rm -f "$tmpf" +} + +case_gate_verification_duplicate_reason_rejected() { + local name="gate-verification: duplicate reason codes are rejected" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-verification.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-verification-duplicate-reason-XXXXXX.json)" + _gate_verification_valid_instance | + jq '.axes.subject_current.reason_codes = ["tree_drift","tree_drift"]' \ + > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + fail "$name" "schema accepted duplicate reason codes" + else + pass "$name" + fi + rm -f "$tmpf" +} + +case_gate_verification_invalid_consumer_rejected() { + local name="gate-verification: unknown policy consumer is rejected" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-verification.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-verification-consumer-XXXXXX.json)" + _gate_verification_valid_instance | + jq '.axes.policy_applicable.consumer = "deployment"' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + fail "$name" "schema accepted a policy consumer outside the contract" + else + pass "$name" + fi + rm -f "$tmpf" +} + case_gate_assurance_invalid_outcome_rejected() { local name="gate-assurance: unknown dispatch status is rejected" should_run "$name" || return 0 @@ -985,6 +1183,13 @@ case_context_pack_invalid_trust_level_rejected case_preflight_basic_evidence_needs_no_git_provenance case_preflight_reusable_evidence_requires_fingerprint case_gate_assurance_valid_instance +case_gate_assurance_v3_valid_instance +case_gate_assurance_v2_rejects_v3_fields +case_gate_assurance_v3_invalid_dirty_pair_rejected +case_gate_assurance_v3_evidence_path_rejected +case_gate_verification_valid_instance +case_gate_verification_duplicate_reason_rejected +case_gate_verification_invalid_consumer_rejected case_gate_assurance_invalid_outcome_rejected case_gate_assurance_non_user_policy_approver_rejected case_gate_policy_override_valid_instance diff --git a/tests/shell/test-gate-lifecycle.sh b/tests/shell/test-gate-lifecycle.sh index 06274705..4c355668 100755 --- a/tests/shell/test-gate-lifecycle.sh +++ b/tests/shell/test-gate-lifecycle.sh @@ -718,8 +718,9 @@ case_wait_fails_on_missing_result() { # ---- 10: GO sentinel with a structurally invalid result fails the wait ------- case_wait_fails_on_corrupt_result() { # CC-423 pr-gate finding (qa-tester, high): distinct from the missing-result - # case above -- here a result file exists but gate_result_verify rejects it - # (no Final: line), and the wait must still fail rather than trust it. + # case above -- here a result file exists but the shared assessment reports + # gate_result_verify's integrity rejection (no Final: line), and the wait + # must still fail rather than trust it. local name="gate-lifecycle/gate wait fails when result fails gate_result_verify" should_run "$name" || return 0 @@ -738,7 +739,9 @@ case_wait_fails_on_corrupt_result() { local out code set +e; out="$("$wait_wrapper" "$gate_id" --cd "$work" --timeout "$_WAIT_OK" 2>&1)"; code=$?; set -e - if [[ "$code" -eq 2 ]] && [[ "$out" == *"gate_result_verify rejected"* ]]; then + if [[ "$code" -eq 2 ]] \ + && [[ "$out" == *"gate artifact is invalid or stale"* ]] \ + && [[ "$out" == *'"artifact_integrity_failed"'* ]]; then pass "$name" else fail "$name" "code=$code out=$out" diff --git a/tests/shell/test-pmctl-gate.sh b/tests/shell/test-pmctl-gate.sh index 70d10a6f..6a7b3b39 100755 --- a/tests/shell/test-pmctl-gate.sh +++ b/tests/shell/test-pmctl-gate.sh @@ -15,6 +15,8 @@ PMCTL="$REPO_ROOT/cli/pmctl" . "$REPO_ROOT/runtime/lib/pmctl-operation.sh" # shellcheck source=runtime/lib/pmctl-dispatch.sh . "$REPO_ROOT/runtime/lib/pmctl-dispatch.sh" +# shellcheck source=runtime/lib/gate-result-verify.sh +. "$REPO_ROOT/runtime/lib/gate-result-verify.sh" th_init "$@" # Isolate the detached-gate sentinel key dir for this suite's cli/pmctl fixture @@ -26,6 +28,12 @@ _GATE_VERIFY_REPO="$tmp_root/gate-verify-repo" _GATE_VERIFY_STATE_ROOT="$tmp_root/gate-verify-state" mkdir -p "$_GATE_VERIFY_REPO" "$_GATE_VERIFY_STATE_ROOT" git -C "$_GATE_VERIFY_REPO" init -q +git -C "$_GATE_VERIFY_REPO" config user.email test@example.com +git -C "$_GATE_VERIFY_REPO" config user.name "Gate Verify Test" +printf 'fixture\n' > "$_GATE_VERIFY_REPO/input.txt" +git -C "$_GATE_VERIFY_REPO" add input.txt +git -C "$_GATE_VERIFY_REPO" commit -qm fixture +git -C "$_GATE_VERIFY_REPO" branch main # shellcheck source=runtime/lib/state-paths.sh . "$REPO_ROOT/runtime/lib/state-paths.sh" @@ -551,6 +559,89 @@ _mk_gate_result_v2_verified() { ' > "$attestation" } +_mk_gate_result_v3_verified() { + local path="$1" bound_repo="${2:-$_GATE_VERIFY_REPO}" + local base_ref="${3:-main}" sidecar="${1}.assurance.json" + local result_parent run_root + local created initial subject + _mk_gate_result_v2_verified "$path" "$bound_repo" + result_parent="$(dirname "$path")" + run_root="$(dirname "$result_parent")" + created="2026-07-27T00:00:00Z" + initial="$( + gate_subject_snapshot "$bound_repo" "$base_ref" HEAD committed_head \ + require_clean "$created" + )" + subject="$(jq -nc --argjson initial "$initial" '{ + kind:"gate_subject_v1", + schema_version:1, + repository:$initial.repository, + observed:$initial.observed, + base:$initial.base, + head:$initial.head, + tree_fingerprint:$initial.tree_fingerprint, + subject_kind:$initial.subject_kind, + dirty_policy:$initial.dirty_policy, + created_at:$initial.captured_at, + finished_at:$initial.captured_at, + observed_at_finish:{ + repository_key:$initial.repository.key, + base_commit:$initial.base.commit, + head_commit:$initial.head.commit, + tree_fingerprint:$initial.tree_fingerprint + } + }')" + jq --argjson subject "$subject" ' + .kind = "gate_assurance_v3" | + .schema_version = 3 | + .bindings.repo_identity = $subject.repository.key | + .bindings.base_commit = $subject.base.commit | + .bindings.head_commit = $subject.head.commit | + .bindings.subject_fingerprint = $subject.tree_fingerprint | + .subject = $subject | + .evidence = { + preflight:{ + status:"not_run",outcome:null,artifact:null,sha256:null, + subject_fingerprint:null + }, + scope_manifest:{ + status:"unavailable",artifact:null,sha256:null,subject_fingerprint:null + }, + closure:{ + status:"unavailable",artifact:null,sha256:null,subject_fingerprint:null + } + } + ' "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" + _refresh_gate_result_v3_attestation "$path" +} + +_refresh_gate_result_v3_attestation() { + local path="$1" sidecar="${1}.assurance.json" + local run_root attestation assurance_sha subject_sha + run_root="$(dirname "$(dirname "$path")")" + attestation="$run_root/gate-assurance-20260727-000000.attestation.json" + assurance_sha="$(sha256sum "$sidecar" | awk '{print $1}')" + subject_sha="$(jq -cS '.subject' "$sidecar" | sha256sum | awk '{print $1}')" + jq -n --arg assurance_sha "$assurance_sha" --arg subject_sha "$subject_sha" \ + --slurpfile a "$sidecar" ' + $a[0] as $sidecar | { + kind:"gate_assurance_attestation_v2", + schema_version:2, + result_sha256:$sidecar.bindings.result_sha256, + assurance_sha256:$assurance_sha, + repo_root:$sidecar.bindings.repo_root, + repo_identity:$sidecar.bindings.repo_identity, + base_commit:$sidecar.bindings.base_commit, + head_commit:$sidecar.bindings.head_commit, + subject_fingerprint:$sidecar.bindings.subject_fingerprint, + repository_key:$sidecar.subject.repository.key, + subject_sha256:$subject_sha, + run_ids:[$sidecar.dispatch.outcomes[].run_id] + } + ' > "$attestation" +} + _gate_verify_result_path() { local slug="$1" run_root run_root="$( @@ -562,11 +653,11 @@ _gate_verify_result_path() { } _run_canonical_gate_verify() { - local result="$1" + local result="$1"; shift ( cd "$_GATE_VERIFY_REPO" PM_DISPATCH_STATE_ROOT="$_GATE_VERIFY_STATE_ROOT" \ - "$PMCTL" gate verify "$result" + "$PMCTL" gate verify "$result" "$@" ) } @@ -637,6 +728,27 @@ case_verify_v2_without_policy_remains_readable() { fi } +case_verify_v2_named_consumer_is_not_authorizing() { + local name="gate/verify: v2 named consumer reports applicability unavailable" + should_run "$name" || return 0 + local result="$tmp_root/v2-named-consumer/result.md" out code + _mk_gate_result_v2 "$result" + set +e + out="$("$PMCTL" gate verify "$result" --consumer embedded --json 2>&1)" + code=$? + set -e + if [[ "$code" -eq 1 ]] && jq -e ' + .axes.artifact_valid.status == "pass" and + .axes.policy_applicable.status == "unavailable" and + (.axes.policy_applicable.reason_codes | + index("consumer_applicability_unavailable")) != null + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + case_verify_v2_canonical_authorization() { local name="gate/verify: v2 protected attestation and canonical runs exit 0" should_run "$name" || return 0 @@ -652,6 +764,504 @@ case_verify_v2_canonical_authorization() { fi } +case_verify_v3_three_axes_current() { + # Behavior: a current immutable subject with verified generic review evidence + # passes all three independently reported axes. + local name="gate/verify: v3 current generic artifact passes all three JSON axes" + should_run "$name" || return 0 + local result out code + result="$(_gate_verify_result_path v3-current)" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + if [[ "$code" -eq 0 ]] \ + && jq -e ' + .kind == "gate_verification_v1" and + .assurance.kind == "gate_assurance_v3" and + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "pass" and + .axes.policy_applicable.status == "pass" and + .axes.policy_applicable.required_policy == "generic" + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_producer_drift_reason_codes() { + local name="gate/verify: v3 producer-finish drift reasons are observable" + should_run "$name" || return 0 + local field reason value result sidecar out code slug + while IFS='|' read -r field reason; do + slug="${field//_/-}" + result="$(_gate_verify_result_path "v3-producer-$slug")" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + case "$field" in + repository_key|tree_fingerprint) value="$(printf '%064d' 0)" ;; + base_commit|head_commit) value="$(printf '%040d' 0)" ;; + esac + jq --arg field "$field" --arg value "$value" \ + '.subject.observed_at_finish[$field] = $value' \ + "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" + _refresh_gate_result_v3_attestation "$result" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + if [[ "$code" -ne 1 ]] || ! jq -e --arg reason "$reason" ' + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "fail" and + (.axes.subject_current.reason_codes | index($reason)) != null + ' <<<"$out" >/dev/null; then + fail "$name" "$field code=$code out=$out" + return + fi + done <<'CASES' +repository_key|producer_repository_drift +base_commit|producer_base_drift +head_commit|producer_head_drift +tree_fingerprint|producer_tree_drift +CASES + pass "$name" +} + +case_verify_v3_policy_reason_codes() { + local name="gate/verify: v3 policy-applicability failure reasons are covered" + should_run "$name" || return 0 + local result sidecar fixture mutation reason consumer axis + result="$(_gate_verify_result_path v3-policy-reasons)" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + while IFS='|' read -r mutation reason consumer; do + fixture="$tmp_root/policy-reason-${reason}.json" + case "$mutation" in + verdict) + jq '.result.final = "NO-GO"' "$sidecar" > "$fixture" + ;; + no-policy) + jq 'del(.policy)' "$sidecar" > "$fixture" + ;; + enforcement) + jq '.policy.enforcement.status = "fail"' "$sidecar" > "$fixture" + ;; + independence) + jq '.coordinates.independence.evidence_status = "unavailable"' \ + "$sidecar" > "$fixture" + ;; + dispatch) + jq '.dispatch.outcomes[0].run_id = null' "$sidecar" > "$fixture" + ;; + closure) + jq '.policy.consumer_policy = "maintainer"' "$sidecar" > "$fixture" + ;; + esac + axis="$( + gate_policy_applicability_assess "$fixture" "$consumer" verified + )" + if ! jq -e --arg reason "$reason" ' + .status == "fail" and (.reason_codes | index($reason)) != null + ' <<<"$axis" >/dev/null; then + fail "$name" "$mutation axis=$axis" + return + fi + if [[ "$consumer" == publish ]] && ! jq -e ' + .consumer == "publish" and .required_policy == "maintainer" and + .embedded_policy == "maintainer" + ' <<<"$axis" >/dev/null; then + fail "$name" "publish policy mapping is not self-describing: $axis" + return + fi + done <<'CASES' +verdict|verdict_not_go|generic +no-policy|policy_resolution_unavailable|generic +enforcement|policy_enforcement_failed|generic +independence|review_independence_unverified|generic +dispatch|review_dispatch_evidence_incomplete|generic +closure|closure_evidence_unavailable|publish +CASES + pass "$name" +} + +case_verify_v3_dirty_drift_is_stale_not_invalid() { + # Behavior: post-finalization working-tree drift keeps result/sidecar integrity + # valid but makes the committed-head subject stale. + local name="gate/verify: v3 dirty drift is subject fail while artifact stays valid" + should_run "$name" || return 0 + local result out code + result="$(_gate_verify_result_path v3-dirty)" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + printf 'dirty\n' > "$_GATE_VERIFY_REPO/untracked.txt" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + rm -f "$_GATE_VERIFY_REPO/untracked.txt" + if [[ "$code" -eq 1 ]] \ + && jq -e ' + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "fail" and + (.axes.subject_current.reason_codes | index("tree_drift")) != null + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_head_moved_is_stale() { + # Behavior: moving HEAD after finalization is freshness failure, not artifact + # forgery. Restore the disposable fixture ref after the assertion. + local name="gate/verify: v3 moved HEAD reports head_moved without invalidating artifact" + should_run "$name" || return 0 + local result out code old_head + result="$(_gate_verify_result_path v3-head-moved)" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + old_head="$(git -C "$_GATE_VERIFY_REPO" rev-parse HEAD)" + printf 'moved\n' > "$_GATE_VERIFY_REPO/input.txt" + git -C "$_GATE_VERIFY_REPO" add input.txt + git -C "$_GATE_VERIFY_REPO" commit -qm moved + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + git -C "$_GATE_VERIFY_REPO" reset --hard -q "$old_head" + if [[ "$code" -eq 1 ]] \ + && jq -e ' + .axes.artifact_valid.status == "pass" and + (.axes.subject_current.reason_codes | index("head_moved")) != null + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_base_advanced_is_stale() { + # Behavior: advancing the named base ref without moving the reviewed HEAD + # reports base_advanced and keeps the artifact digest valid. + local name="gate/verify: v3 advanced base is stale rather than invalid" + should_run "$name" || return 0 + local result out code old_base tree new_base + result="$(_gate_verify_result_path v3-base-advanced)" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + old_base="$(git -C "$_GATE_VERIFY_REPO" rev-parse refs/heads/main)" + tree="$(git -C "$_GATE_VERIFY_REPO" rev-parse 'HEAD^{tree}')" + new_base="$(printf 'base advance\n' \ + | git -C "$_GATE_VERIFY_REPO" commit-tree "$tree" -p "$old_base")" + git -C "$_GATE_VERIFY_REPO" update-ref refs/heads/main "$new_base" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + git -C "$_GATE_VERIFY_REPO" update-ref refs/heads/main "$old_base" + if [[ "$code" -eq 1 ]] \ + && jq -e ' + .axes.artifact_valid.status == "pass" and + (.axes.subject_current.reason_codes | index("base_advanced")) != null + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_fixed_ref_ignores_working_tree() { + # Behavior: a fixed-ref subject is immutable and deliberately ignores + # unrelated working-tree dirt while still binding repository/base/head. + local name="gate/verify: v3 fixed ref remains current across working-tree dirt" + should_run "$name" || return 0 + local result sidecar snapshot subject out code + result="$(_gate_verify_result_path v3-fixed-ref)" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + snapshot="$( + gate_subject_snapshot "$_GATE_VERIFY_REPO" main \ + "$(git -C "$_GATE_VERIFY_REPO" rev-parse HEAD)" \ + fixed_ref ignore_working_tree "2026-07-27T00:00:00Z" + )" + subject="$(jq -nc --argjson snapshot "$snapshot" '{ + kind:"gate_subject_v1", + schema_version:1, + repository:$snapshot.repository, + observed:$snapshot.observed, + base:$snapshot.base, + head:$snapshot.head, + tree_fingerprint:$snapshot.tree_fingerprint, + subject_kind:$snapshot.subject_kind, + dirty_policy:$snapshot.dirty_policy, + created_at:$snapshot.captured_at, + finished_at:$snapshot.captured_at, + observed_at_finish:{ + repository_key:$snapshot.repository.key, + base_commit:$snapshot.base.commit, + head_commit:$snapshot.head.commit, + tree_fingerprint:$snapshot.tree_fingerprint + } + }')" + jq --argjson subject "$subject" ' + .subject = $subject | + .bindings.repo_identity = $subject.repository.key | + .bindings.base_commit = $subject.base.commit | + .bindings.head_commit = $subject.head.commit | + .bindings.subject_fingerprint = $subject.tree_fingerprint + ' "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" + _refresh_gate_result_v3_attestation "$result" + printf 'unrelated dirt\n' > "$_GATE_VERIFY_REPO/untracked.txt" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + rm -f "$_GATE_VERIFY_REPO/untracked.txt" + if [[ "$code" -eq 0 ]] \ + && jq -e ' + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "pass" and + .axes.policy_applicable.status == "pass" + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_linked_worktree_path_is_current() { + # Behavior: another linked worktree has the same Git common-dir identity; its + # different observed path alone does not stale the subject. + local name="gate/verify: v3 linked worktree path difference keeps subject current" + should_run "$name" || return 0 + local result linked out code + result="$(_gate_verify_result_path v3-linked-worktree)" + linked="$tmp_root/gate-verify-linked" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + git -C "$_GATE_VERIFY_REPO" worktree add -q --detach "$linked" HEAD + set +e + out="$( + PM_DISPATCH_STATE_ROOT="$_GATE_VERIFY_STATE_ROOT" \ + "$PMCTL" gate verify "$result" --cd "$linked" --json 2>&1 + )" + code=$? + set -e + git -C "$_GATE_VERIFY_REPO" worktree remove -f "$linked" + if [[ "$code" -eq 0 ]] \ + && jq -e --arg original "$_GATE_VERIFY_REPO" ' + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "pass" and + .axes.subject_current.current.observed_root != $original + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_different_repo_same_content_is_stale() { + # Behavior: equal files and commits in a separate repository do not satisfy + # the stable common-dir repository identity. + local name="gate/verify: v3 different repository reports repository_mismatch" + should_run "$name" || return 0 + local result other out code + result="$(_gate_verify_result_path v3-other-repo)" + other="$tmp_root/gate-verify-other" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + git clone -q "$_GATE_VERIFY_REPO" "$other" + git -C "$other" branch main origin/main + set +e + out="$( + PM_DISPATCH_STATE_ROOT="$_GATE_VERIFY_STATE_ROOT" \ + "$PMCTL" gate verify "$result" --cd "$other" --consumer generic --json 2>&1 + )" + code=$? + set -e + if [[ "$code" -eq 1 ]] \ + && jq -e ' + .axes.artifact_valid.status == "pass" and + (.axes.subject_current.reason_codes | index("repository_mismatch")) != null + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_copy_replay_is_valid_but_not_authorizing() { + # Behavior: copying the self-contained result/sidecar pair preserves content + # validity and subject freshness, but protected dispatch applicability does + # not travel outside the canonical run partition. + local name="gate/verify: v3 copied pair is valid/current but not policy-authorizing" + should_run "$name" || return 0 + local result copied out code + result="$(_gate_verify_result_path v3-copy-source)" + copied="$tmp_root/v3-copy/result.md" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + mkdir -p "$(dirname "$copied")" + cp "$result" "$copied" + cp "${result}.assurance.json" "${copied}.assurance.json" + set +e + out="$( + PM_DISPATCH_STATE_ROOT="$_GATE_VERIFY_STATE_ROOT" \ + "$PMCTL" gate verify "$copied" --cd "$_GATE_VERIFY_REPO" \ + --consumer generic --json 2>&1 + )" + code=$? + set -e + if [[ "$code" -eq 1 ]] \ + && jq -e ' + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "pass" and + .axes.policy_applicable.status == "fail" and + (.axes.policy_applicable.reason_codes | + index("canonical_dispatch_evidence_unavailable")) != null + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_valid_but_policy_insufficient() { + # Behavior: a generic-policy artifact remains valid/current but cannot satisfy + # the stronger maintainer consumer. + local name="gate/verify: v3 generic artifact is insufficient for maintainer consumer" + should_run "$name" || return 0 + local result out code + result="$(_gate_verify_result_path v3-policy-insufficient)" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer maintainer --json 2>&1)" + code=$? + set -e + if [[ "$code" -eq 1 ]] \ + && jq -e ' + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "pass" and + (.axes.policy_applicable.reason_codes | + index("consumer_policy_mismatch")) != null + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_linked_evidence_digest_tamper_is_invalid() { + # Behavior: a linked evidence digest is part of artifact integrity, distinct + # from whether the otherwise immutable subject is still current. + local name="gate/verify: v3 malformed linked-evidence digest invalidates artifact" + should_run "$name" || return 0 + local result sidecar evidence evidence_sha out report code + result="$(_gate_verify_result_path v3-evidence-tamper)" + sidecar="${result}.assurance.json" + evidence="$(dirname "$result")/preflight-evidence-20260727-000000.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + printf '{"kind":"fixture","status":"pass"}\n' > "$evidence" + evidence_sha="$(sha256sum "$evidence" | awk '{print $1}')" + jq --arg artifact "$(basename "$evidence")" --arg sha "$evidence_sha" ' + .evidence.preflight = { + status:"linked", + outcome:"pass", + artifact:$artifact, + sha256:$sha, + subject_fingerprint:.subject.tree_fingerprint + } + ' "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" + _refresh_gate_result_v3_attestation "$result" + printf 'tampered\n' >> "$evidence" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + report="$(tail -n 1 <<<"$out")" + if [[ "$code" -eq 1 ]] \ + && jq -e ' + .axes.artifact_valid.status == "fail" and + (.axes.artifact_valid.reason_codes | + index("artifact_integrity_failed")) != null + ' <<<"$report" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_subject_binding_mismatch_is_invalid() { + # Behavior: even a freshly re-attested sidecar cannot separate the legacy + # binding field from the immutable v3 subject it claims to summarize. + local name="gate/verify: v3 binding fingerprint must match immutable subject" + should_run "$name" || return 0 + local result sidecar out report code + result="$(_gate_verify_result_path v3-binding-subject-mismatch)" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + jq '.bindings.subject_fingerprint = ("f" * 64)' "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" + _refresh_gate_result_v3_attestation "$result" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + report="$(tail -n 1 <<<"$out")" + if [[ "$code" -eq 1 ]] \ + && [[ "$out" == *"structural/claim verification"* ]] \ + && jq -e '.axes.artifact_valid.status == "fail"' \ + <<<"$report" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_linked_preflight_subject_claim_mismatch_is_invalid() { + # Behavior: the sidecar cannot attach a digest-valid preflight artifact to a + # different subject by writing the desired subject only in the link record. + local name="gate/verify: v3 linked preflight subject must match evidence claim" + should_run "$name" || return 0 + local result sidecar evidence evidence_sha out report code + result="$(_gate_verify_result_path v3-preflight-subject-mismatch)" + sidecar="${result}.assurance.json" + evidence="$(dirname "$result")/preflight-evidence-20260727-000001.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + jq -n '{ + kind:"pr_gate_preflight_v1", + status:"pass", + subject:{ + fingerprint_before:("f" * 64), + fingerprint_after:("f" * 64) + } + }' > "$evidence" + evidence_sha="$(sha256sum "$evidence" | awk '{print $1}')" + jq --arg artifact "$(basename "$evidence")" --arg sha "$evidence_sha" ' + .evidence.preflight = { + status:"linked", + outcome:"pass", + artifact:$artifact, + sha256:$sha, + subject_fingerprint:.subject.tree_fingerprint + } + ' "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" + _refresh_gate_result_v3_attestation "$result" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + report="$(tail -n 1 <<<"$out")" + if [[ "$code" -eq 1 ]] \ + && [[ "$out" == *"linked preflight evidence subject claim mismatch"* ]] \ + && jq -e '.axes.artifact_valid.status == "fail"' \ + <<<"$report" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + case_verify_v2_forged_state_tree_rejected() { local name="gate/verify: self-consistent noncanonical state tree exits 1" should_run "$name" || return 0 @@ -989,6 +1599,45 @@ case_verify_usage() { fi } +case_verify_argument_errors() { + local name="gate/verify: new flag parser rejects malformed arguments" + should_run "$name" || return 0 + local result="$tmp_root/verify-args/result.md" variant expected out code + _mk_gate_result "$result" GO + while IFS='|' read -r variant expected; do + set +e + case "$variant" in + consumer) + out="$("$PMCTL" gate verify "$result" --consumer bogus 2>&1)" + code=$? + ;; + cd) + out="$("$PMCTL" gate verify "$result" --cd 2>&1)" + code=$? + ;; + option) + out="$("$PMCTL" gate verify "$result" --bogus 2>&1)" + code=$? + ;; + positional) + out="$("$PMCTL" gate verify "$result" "$result" 2>&1)" + code=$? + ;; + esac + set -e + if [[ "$code" -ne 2 || "$out" != *"$expected"* ]]; then + fail "$name" "$variant code=$code out=$out" + return + fi + done <<'CASES' +consumer|requires embedded, generic, maintainer, or publish +cd|requires a repository path +option|unknown option +positional|unexpected argument +CASES + pass "$name" +} + # ---- 11: pmctl gate run forwards --run-dir to pr-gate.sh -------------------- case_run_dir_forwarded_to_gate() { # Verifies that pmctl_gate_run computes a run dir keyed to the --cd target repo @@ -1447,7 +2096,7 @@ RUNNER >"$out" 2>"$err" & gate_pid=$! - ready_value="$(timeout 20 cat "$ready")" || { + ready_value="$(timeout 60 cat "$ready")" || { kill "$gate_pid" 2>/dev/null || true wait "$gate_pid" 2>/dev/null || true fail "$name" "preflight never reached deterministic readiness; err=$(cat "$err" 2>/dev/null || true)" @@ -1517,7 +2166,7 @@ FAKE_GATE gate_id="$(PM_DISPATCH_STATE_ROOT="$state" XDG_RUNTIME_DIR="$_GATE_CLI_XDG_RUNTIME_DIR" \ TEST_GATE_READY_FIFO="$ready" TEST_GATE_RELEASE_FIFO="$release" TEST_GATE_PIDS="$pids" \ "$fixture/cli/pmctl" gate run --lifecycle detached --cd "$work" 2>"$err")" - if [[ "$(timeout 20 cat "$ready")" != ready ]]; then + if [[ "$(timeout 60 cat "$ready")" != ready ]]; then fail "$name" "detached producer never reached readiness; gate=$gate_id err=$(cat "$err" 2>/dev/null || true)" return fi @@ -1560,7 +2209,22 @@ case_help_bypasses_detached_default case_verify_valid case_verify_v2_assurance case_verify_v2_without_policy_remains_readable +case_verify_v2_named_consumer_is_not_authorizing case_verify_v2_canonical_authorization +case_verify_v3_three_axes_current +case_verify_v3_producer_drift_reason_codes +case_verify_v3_policy_reason_codes +case_verify_v3_dirty_drift_is_stale_not_invalid +case_verify_v3_head_moved_is_stale +case_verify_v3_base_advanced_is_stale +case_verify_v3_fixed_ref_ignores_working_tree +case_verify_v3_linked_worktree_path_is_current +case_verify_v3_different_repo_same_content_is_stale +case_verify_v3_copy_replay_is_valid_but_not_authorizing +case_verify_v3_valid_but_policy_insufficient +case_verify_v3_linked_evidence_digest_tamper_is_invalid +case_verify_v3_subject_binding_mismatch_is_invalid +case_verify_v3_linked_preflight_subject_claim_mismatch_is_invalid case_verify_v2_forged_state_tree_rejected case_verify_v2_repo_binding_rejected case_verify_v2_legacy_assurance_is_unavailable @@ -1581,6 +2245,7 @@ case_verify_empty case_verify_no_final case_verify_parity_mismatch case_verify_usage +case_verify_argument_errors case_run_dir_forwarded_to_gate case_default_lifecycle_is_detached case_gate_wait_go_route_via_cli diff --git a/tests/shell/test-pmctl-ship.sh b/tests/shell/test-pmctl-ship.sh index 02920577..fe041134 100755 --- a/tests/shell/test-pmctl-ship.sh +++ b/tests/shell/test-pmctl-ship.sh @@ -182,6 +182,9 @@ pmctl_gate_run() {\ printf "Final: GO\\n" > "$result_file"\ printf "result: %s\\n" "$result_file"\ }\ +pmctl_gate_verify() {\ + jq -n '"'"'{kind:"gate_verification_v1",verdict:"GO",axes:{artifact_valid:{status:"pass",reason_codes:[]},subject_current:{status:"pass",reason_codes:[]},policy_applicable:{status:"pass",reason_codes:[]}}}'"'"'\ +}\ ' "$path/cli/pmctl" chmod +x "$path/cli/pmctl" } @@ -198,6 +201,7 @@ run_finish_with_fake_gate() { shift 3 bash -c ' repo_root="$1"; work_dir="$2"; ticket_id="$3"; verdict="$4"; shift 4 + subject_status="${PM_TEST_GATE_SUBJECT_STATUS:-pass}" pmctl_gate_run() { local result_file result_file="$(mktemp)" @@ -205,6 +209,24 @@ run_finish_with_fake_gate() { printf "result: %s\n" "$result_file" [[ "$verdict" == "GO" ]] } + pmctl_gate_verify() { + jq -n \ + --arg verdict "$verdict" \ + --arg subject_status "$subject_status" \ + '"'"'{ + kind:"gate_verification_v1", + verdict:$verdict, + axes:{ + artifact_valid:{status:"pass",reason_codes:[]}, + subject_current:{ + status:$subject_status, + reason_codes:(if $subject_status == "pass" then [] else ["tree_drift"] end) + }, + policy_applicable:{status:"pass",reason_codes:[]} + } + }'"'"' + [[ "$verdict" == "GO" && "$subject_status" == "pass" ]] + } . "$repo_root/runtime/lib/pmctl-ship.sh" pmctl_ship_finish "$repo_root" "$work_dir" "$ticket_id" "$@" ' _ "$REPO_ROOT" "$work_dir" "$ticket_id" "$verdict" "$@" @@ -223,6 +245,30 @@ run_finish_with_no_result_line() { ' _ "$REPO_ROOT" "$work_dir" "$ticket_id" } +# run_finish_with_broken_shared_verifier <work_dir> <ticket_id> +# <missing|malformed> +# Reaches the post-gate publication boundary with either no shared verifier +# function or one that violates the structured-assessment contract. +run_finish_with_broken_shared_verifier() { + local work_dir="$1" ticket_id="$2" verifier_mode="$3" + bash -c ' + repo_root="$1"; work_dir="$2"; ticket_id="$3"; verifier_mode="$4" + pmctl_gate_run() { + local result_file + result_file="$(mktemp)" + printf "Final: GO\n" > "$result_file" + printf "result: %s\n" "$result_file" + } + . "$repo_root/runtime/lib/pmctl-ship.sh" + if [[ "$verifier_mode" == malformed ]]; then + pmctl_gate_verify() { printf "{\"kind\":\"unexpected\"}\n"; } + else + unset -f pmctl_gate_verify 2>/dev/null || true + fi + pmctl_ship_finish "$repo_root" "$work_dir" "$ticket_id" + ' _ "$REPO_ROOT" "$work_dir" "$ticket_id" "$verifier_mode" +} + # run_ship_parallel_capture_dispatch_argv <store> <work_dir> <ticket-id> [ship --parallel flags...] # Runs the REAL `pmctl_ship_parallel_run` (real worktree creation, so # --from is genuinely exercised against git) but with `pmctl_dispatch_run` @@ -971,6 +1017,69 @@ case_finish_missing_result_file() { pass "$name" } +case_finish_missing_shared_verifier_refuses_publish() { + local name="ship finish: missing shared gate verifier fails closed" + should_run "$name" || return 0 + local work out err status=0 + work="$tmp_root/work-finish-missing-verifier" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + out="$tmp_root/out-finish-missing-verifier" + err="$tmp_root/err-finish-missing-verifier" + run_finish_with_broken_shared_verifier "$work" "CC-9001" missing \ + > "$out" 2> "$err" || status=$? + if [[ "$status" -eq 2 ]] \ + && grep -q "shared gate verifier is unavailable" "$err"; then + pass "$name" + else + fail "$name" "expected exit 2 fail-closed; status=$status stderr=$(cat "$err")" + fi +} + +case_finish_malformed_shared_assessment_refuses_publish() { + local name="ship finish: malformed shared gate assessment fails closed" + should_run "$name" || return 0 + local work out err status=0 + work="$tmp_root/work-finish-malformed-verifier" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + out="$tmp_root/out-finish-malformed-verifier" + err="$tmp_root/err-finish-malformed-verifier" + run_finish_with_broken_shared_verifier "$work" "CC-9001" malformed \ + > "$out" 2> "$err" || status=$? + if [[ "$status" -eq 1 ]] \ + && grep -q "returned no structured assessment" "$err"; then + pass "$name" + else + fail "$name" "expected exit 1 fail-closed; status=$status stderr=$(cat "$err")" + fi +} + +case_finish_go_stale_subject_does_not_push() { + local name="ship finish: GO with stale subject exits 1 before push" + should_run "$name" || return 0 + local work out err status=0 + work="$tmp_root/work-finish-stale-subject" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + add_bare_origin "$work" + out="$tmp_root/out-finish-stale-subject" + err="$tmp_root/err-finish-stale-subject" + PM_TEST_GATE_SUBJECT_STATUS=fail \ + run_finish_with_fake_gate "$work" "CC-9001" "GO" \ + > "$out" 2> "$err" || status=$? + local pushed=0 + git -C "$work.bare-origin.git" show-ref --quiet feat/CC-9001 \ + 2>/dev/null && pushed=1 + if [[ "$status" -eq 1 ]] \ + && grep -q "invalid, stale, or not applicable" "$err" \ + && [[ "$pushed" -eq 0 ]]; then + pass "$name" + else + fail "$name" "expected exit 1 + no push; got status=$status pushed=$pushed stderr=$(cat "$err")" + fi +} + case_finish_go_dirty_tree_refuses_push() { local name="ship finish: GO with an uncommitted (dirty) tree refuses to push -- committed-diff guard" should_run "$name" || return 0 @@ -1014,6 +1123,17 @@ case_finish_go_head_moved_refuses_push() { printf "result: %s\n" "$result_file" return 0 } + pmctl_gate_verify() { + jq -n '"'"'{ + kind:"gate_verification_v1", + verdict:"GO", + axes:{ + artifact_valid:{status:"pass",reason_codes:[]}, + subject_current:{status:"pass",reason_codes:[]}, + policy_applicable:{status:"pass",reason_codes:[]} + } + }'"'"' + } . "$repo_root/runtime/lib/pmctl-ship.sh" pmctl_ship_finish "$repo_root" "$work_dir" "$ticket_id" ' _ "$REPO_ROOT" "$work" "CC-9001" > "$out" 2> "$err" || status=$? @@ -1870,6 +1990,9 @@ case_run_tracks_adapter_field() { case_finish_no_go_does_not_push case_finish_missing_result_file +case_finish_missing_shared_verifier_refuses_publish +case_finish_malformed_shared_assessment_refuses_publish +case_finish_go_stale_subject_does_not_push case_finish_go_dirty_tree_refuses_push case_finish_go_head_moved_refuses_push case_finish_gh_missing_refuses_before_gate_or_push diff --git a/tests/shell/test-pr-gate.sh b/tests/shell/test-pr-gate.sh index 336c8e61..691e494f 100755 --- a/tests/shell/test-pr-gate.sh +++ b/tests/shell/test-pr-gate.sh @@ -2902,6 +2902,19 @@ test_preflight_pass_no_override() { fi assert_file_contains "$name" "$result" "Final: GO" || return assert_file_contains "$name" "$result" "test_suite: pass" || return + jq -e ' + .kind == "gate_assurance_v3" and + .evidence.preflight.status == "linked" and + .evidence.preflight.outcome == "pass" and + (.evidence.preflight.artifact | + test("^preflight-evidence-[0-9]{8}-[0-9]{6}\\.json$")) and + (.evidence.preflight.sha256 | test("^[a-f0-9]{64}$")) and + .evidence.preflight.subject_fingerprint == + .subject.tree_fingerprint + ' "${result}.assurance.json" >/dev/null || { + fail "$name" "v3 assurance did not bind the passing preflight artifact" + return + } pass "$name" } @@ -3856,6 +3869,18 @@ test_gate_result_frontmatter_and_escalation() { return fi jq -e ' + .kind == "gate_assurance_v3" and + .schema_version == 3 and + .subject.kind == "gate_subject_v1" and + .subject.subject_kind == "working_tree" and + .subject.dirty_policy == "include_working_tree" and + .bindings.repo_identity == .subject.repository.key and + .bindings.base_commit == .subject.base.commit and + .bindings.head_commit == .subject.head.commit and + .bindings.subject_fingerprint == .subject.tree_fingerprint and + .evidence.preflight.status == "not_run" and + .evidence.scope_manifest.status == "unavailable" and + .evidence.closure.status == "unavailable" and .coordinates.mode.resolved == "sequential" and .coordinates.independence.evidence_status == "unavailable" and .coordinates.independence.per_reviewer_independent == null and @@ -5926,7 +5951,7 @@ test_dirty_preflight_allow_dirty_includes_worktree() { should_run "$name" || return 0 local dir="$TMP_ROOT/$name" local home="$dir/home" repo="$dir/repo" runner="$dir/runner" - local out="$dir/out" err="$dir/err" brief="$dir/brief.md" + local out="$dir/out" err="$dir/err" brief="$dir/brief.md" result mkdir -p "$dir" create_runner "$runner" create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer @@ -5946,6 +5971,15 @@ test_dirty_preflight_allow_dirty_includes_worktree() { assert_file_contains "$name" "$err" "DISPATCH_STUB:success" || return assert_file_contains "$name" "$brief" "dirtysrc.go" || return assert_file_contains "$name" "$err" "--allow-dirty set" || return + result="$(awk -F'result: ' '/^result: /{path=$2} END{print path}' "$out")" + jq -e ' + .kind == "gate_assurance_v3" and + .subject.subject_kind == "working_tree" and + .subject.dirty_policy == "include_working_tree" + ' "${result}.assurance.json" >/dev/null || { + fail "$name" "allow-dirty result did not bind a working-tree subject" + return + } pass "$name" } @@ -7417,7 +7451,7 @@ test_head_override_diffs_fixed_ref() { should_run "$name" || return 0 local dir="$TMP_ROOT/$name" local home="$dir/home" repo="$dir/repo" runner="$dir/runner" - local out="$dir/out" err="$dir/err" brief="$dir/brief.md" + local out="$dir/out" err="$dir/err" brief="$dir/brief.md" result mkdir -p "$dir" create_runner "$runner" create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer @@ -7438,6 +7472,16 @@ test_head_override_diffs_fixed_ref() { fi assert_file_contains "$name" "$brief" "Head: feature" || return assert_file_contains "$name" "$brief" "app.go" || return + result="$(awk -F'result: ' '/^result: /{path=$2} END{print path}' "$out")" + jq -e ' + .kind == "gate_assurance_v3" and + .subject.subject_kind == "fixed_ref" and + .subject.dirty_policy == "ignore_working_tree" and + .subject.head.ref == "feature" + ' "${result}.assurance.json" >/dev/null || { + fail "$name" "fixed --head result did not bind an immutable ref subject" + return + } pass "$name" } diff --git a/tools/generate-gate-result-verifier-fallback.sh b/tools/generate-gate-result-verifier-fallback.sh index 5dd552c7..d710d97e 100755 --- a/tools/generate-gate-result-verifier-fallback.sh +++ b/tools/generate-gate-result-verifier-fallback.sh @@ -26,7 +26,12 @@ trap cleanup EXIT functions=( gate_result_verdict_verify _gate_result_frontmatter_value + _gate_result_sha256_stream _gate_result_sha256_file + _gate_subject_common_dir + _gate_subject_tree_fingerprint + gate_subject_snapshot + _gate_assurance_linked_evidence_verify gate_assurance_verify gate_result_verify )