From af3384f98c039a23b9b010b127d92f085f61f00f Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Fri, 7 Aug 2026 18:24:07 -0700 Subject: [PATCH 1/5] feat(control): add an atomic current-control pointer and refresh protocol A coding agent could keep enforcing a verifier result the workspace had outgrown. In the reported sequence a worktree verify returned human_review_required, a human committed the reviewed change, a fresh committed-ref run produced a complete receipt for the same request -- and the agent kept enforcing the older must_stop, asking for the commit that had already happened. The same gap runs backward: a complete remembered from earlier in a conversation is not evidence about a workspace that has since been rebased, checked out, or reconfigured. The content-addressed receipt already prevented an old decision from authorizing a *different* request. What was missing was one atomic place to ask "what is current now?", and any obligation to ask it. agents-shipgate-reports/current-control.json (shipgate.current_control/v1) is that entry point. It is a pointer, not a second decision engine: it binds identities and hashes of the receipt, handoff, verifier, and report those commands already publish. Its lifecycle is what makes it trustworthy: - Invalidate first. verify, verify --preview, scan, and verification prepare each replace it with a non-terminal `unavailable` marker before touching any other artifact, so a run that crashes leaves a directory that denies cached control instead of one still advertising the previous verdict for a workspace that has moved. - Publish last, after every artifact it references exists and is hashed. - Publish atomically: same-directory temporary file, fsync, os.replace, then fsync of the directory. Readers use the generation-safe protocol in `agents-shipgate agent control`: validate the pointer, validate every artifact hash it binds, re-read the pointer, and continue only if current_control_id is unchanged. A run that republishes mid-read makes the read fail rather than return one generation's pointer beside another's artifacts. Two invariants are structural rather than advisory: only an operation: "verify" pointer can carry control.state: "complete", and only when it also binds a verification_receipt for that exact request. A scan or preview pointer cannot represent completion authority at all. Supporting scans stay isolated -- verify's internal head scan does not take over the PR's control identity, and baseline save already scanned into a temporary directory. Contract 19 -> 20 adds current_control_schema_version, current_control_schema_path, current_control_artifact, the agent_refresh_triggers[] list of boundaries at which a cached control state expires, and current_control_fallback_read_order[] for consumers built before the pointer existed. agent_read_order[] now starts at the pointer, and the local downstream contract moves 7 -> 8. Generated agent instructions and both adoption kits now require the refresh, and the outgoing render hashes are recorded so repos that already ran `init --write` can upgrade in place. No report, packet, verifier, handoff, or receipt schema changed. Closes #339 Co-Authored-By: Claude Opus 5 --- .agents/skills/agents-shipgate/SKILL.md | 2 +- .../references/report-reading.md | 8 +- .claude/commands/shipgate.md | 6 +- .cursor/rules/agents-shipgate.mdc | 11 + .well-known/agents-shipgate.json | 30 +- AGENTS.md | 24 +- CHANGELOG.md | 41 ++ README.md | 5 +- STABILITY.md | 12 + .../.agents-shipgate-kit-metadata.json | 3 +- adoption-kits/claude-code-skill/SKILL.md | 10 +- .../.agents-shipgate-kit-metadata.json | 6 +- adoption-kits/codex-skill/SKILL.md | 2 +- .../codex-skill/references/report-reading.md | 8 +- docs/INDEX.md | 1 + docs/agent-contract-current.md | 44 +- docs/architecture.md | 2 +- docs/current-control-schema.v1.json | 395 +++++++++++ docs/target-repo-agent-snippets.md | 33 + llms-full.txt | 68 +- llms.txt | 2 +- .../skills/agents-shipgate/SKILL.md | 2 +- .../references/report-reading.md | 8 +- plugins/claude-code/commands/shipgate.md | 6 +- .../skills/agents-shipgate/SKILL.md | 10 +- scripts/generate_schemas.py | 19 + skills/agents-shipgate/SKILL.md | 10 +- src/agents_shipgate/cli/_register_scan.py | 29 + src/agents_shipgate/cli/agent_interface.py | 61 +- .../agent_instructions/renderers/_shared.py | 17 + .../agent_instructions/renderers/agents_md.py | 3 + .../renderers/claude_command.py | 5 +- .../agent_instructions/renderers/claude_md.py | 3 + .../agent_instructions/renderers/cursor.py | 15 +- .../renderers/local_contract.py | 2 + .../cli/discovery/local_contract.py | 16 +- src/agents_shipgate/cli/scan/writing.py | 54 ++ src/agents_shipgate/cli/verification.py | 39 ++ src/agents_shipgate/cli/verify/command.py | 29 + .../cli/verify/orchestrator.py | 144 +++- src/agents_shipgate/core/current_control.py | 564 +++++++++++++++ .../core/verification_identity.py | 33 +- src/agents_shipgate/schemas/contract.py | 59 +- .../schemas/current_control.py | 244 +++++++ .../schemas/verification_identity.py | 13 +- tests/test_agent_instructions_apply.py | 7 +- tests/test_agent_instructions_renderers.py | 28 +- tests/test_cli.py | 14 + tests/test_current_control.py | 655 ++++++++++++++++++ tests/test_local_contract.py | 13 +- tests/test_public_surface_contract.py | 5 +- tests/test_schema_boundaries.py | 10 + 52 files changed, 2766 insertions(+), 64 deletions(-) create mode 100644 docs/current-control-schema.v1.json create mode 100644 src/agents_shipgate/core/current_control.py create mode 100644 src/agents_shipgate/schemas/current_control.py create mode 100644 tests/test_current_control.py diff --git a/.agents/skills/agents-shipgate/SKILL.md b/.agents/skills/agents-shipgate/SKILL.md index a34b43c0..fb404a2b 100644 --- a/.agents/skills/agents-shipgate/SKILL.md +++ b/.agents/skills/agents-shipgate/SKILL.md @@ -20,7 +20,7 @@ Do not use it for general linting, runtime monitoring, evals, model-output quali 5. Default first-time CI to advisory mode. Do not enable release-blocking CI or save a baseline until a human has reviewed current findings. 6. For local agent control, run `shipgate check --agent codex --workspace . --format agent-boundary-json` and read the stdout `shipgate.agent_boundary_result/v1` object. Switch on `control.state`; follow only `control.next_action`, `control.allowed_next_commands`, and `control.human_review`. Treat `decision` as diagnostic context only. 7. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. -8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. +8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control` before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. 9. Auto-apply only high-confidence safe patches. Do not auto-assert action effect, action authority, agent bindings, approval, confirmation, idempotency, broad-scope, prohibited-action, or runtime-trace evidence. 10. Ensure `.gitignore` covers `agents-shipgate-reports/` before committing. diff --git a/.agents/skills/agents-shipgate/references/report-reading.md b/.agents/skills/agents-shipgate/references/report-reading.md index 87c649eb..95110ddd 100644 --- a/.agents/skills/agents-shipgate/references/report-reading.md +++ b/.agents/skills/agents-shipgate/references/report-reading.md @@ -1,12 +1,16 @@ # Reading Agents Shipgate Reports -For verify runs, validate `agents-shipgate-reports/verification-receipt.json` -first. Then read `agents-shipgate-reports/agent-handoff.json`. After that, +For verify runs, read `agents-shipgate-reports/current-control.json` first — +via `agents-shipgate agent control` — because it names which run is current and +a non-zero exit means none is. Then validate the +`agents-shipgate-reports/verification-receipt.json` it binds. Then read +`agents-shipgate-reports/agent-handoff.json`. After that, read `agents-shipgate-reports/verifier.json` for detailed control context and `agents-shipgate-reports/report.json` for findings. Do not scrape Markdown. ## Order +0. `current-control.json.current_control_id` / `lifecycle_state` / `control.state`: which run is current, and whether any decision is. Re-read this before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete; if the id changed, discard cached control state and start again from the new identity. 1. `agent-handoff.json.control.state`: `complete`, `agent_action_required`, or `human_review_required`. 2. `agent-handoff.json.capability_review.top_changes[]`: the highest-signal tool/action or trust-root changes. 3. `agent-handoff.json.next_action` / `control.next_action` / `fix_task`: who acts next and whether a coding agent may safely attempt the fix. diff --git a/.claude/commands/shipgate.md b/.claude/commands/shipgate.md index 55f75f8c..c3004de1 100644 --- a/.claude/commands/shipgate.md +++ b/.claude/commands/shipgate.md @@ -48,7 +48,8 @@ Required behavior (do not skip): 1. Set `AGENTS_SHIPGATE_AGENT_MODE=1` for every CLI call so errors emit a `next_action` JSON line on stderr. 2. Run `agents-shipgate contract --json` when available and use it to verify the installed CLI's schema versions and gating signal. -3. For verifier runs, validate `agents-shipgate-reports/verification-receipt.json` first, +3. For verifier runs, read `agents-shipgate-reports/current-control.json` first, + then validate the `agents-shipgate-reports/verification-receipt.json` it binds, then parse `agents-shipgate-reports/agent-handoff.json`, then `verifier.json`, `verify-run.json`, and `report.json.release_decision.decision` as the release gate. @@ -78,7 +79,8 @@ permissions, policies, CI gates, or `shipgate.yaml`, run the verifier: agents-shipgate verify --base origin/main --head HEAD --json ``` -Validate `agents-shipgate-reports/verification-receipt.json` first, then read +Read `agents-shipgate-reports/current-control.json` first, then validate the +`agents-shipgate-reports/verification-receipt.json` it binds, then read `agents-shipgate-reports/agent-handoff.json` and lead with `gate.merge_verdict` (a deterministic projection of `release_decision.decision`, which remains the gate in `report.json`), then the authoritative substrate diff --git a/.cursor/rules/agents-shipgate.mdc b/.cursor/rules/agents-shipgate.mdc index 583e89c7..553c9d07 100644 --- a/.cursor/rules/agents-shipgate.mdc +++ b/.cursor/rules/agents-shipgate.mdc @@ -68,6 +68,17 @@ release gate. Legacy `agent-result.json` surfaces, where present, are supporting/provisional projections and not the CI gate. +`agents-shipgate-reports/current-control.json` is the one entry point that +says which control identity is current. Read it with `agents-shipgate agent +control`; a non-zero exit means nothing is current here and you hold no +authority. Re-read it after any human or external-tool action, after commit, +rebase, checkout, pull, or any worktree change, after any agents-shipgate +command returns, before enforcing a cached `must_stop`, before commit/push/PR +update, before merge or release, and before declaring the task complete. If +`current_control_id` changed, discard every cached control state and restart +from the new identity. A result you remember from earlier in this conversation +never outranks the current pointer — in either direction. + For coding-agent host grants, run: shipgate audit --host --json --out agents-shipgate-reports/host-grants.json diff --git a/.well-known/agents-shipgate.json b/.well-known/agents-shipgate.json index 8ba40386..1d6f6ae5 100644 --- a/.well-known/agents-shipgate.json +++ b/.well-known/agents-shipgate.json @@ -186,6 +186,9 @@ "verification_unit_result_schema_version": "shipgate.verification_unit_result/v1", "verification_artifact_manifest_schema_version": "shipgate.verification_artifact_manifest/v1", "verification_receipt_schema_version": "shipgate.verification_receipt/v1", + "current_control_schema_version": "shipgate.current_control/v1", + "current_control_schema_path": "docs/current-control-schema.v1.json", + "current_control_artifact": "agents-shipgate-reports/current-control.json", "human_authorization_request_schema_version": "shipgate.human_authorization_request/v1", "human_authorization_schema_version": "shipgate.human_authorization/v1", "human_authorization_evaluation_schema_version": "shipgate.human_authorization_evaluation/v1", @@ -195,7 +198,7 @@ "agent_handoff_schema_version": "shipgate.agent_handoff/v6", "agent_handoff_schema_path": "docs/agent-handoff-schema.v6.json", "agent_handoff_artifact": "agents-shipgate-reports/agent-handoff.json", - "contract_version": "19", + "contract_version": "20", "minimum_control_contract_version": "14", "local_agent_contract_schema_version": "7", "inputs": [ @@ -213,6 +216,7 @@ "conductor" ], "outputs": [ + "current_control_json", "markdown", "json", "sarif", @@ -243,6 +247,7 @@ "governance_benchmark_result_json" ], "artifacts": { + "current_control": "agents-shipgate-reports/current-control.json", "verifier": "agents-shipgate-reports/verifier.json", "verify_run": "agents-shipgate-reports/verify-run.json", "agent_handoff": "agents-shipgate-reports/agent-handoff.json", @@ -295,6 +300,7 @@ "skill_review" ], "external_integration_surfaces": [ + "current_control", "agent_handoff", "preflight", "capability_lock", @@ -404,6 +410,10 @@ "decision_id" ], "agent_read_order": [ + "current-control.json", + "current-control.json.current_control_id", + "current-control.json.lifecycle_state", + "current-control.json.control.state", "verification-receipt.json", "verification-receipt.json.request_id", "verification-receipt.json.receipt_id", @@ -414,6 +424,23 @@ "verify-run.json", "report.json.release_decision.decision" ], + "agent_refresh_triggers": [ + "after any human action or external tool action", + "after commit, rebase, checkout, pull, or any other worktree mutation", + "after any agents-shipgate command returns", + "before enforcing a cached must_stop", + "before commit, push, or PR update when permission depends on Shipgate", + "before merge or release", + "before declaring the task complete", + "whenever the observed request_id, HEAD, worktree identity, or current_control_id changes" + ], + "current_control_fallback_read_order": [ + "current-control.json", + "verification-receipt.json", + "agent-handoff.json", + "verifier.json", + "report.json" + ], "do_not_auto_assert": [ "action_effect", "action_authority", @@ -436,6 +463,7 @@ "runtime_behavior_verified": false, "static_verdict_disclaimer": "This verdict covers deterministic static evidence only. Agents Shipgate did not execute the agent or prove runtime behavior, tool routing, credential enforcement, or safety.", "schemas": { + "current_control": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/current-control-schema.v1.json", "manifest": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/manifest-v0.1.json", "report": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/report-schema.v0.34.json", "agent_result": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-result-schema.v2.json", diff --git a/AGENTS.md b/AGENTS.md index 99b68025..8ff6767b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,8 +130,10 @@ agents-shipgate verify --workspace . --config shipgate.yaml \ ``` For local uncommitted verifier work, omit `--base`/`--head` so the working tree -is scanned. Validate `agents-shipgate-reports/verification-receipt.json` -first, then read `agents-shipgate-reports/agent-handoff.json` and lead +is scanned. Read `agents-shipgate-reports/current-control.json` +first — it names which run is current — then validate the +`verification-receipt.json` it binds, then read +`agents-shipgate-reports/agent-handoff.json` and lead with `control.state`, then `gate.merge_verdict` (`mergeable | human_review_required | insufficient_evidence | blocked | unknown`), `gate.can_merge_without_human`, `next_action`, @@ -145,6 +147,24 @@ the release gate. Do not report completion unless `control.state` is changes the control state; conversation-level acceptance is not a gate override. +Read the pointer with: + +```bash +agents-shipgate agent control --reports-dir agents-shipgate-reports +``` + +A zero exit means the printed pointer was validated against every artifact it +binds and did not move while it was read. A non-zero exit means no control +identity is current here — you hold no authority, and a remembered result does +not substitute for one. Re-read it after any human or external-tool action, +after commit, rebase, checkout, pull, or any worktree change, after any +agents-shipgate command returns, before enforcing a cached `must_stop`, before +commit/push/PR update, before merge or release, and before declaring the task +complete. If `current_control_id` changed, discard every cached control state +and restart from the new identity. This runs in both directions: a cached stop +must not survive a newer complete run, and a cached completion must not survive +a changed workspace. + Do not bypass the verifier by suppressing findings, lowering severity, expanding baselines or waivers, removing Shipgate CI, or weakening agent instructions. Verify-mode `SHIP-VERIFY-*` checks make those trust-root edits diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4e7165..33c9c68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ ## Unreleased +- **A coding agent can no longer enforce a verifier result the workspace has + outgrown.** The reported failure ran forward: a worktree verify returned + `human_review_required`, a human committed the reviewed change, a fresh + committed-ref run produced a `complete` receipt for the same request — and the + agent kept enforcing the older `must_stop`, asking for the commit that had + already happened. It runs backward just as easily: a `complete` remembered + from earlier in a conversation is not evidence about a workspace that has + since been rebased, checked out, or reconfigured. The content-addressed + receipt already prevented an old decision from *authorizing a different + request*; what was missing was one atomic place to ask "what is current now?", + and any obligation to ask it. Both are now present. + `agents-shipgate-reports/current-control.json` + (`shipgate.current_control/v1`) is that entry point. It is a pointer, not a + second decision engine: it binds identities and hashes of the receipt, + handoff, verifier, and report those commands already publish. Its lifecycle is + what makes it trustworthy. `verify`, `verify --preview`, `scan`, and + `verification prepare` each replace it with a non-terminal `unavailable` + marker *before* touching any other artifact, so a run that crashes leaves a + directory that denies cached control instead of one that still advertises the + previous verdict for a workspace that has moved; the terminal pointer is + written last, after every artifact it references exists and has been hashed, + and published by same-directory `os.replace` so no reader can observe a + half-written one. Readers use the generation-safe protocol in `agents-shipgate + agent control`: validate the pointer, validate every artifact hash it binds, + re-read the pointer, and continue only if `current_control_id` is unchanged — + a run that republishes mid-read makes the read fail rather than return one + generation's pointer beside another's artifacts. Two invariants are structural + rather than advisory: only an `operation: "verify"` pointer can carry + `control.state: "complete"`, and only when it also binds a + `verification_receipt` for that exact request, so a scan or a preview cannot + represent completion authority at all. Supporting scans stay isolated — + `verify`'s internal head scan does not take over the PR's control identity, + and `baseline save` already scanned into a temporary directory. Contract + `19 → 20` adds `current_control_schema_version`, `current_control_artifact`, + the `agent_refresh_triggers[]` list of boundaries at which a cached control + state expires, and `current_control_fallback_read_order[]` for consumers built + before the pointer existed; `agent_read_order[]` now starts at the pointer, + and the local downstream contract moves `7 → 8`. Generated agent instructions + and both adoption kits now require the refresh. No report, packet, verifier, + handoff, or receipt schema changed. ([#339](https://github.com/ThreeMoonsLab/agents-shipgate/issues/339)) + - **An unreadable PR diff is no longer reported as "nothing here is agent-related."** `verify --preview` collapsed every diff-acquisition failure into one message, then evaluated the trigger catalog against the empty inputs diff --git a/README.md b/README.md index 07d278b7..a1bbbb5c 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,10 @@ The release gate is `agents-shipgate-reports/report.json` → The PR/control surface is `agents-shipgate-reports/verifier.json` → `merge_verdict` (`mergeable | human_review_required | insufficient_evidence | blocked | unknown`), a deterministic projection of the release decision. -Validate `verification-receipt.json` first; then read `agent-handoff.json` +Read `agents-shipgate-reports/current-control.json` first — it names which run +is current, and `agents-shipgate agent control` validates it against every +artifact it binds. Then validate the `verification-receipt.json` it binds; then +read `agent-handoff.json` (`control.state`, then `gate.merge_verdict`), followed by the authoritative control substrate `verifier.json` for `control`, `merge_verdict`, `applicability`, `can_merge_without_human`, diff --git a/STABILITY.md b/STABILITY.md index deec681e..a3e5b840 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -654,7 +654,19 @@ Stable JSON fields: - `agent_handoff_artifact` — default emitted handoff artifact path. - `codex_boundary_result_schema_version` — schema version emitted by `shipgate check --format codex-boundary-json`. +- `current_control_schema_version` / `current_control_schema_path` / + `current_control_artifact` — schema version, checked-in JSON Schema path, and + default artifact path for `agents-shipgate-reports/current-control.json`, the + one atomic entry point naming the control identity that is current. +- `agent_refresh_triggers[]` — the boundaries at which a consumer must re-read + `current_control_artifact` before acting. A control state cached across any of + them is not authority. +- `current_control_fallback_read_order[]` — documented read order for consumers + built before the pointer existed. Its absence is evidence of an older + producer, never permission to act on a cached decision. - `agent_read_order[]` — cross-artifact machine read order for coding agents: + `current-control.json` first (`current_control_id`, `lifecycle_state`, + `control.state`), then `verification-receipt.json`, `agent-handoff.json.control.state`, then `verifier.json.control.state`, `verify-run.json`, then `report.json.release_decision.decision`. diff --git a/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json b/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json index bfc73f6a..592214c5 100644 --- a/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json +++ b/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json @@ -37,7 +37,8 @@ "98ba22d7518ae4635ed109fd187323da0541281061dd4f259ac7fdb950c7b185", "02e780f5a1506d948e4c1d77f6ee4c6b4193227a4fd2ced081847d1fb2e5fbd0", "bc5cd31a5c4d4f6a1ebf6a04db3f80480e7cc5f9ab2b7a6f7e3f62e8ddfc3937", - "58ea3b6bba89078ec54d6b5493ffebf9250d9619fbacef5090285b009e58cdcd" + "58ea3b6bba89078ec54d6b5493ffebf9250d9619fbacef5090285b009e58cdcd", + "f6771ad16589854d51604cf35af0d78022b6a28e5ffa97a1d0c429ee6b6107da" ], "prompts/add-shipgate-to-repo.md": [ "ea3c37cfbbd42c40d164abfe21d468a3a5550d5384125f94a53c947dea6b4b2a", diff --git a/adoption-kits/claude-code-skill/SKILL.md b/adoption-kits/claude-code-skill/SKILL.md index 93c8815e..facedb67 100644 --- a/adoption-kits/claude-code-skill/SKILL.md +++ b/adoption-kits/claude-code-skill/SKILL.md @@ -52,7 +52,14 @@ Always: `control.state`, `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, and `capability_review.top_changes`. Then parse `agents-shipgate-reports/report.json.release_decision.decision`; it is the - release gate. + release gate. Refresh `agents-shipgate-reports/current-control.json` with + `agents-shipgate agent control` before you act on any of that, and again + before enforcing a cached `must_stop`, before commit/push/PR update, before + merge, and before declaring the task complete. A non-zero exit means no + control identity is current and you hold no authority; if + `current_control_id` changed, discard every cached control state and restart + from the new identity. A result remembered from earlier in the conversation + never outranks the current pointer, in either direction. 4. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. 5. Before finishing an agent-related diff, run `shipgate check --agent claude-code --workspace . --format agent-boundary-json`. For committed PR/CI verification, run `agents-shipgate verify --workspace . --config shipgate.yaml --base origin/main --head HEAD --ci-mode advisory --format json` after making the base ref available. `verify` never fetches. For host grants, run `shipgate audit --host --json --out agents-shipgate-reports/host-grants.json`. 6. Do not bypass the verifier by suppressing findings, lowering severity, expanding baselines or waivers, removing Shipgate CI, or weakening agent instructions; verify-mode `SHIP-VERIFY-*` checks make those trust-root edits release-visible. @@ -75,6 +82,7 @@ For non-GitHub CI (GitLab, CircleCI, Jenkins, Azure Pipelines, Buildkite, Bitbuc - **CLI surface** follows the current 0.x contract line — see https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/STABILITY.md. - **Installed CLI contract**: when available, run `agents-shipgate contract --json` to verify local schema versions, capability/research surfaces, `release_decision.decision`, and manual-review signal fields. Older installs should use [`docs/agent-contract-current.md`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md) or upgrade before automating against the local contract command. - **Verifier JSON**: `verifier_schema_version: "0.7"`. Switch on `control.state`, then read `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, `capability_review.top_changes`, `trust_root_touched`, and `policy_weakened` before summarizing an AI-generated PR. `merge_verdict` is a deterministic projection; the gate remains `report.json.release_decision.decision`. Check `diff_status.completeness` before you believe any negative result: only `"complete"` means the PR diff was actually read. Anything else (`reason` is one of `not_attempted`, `refs_missing`, `merge_base_missing`, `unrelated_histories`, `objects_missing`, `metadata_limit_exceeded`, `body_limit_exceeded`, `git_timeout`, `git_failed`) means evidence was missing: follow `remediation`, and never report the PR as unrelated to agent capabilities. Then read `trigger.evaluation_status` for what that cost. `"not_evaluated"` (with `trigger.should_run` `null`) means no verdict exists. `"evaluated"` on an incomplete diff is not a contradiction — evidence that did not depend on the missing bytes already proved Shipgate should run — so honor `should_run: true` instead of overriding it, and still recover the diff before trusting a merge verdict. That evidence is either a rule matched on the change set or, in an already-adopted repository, `force_run: true` from the manifest alone; check `matched_rules` before attributing the verdict to anything the diff showed. +- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt`; a `scan` or `preview` pointer never authorizes completion or merge. - **Verification receipt**: `verification-receipt.json` uses `schema_version: "shipgate.verification_receipt/v1"` and is written last. Validate it before trusting any projected verdict; it content-addresses the request, executor, unit result, decision, and complete artifact set. - **Verify run JSON**: `verify-run.json` uses `schema_version: "shipgate.verify_run/v3"`, embeds the content-addressed plan and executor, and binds unit-result and decision IDs. `run_id` is an exact compatibility alias of `request_id`; do not treat the run projection as a second gate. - **Report JSON**: `report_schema_version: "0.34"`. Read `release_decision.decision` first. A `passed` decision requires a complete root-reachable static binding graph plus complete, conflict-free identity, effect, and authority evidence for every reachable action; it does not prove runtime behavior. Preserve `release_decision.static_analysis_only=true`, `runtime_behavior_verified=false`, and `static_verdict_disclaimer` in summaries. Read `release_decision.evidence_coverage.binding_coverage`, `semantic_coverage`, `identity_coverage`, and `policy_gap_count`, then work every `evidence_gaps[].next_action` in order. Binding, semantic, and policy-applicability gaps are not Findings and cannot be suppressed, baselined, severity-overridden, cleared by `--no-heuristics`, or satisfied by `human_ack`; binding, effect, and authority declarations are human assertions and must never be auto-written. Use `tool_catalog[]` for diagnostics and `tool_inventory[]` for the proven reachable surface. The current schema is [`docs/report-schema.v0.34.json`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/report-schema.v0.34.json); v0.33 is a frozen compatibility reference. See the [current agent contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md), [verification identity contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/verification-reproducibility.md), and [evidence-backed passed contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/passed-verdict-contract.md). diff --git a/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json b/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json index 955ec5b5..6f98a9b0 100644 --- a/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json +++ b/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json @@ -19,7 +19,8 @@ "37795c6ffc3dcdda624b609dd17da8656c245d00ea9cebb9fae25c50842a4a9b", "a8dd8e22d9a9dd3358f9d7d328f6f5da5d80ac142681dfdb28b96b44bc68004b", "ad6ca3c53872f1d7e2d8a42794a766f51f0c50a8b4599bb3b76ddd2e31260af1", - "4cbd6a9b978bb142908b8601f52fa1b8fe6a0aa89ecd05eec28f3b00f470ff04" + "4cbd6a9b978bb142908b8601f52fa1b8fe6a0aa89ecd05eec28f3b00f470ff04", + "10fcba81a9d07d7f1736fd3177c162dfdc92d5a106469c3def907d233a270dd4" ], "references/recipes.md": [ "df5110bfa05eeabd9b918d8902b5c054fa547d1155be61ef6e7d7d63378bf210", @@ -43,7 +44,8 @@ "3e7bd6a3a882f5e52c0fc4f215c5589149f8eb24eeef0ea054854f03f0f050de", "6d2848f3436f6e246bf553e6cf061c990888d6ff39eb82fec9a41f291b2e94fe", "7baeb0715e59daf92cf78fb9fee6e4f659174f5fcb6c9a1e3eca9f73fc697503", - "b652d59a846ccf131df71cf284e10f2e5a72c6c1da6d5454067f1a6a457452d5" + "b652d59a846ccf131df71cf284e10f2e5a72c6c1da6d5454067f1a6a457452d5", + "a5dd0eec215e973c403a22bdbbf0eca76a1796e20be5ae9623829c9d11d2a8dc" ], "agents/openai.yaml": [ "4d94a724336e5d36a2769630495f341007580e4dee306bc42a1aeca1af9e867b" diff --git a/adoption-kits/codex-skill/SKILL.md b/adoption-kits/codex-skill/SKILL.md index a34b43c0..fb404a2b 100644 --- a/adoption-kits/codex-skill/SKILL.md +++ b/adoption-kits/codex-skill/SKILL.md @@ -20,7 +20,7 @@ Do not use it for general linting, runtime monitoring, evals, model-output quali 5. Default first-time CI to advisory mode. Do not enable release-blocking CI or save a baseline until a human has reviewed current findings. 6. For local agent control, run `shipgate check --agent codex --workspace . --format agent-boundary-json` and read the stdout `shipgate.agent_boundary_result/v1` object. Switch on `control.state`; follow only `control.next_action`, `control.allowed_next_commands`, and `control.human_review`. Treat `decision` as diagnostic context only. 7. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. -8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. +8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control` before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. 9. Auto-apply only high-confidence safe patches. Do not auto-assert action effect, action authority, agent bindings, approval, confirmation, idempotency, broad-scope, prohibited-action, or runtime-trace evidence. 10. Ensure `.gitignore` covers `agents-shipgate-reports/` before committing. diff --git a/adoption-kits/codex-skill/references/report-reading.md b/adoption-kits/codex-skill/references/report-reading.md index 87c649eb..95110ddd 100644 --- a/adoption-kits/codex-skill/references/report-reading.md +++ b/adoption-kits/codex-skill/references/report-reading.md @@ -1,12 +1,16 @@ # Reading Agents Shipgate Reports -For verify runs, validate `agents-shipgate-reports/verification-receipt.json` -first. Then read `agents-shipgate-reports/agent-handoff.json`. After that, +For verify runs, read `agents-shipgate-reports/current-control.json` first — +via `agents-shipgate agent control` — because it names which run is current and +a non-zero exit means none is. Then validate the +`agents-shipgate-reports/verification-receipt.json` it binds. Then read +`agents-shipgate-reports/agent-handoff.json`. After that, read `agents-shipgate-reports/verifier.json` for detailed control context and `agents-shipgate-reports/report.json` for findings. Do not scrape Markdown. ## Order +0. `current-control.json.current_control_id` / `lifecycle_state` / `control.state`: which run is current, and whether any decision is. Re-read this before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete; if the id changed, discard cached control state and start again from the new identity. 1. `agent-handoff.json.control.state`: `complete`, `agent_action_required`, or `human_review_required`. 2. `agent-handoff.json.capability_review.top_changes[]`: the highest-signal tool/action or trust-root changes. 3. `agent-handoff.json.next_action` / `control.next_action` / `fix_task`: who acts next and whether a coding agent may safely attempt the fix. diff --git a/docs/INDEX.md b/docs/INDEX.md index 73a5e110..e020b0b8 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -66,6 +66,7 @@ A single entry point for human readers and AI agents walking the `docs/` tree. - [`verification-unit-result-schema.v1.json`](verification-unit-result-schema.v1.json) — decision-free worker result contract - [`verification-artifact-manifest-schema.v1.json`](verification-artifact-manifest-schema.v1.json) — content-addressed terminal artifact set - [`verification-receipt-schema.v1.json`](verification-receipt-schema.v1.json) — terminal request, decision, executor, and artifact closure +- [`current-control-schema.v1.json`](current-control-schema.v1.json) — the atomic pointer naming which control identity is current - [`agent-handoff-schema.v4.json`](agent-handoff-schema.v4.json) — frozen handoff v4 reference - [`agent-handoff-schema.v3.json`](agent-handoff-schema.v3.json) — frozen handoff v3 reference - [`agent-handoff-schema.v2.json`](agent-handoff-schema.v2.json) — frozen handoff v2 reference diff --git a/docs/agent-contract-current.md b/docs/agent-contract-current.md index 472be454..e340c292 100644 --- a/docs/agent-contract-current.md +++ b/docs/agent-contract-current.md @@ -10,6 +10,20 @@ Verify the installed CLI contract locally before relying on hard-coded docs: agents-shipgate contract --json ``` +Runtime contract v20 adds `agents-shipgate-reports/current-control.json`, +the one atomic entry point that says which control identity is current. It is a +pointer, not a second decision: it binds identities and hashes the receipt, +handoff, verifier, and report already published. Every run replaces it with a +non-terminal `unavailable` marker before touching any other artifact, and +publishes the terminal pointer atomically, last. Consumers must re-read it at +every boundary in `agent_refresh_triggers` — after any human or external-tool +action, after any worktree change, after any command returns, before enforcing a +cached `must_stop`, before commit/push/PR update, before merge, and before +declaring the task complete. A control state remembered from earlier in a +conversation never outranks the pointer, in either direction: it can neither +keep blocking after a newer complete run exists, nor authorize action after the +workspace moved. + Runtime contract v19 grades the LOCAL boundary stop: a `require_review` violation set that is entirely low/medium risk projects `control.state: "agent_action_required"` with the exact verify command, and @@ -73,19 +87,20 @@ Downstream repos generated with - Latest release: `v0.15.0` - In-tree runtime: `0.16.0b7` — see [pyproject.toml](../pyproject.toml) -- Runtime contract: `19` (minimum control contract: `14`) +- Runtime contract: `20` (minimum control contract: `14`) - Current report schema: `0.34` — [`docs/report-schema.v0.34.json`](report-schema.v0.34.json) - Current packet schema: `0.12` — [`docs/packet-schema.v0.12.json`](packet-schema.v0.12.json) - Current shared agent result schema: `agent_result_v2` — [`docs/agent-result-schema.v2.json`](agent-result-schema.v2.json) - Current verifier schema: `0.7` — [`docs/verifier-schema.v0.7.json`](verifier-schema.v0.7.json) - Current verify-run schema: `shipgate.verify_run/v3` — [`docs/verify-run-schema.v3.json`](verify-run-schema.v3.json) - Current verification identity schemas: [`plan v1`](verification-plan-schema.v1.json), [`unit result v1`](verification-unit-result-schema.v1.json), [`artifact manifest v1`](verification-artifact-manifest-schema.v1.json), and [`terminal receipt v1`](verification-receipt-schema.v1.json) +- Current control pointer schema: `shipgate.current_control/v1` — [`docs/current-control-schema.v1.json`](current-control-schema.v1.json) - Current human-authorization schemas: request, signed grant, verifier evaluation, and external trust policy v1 — [`docs/human-authorization-schema.v1.json`](human-authorization-schema.v1.json) - Current agent handoff schema: `shipgate.agent_handoff/v6` — [`docs/agent-handoff-schema.v6.json`](agent-handoff-schema.v6.json) - Current agent boundary result schema: `shipgate.agent_boundary_result/v1` — [`docs/agent-boundary-result-schema.v1.json`](agent-boundary-result-schema.v1.json) - Frozen deprecated Codex projection: `shipgate.codex_boundary_result/v2` — [`docs/codex-boundary-result-schema.v2.json`](codex-boundary-result-schema.v2.json) - Current preflight schema: `0.3` — [`docs/preflight-schema.v0.3.json`](preflight-schema.v0.3.json) -- Current downstream local agent contract schema: `7` +- Current downstream local agent contract schema: `8` - Current capability standard: `0.5` — [`docs/capability-standard.md`](capability-standard.md) - Current capability lock schema: `0.6` — [`docs/capability-lock-schema.v0.6.json`](capability-lock-schema.v0.6.json) - Current capability lock diff schema: `0.7` — [`docs/capability-lock-diff-schema.v0.7.json`](capability-lock-diff-schema.v0.7.json) @@ -104,9 +119,15 @@ Downstream repos generated with ## Two read entry points -There are two correct "read first" paths; which one applies depends on who is -reading. They are not two decisions — they are two entry points into the same -one decision engine. +Both start at `agents-shipgate-reports/current-control.json` (`agents-shipgate +agent control`), which names the run that is current. Everything below it +describes *a* run; only the pointer says *which* run. A non-zero exit from the +reader means no control identity is current here and the caller holds no +authority — not that the previous answer still stands. + +Given a current pointer, there are two correct "read first" paths; which one +applies depends on who is reading. They are not two decisions — they are two +entry points into the same one decision engine. - **PR / controller flow** — an autonomous coding agent deciding *continue, repair, or stop*. Prefer @@ -141,6 +162,19 @@ filenames already present in the output directory: above. The receipt and handoff retain the content-addressed identity of that exact verify run. +`current-control.json` records which of those two just happened in its +`operation` field, so the choice does not have to be inferred from filenames at +all. Only an `operation: "verify"` pointer can carry `control.state: +"complete"`, and only when it also binds a `verification_receipt` for that exact +request; a `scan` or `preview` pointer is structurally incapable of authorizing +completion or merge. While a run is in flight the pointer reads +`lifecycle_state: "in_progress"` with `control.state: "unavailable"`, +`must_stop: true`, so an interrupted or crashed run leaves a directory that +denies cached control rather than one that still authorizes it. Consumers built +before the pointer existed fall back through +`current_control_fallback_read_order`; the pointer's absence is evidence of an +older producer, never permission. + When standalone `scan` replaces a report set in the same output directory, it removes the complete prior verifier route and its identity support: `verifier.json`, `agent-handoff.json`, `pr-comment.md`, `verify-run.json`, diff --git a/docs/architecture.md b/docs/architecture.md index 3ee0adbc..da1c25e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ A single-page summary of the `agents-shipgate` codebase for new contributors and AI coding agents extending the project. Current as of 2026-07-13; auto-checked against `agents-shipgate contract --json`: -runtime contract `19`, report schema `v0.34`, packet schema `v0.12`. +runtime contract `20`, report schema `v0.34`, packet schema `v0.12`. For the per-field stability contract, see [`../STABILITY.md`](../STABILITY.md). For the agent-facing field index, diff --git a/docs/current-control-schema.v1.json b/docs/current-control-schema.v1.json new file mode 100644 index 00000000..4fb1577d --- /dev/null +++ b/docs/current-control-schema.v1.json @@ -0,0 +1,395 @@ +{ + "$defs": { + "AgentActionRequiredCurrentControl": { + "additionalProperties": false, + "description": "The referenced run leaves one coding-agent-owned step outstanding.", + "properties": { + "completion_allowed": { + "const": false, + "default": false, + "title": "Completion Allowed", + "type": "boolean" + }, + "must_stop": { + "const": false, + "default": false, + "title": "Must Stop", + "type": "boolean" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "agent_action_required", + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop" + ], + "title": "AgentActionRequiredCurrentControl", + "type": "object" + }, + "CompleteCurrentControl": { + "additionalProperties": false, + "description": "The referenced run authorizes reporting the task complete.", + "properties": { + "completion_allowed": { + "const": true, + "default": true, + "title": "Completion Allowed", + "type": "boolean" + }, + "must_stop": { + "const": false, + "default": false, + "title": "Must Stop", + "type": "boolean" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "complete", + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop" + ], + "title": "CompleteCurrentControl", + "type": "object" + }, + "CurrentControlArtifactRef": { + "additionalProperties": false, + "description": "A hash-bound reference to one artifact beside the pointer.", + "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + }, + "size_bytes": { + "minimum": 0, + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "path", + "sha256", + "size_bytes" + ], + "title": "CurrentControlArtifactRef", + "type": "object" + }, + "CurrentControlProjection": { + "discriminator": { + "mapping": { + "agent_action_required": "#/$defs/AgentActionRequiredCurrentControl", + "complete": "#/$defs/CompleteCurrentControl", + "human_review_required": "#/$defs/HumanReviewRequiredCurrentControl", + "unavailable": "#/$defs/UnavailableCurrentControl" + }, + "propertyName": "state" + }, + "oneOf": [ + { + "$ref": "#/$defs/UnavailableCurrentControl" + }, + { + "$ref": "#/$defs/CompleteCurrentControl" + }, + { + "$ref": "#/$defs/AgentActionRequiredCurrentControl" + }, + { + "$ref": "#/$defs/HumanReviewRequiredCurrentControl" + } + ] + }, + "CurrentControlWorkspaceIdentity": { + "additionalProperties": false, + "description": "What the pointer was published against.\n\nEvery field is nullable because the pointer is published by commands that\nresolve different amounts of identity \u2014 an in-progress marker knows only\nthe repository, a preview outside Git knows nothing. A consumer treats a\n``null`` as \"not bound\", never as \"unchanged\".", + "properties": { + "head_commit_sha": { + "anyOf": [ + { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Head Commit Sha" + }, + "head_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Head Ref" + }, + "head_tree_sha": { + "anyOf": [ + { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Head Tree Sha" + }, + "policy_snapshot_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Policy Snapshot Sha256" + }, + "repository": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repository" + }, + "snapshot_kind": { + "anyOf": [ + { + "enum": [ + "committed_tree", + "worktree_overlay" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Snapshot Kind" + }, + "worktree_overlay_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Worktree Overlay Sha256" + } + }, + "title": "CurrentControlWorkspaceIdentity", + "type": "object" + }, + "HumanReviewRequiredCurrentControl": { + "additionalProperties": false, + "description": "The referenced run stops the coding agent pending human review.", + "properties": { + "completion_allowed": { + "const": false, + "default": false, + "title": "Completion Allowed", + "type": "boolean" + }, + "must_stop": { + "const": true, + "default": true, + "title": "Must Stop", + "type": "boolean" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "human_review_required", + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop" + ], + "title": "HumanReviewRequiredCurrentControl", + "type": "object" + }, + "UnavailableCurrentControl": { + "additionalProperties": false, + "description": "A lifecycle run is in flight; no decision in this directory is current.", + "properties": { + "completion_allowed": { + "const": false, + "default": false, + "title": "Completion Allowed", + "type": "boolean" + }, + "must_stop": { + "const": true, + "default": true, + "title": "Must Stop", + "type": "boolean" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "unavailable", + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop" + ], + "title": "UnavailableCurrentControl", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/current-control-schema.v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "JSON Schema for agents-shipgate-reports/current-control.json, the one atomic entry point naming the control identity that is current now. Generated from agents_shipgate.schemas.current_control.CurrentControlPointer. Do not edit by hand.", + "properties": { + "artifacts": { + "additionalProperties": { + "$ref": "#/$defs/CurrentControlArtifactRef" + }, + "title": "Artifacts", + "type": "object" + }, + "control": { + "$ref": "#/$defs/CurrentControlProjection" + }, + "current_control_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Current Control Id", + "type": "string" + }, + "decision_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decision Id" + }, + "lifecycle_state": { + "enum": [ + "in_progress", + "terminal" + ], + "title": "Lifecycle State", + "type": "string" + }, + "operation": { + "enum": [ + "verify", + "preview", + "scan" + ], + "title": "Operation", + "type": "string" + }, + "request_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Id" + }, + "schema_version": { + "const": "shipgate.current_control/v1", + "default": "shipgate.current_control/v1", + "title": "Schema Version", + "type": "string" + }, + "supersedes": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Supersedes" + }, + "workspace_identity": { + "$ref": "#/$defs/CurrentControlWorkspaceIdentity" + } + }, + "required": [ + "current_control_id", + "operation", + "lifecycle_state", + "workspace_identity", + "control" + ], + "title": "Agents Shipgate Current Control Pointer v1", + "type": "object" +} diff --git a/docs/target-repo-agent-snippets.md b/docs/target-repo-agent-snippets.md index 586adc3d..dfedf2e9 100644 --- a/docs/target-repo-agent-snippets.md +++ b/docs/target-repo-agent-snippets.md @@ -85,6 +85,17 @@ release gate. Legacy `agent-result.json` surfaces, where present, are supporting/provisional projections and not the CI gate. +`agents-shipgate-reports/current-control.json` is the one entry point that +says which control identity is current. Read it with `agents-shipgate agent +control`; a non-zero exit means nothing is current here and you hold no +authority. Re-read it after any human or external-tool action, after commit, +rebase, checkout, pull, or any worktree change, after any agents-shipgate +command returns, before enforcing a cached `must_stop`, before commit/push/PR +update, before merge or release, and before declaring the task complete. If +`current_control_id` changed, discard every cached control state and restart +from the new identity. A result you remember from earlier in this conversation +never outranks the current pointer — in either direction. + For coding-agent host grants, run `shipgate audit --host` and read the emitted host-grants inventory before changing MCP servers, permission rules, hooks, or workflow scopes. @@ -193,6 +204,17 @@ release gate. Legacy `agent-result.json` surfaces, where present, are supporting/provisional projections and not the CI gate. +`agents-shipgate-reports/current-control.json` is the one entry point that +says which control identity is current. Read it with `agents-shipgate agent +control`; a non-zero exit means nothing is current here and you hold no +authority. Re-read it after any human or external-tool action, after commit, +rebase, checkout, pull, or any worktree change, after any agents-shipgate +command returns, before enforcing a cached `must_stop`, before commit/push/PR +update, before merge or release, and before declaring the task complete. If +`current_control_id` changed, discard every cached control state and restart +from the new identity. A result you remember from earlier in this conversation +never outranks the current pointer — in either direction. + For coding-agent host grants, run `shipgate audit --host` and read the emitted host-grants inventory before changing MCP servers, permission rules, hooks, or workflow scopes. @@ -279,6 +301,17 @@ release gate. Legacy `agent-result.json` surfaces, where present, are supporting/provisional projections and not the CI gate. +`agents-shipgate-reports/current-control.json` is the one entry point that +says which control identity is current. Read it with `agents-shipgate agent +control`; a non-zero exit means nothing is current here and you hold no +authority. Re-read it after any human or external-tool action, after commit, +rebase, checkout, pull, or any worktree change, after any agents-shipgate +command returns, before enforcing a cached `must_stop`, before commit/push/PR +update, before merge or release, and before declaring the task complete. If +`current_control_id` changed, discard every cached control state and restart +from the new identity. A result you remember from earlier in this conversation +never outranks the current pointer — in either direction. + For coding-agent host grants, run: shipgate audit --host --json --out agents-shipgate-reports/host-grants.json diff --git a/llms-full.txt b/llms-full.txt index c5096ea5..de8b570a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -155,8 +155,10 @@ agents-shipgate verify --workspace . --config shipgate.yaml \ ``` For local uncommitted verifier work, omit `--base`/`--head` so the working tree -is scanned. Validate `agents-shipgate-reports/verification-receipt.json` -first, then read `agents-shipgate-reports/agent-handoff.json` and lead +is scanned. Read `agents-shipgate-reports/current-control.json` +first — it names which run is current — then validate the +`verification-receipt.json` it binds, then read +`agents-shipgate-reports/agent-handoff.json` and lead with `control.state`, then `gate.merge_verdict` (`mergeable | human_review_required | insufficient_evidence | blocked | unknown`), `gate.can_merge_without_human`, `next_action`, @@ -170,6 +172,24 @@ the release gate. Do not report completion unless `control.state` is changes the control state; conversation-level acceptance is not a gate override. +Read the pointer with: + +```bash +agents-shipgate agent control --reports-dir agents-shipgate-reports +``` + +A zero exit means the printed pointer was validated against every artifact it +binds and did not move while it was read. A non-zero exit means no control +identity is current here — you hold no authority, and a remembered result does +not substitute for one. Re-read it after any human or external-tool action, +after commit, rebase, checkout, pull, or any worktree change, after any +agents-shipgate command returns, before enforcing a cached `must_stop`, before +commit/push/PR update, before merge or release, and before declaring the task +complete. If `current_control_id` changed, discard every cached control state +and restart from the new identity. This runs in both directions: a cached stop +must not survive a newer complete run, and a cached completion must not survive +a changed workspace. + Do not bypass the verifier by suppressing findings, lowering severity, expanding baselines or waivers, removing Shipgate CI, or weakening agent instructions. Verify-mode `SHIP-VERIFY-*` checks make those trust-root edits @@ -1034,6 +1054,20 @@ Verify the installed CLI contract locally before relying on hard-coded docs: agents-shipgate contract --json ``` +Runtime contract v20 adds `agents-shipgate-reports/current-control.json`, +the one atomic entry point that says which control identity is current. It is a +pointer, not a second decision: it binds identities and hashes the receipt, +handoff, verifier, and report already published. Every run replaces it with a +non-terminal `unavailable` marker before touching any other artifact, and +publishes the terminal pointer atomically, last. Consumers must re-read it at +every boundary in `agent_refresh_triggers` — after any human or external-tool +action, after any worktree change, after any command returns, before enforcing a +cached `must_stop`, before commit/push/PR update, before merge, and before +declaring the task complete. A control state remembered from earlier in a +conversation never outranks the pointer, in either direction: it can neither +keep blocking after a newer complete run exists, nor authorize action after the +workspace moved. + Runtime contract v19 grades the LOCAL boundary stop: a `require_review` violation set that is entirely low/medium risk projects `control.state: "agent_action_required"` with the exact verify command, and @@ -1097,19 +1131,20 @@ Downstream repos generated with - Latest release: `v0.15.0` - In-tree runtime: `0.16.0b7` — see [pyproject.toml](../pyproject.toml) -- Runtime contract: `19` (minimum control contract: `14`) +- Runtime contract: `20` (minimum control contract: `14`) - Current report schema: `0.34` — [`docs/report-schema.v0.34.json`](report-schema.v0.34.json) - Current packet schema: `0.12` — [`docs/packet-schema.v0.12.json`](packet-schema.v0.12.json) - Current shared agent result schema: `agent_result_v2` — [`docs/agent-result-schema.v2.json`](agent-result-schema.v2.json) - Current verifier schema: `0.7` — [`docs/verifier-schema.v0.7.json`](verifier-schema.v0.7.json) - Current verify-run schema: `shipgate.verify_run/v3` — [`docs/verify-run-schema.v3.json`](verify-run-schema.v3.json) - Current verification identity schemas: [`plan v1`](verification-plan-schema.v1.json), [`unit result v1`](verification-unit-result-schema.v1.json), [`artifact manifest v1`](verification-artifact-manifest-schema.v1.json), and [`terminal receipt v1`](verification-receipt-schema.v1.json) +- Current control pointer schema: `shipgate.current_control/v1` — [`docs/current-control-schema.v1.json`](current-control-schema.v1.json) - Current human-authorization schemas: request, signed grant, verifier evaluation, and external trust policy v1 — [`docs/human-authorization-schema.v1.json`](human-authorization-schema.v1.json) - Current agent handoff schema: `shipgate.agent_handoff/v6` — [`docs/agent-handoff-schema.v6.json`](agent-handoff-schema.v6.json) - Current agent boundary result schema: `shipgate.agent_boundary_result/v1` — [`docs/agent-boundary-result-schema.v1.json`](agent-boundary-result-schema.v1.json) - Frozen deprecated Codex projection: `shipgate.codex_boundary_result/v2` — [`docs/codex-boundary-result-schema.v2.json`](codex-boundary-result-schema.v2.json) - Current preflight schema: `0.3` — [`docs/preflight-schema.v0.3.json`](preflight-schema.v0.3.json) -- Current downstream local agent contract schema: `7` +- Current downstream local agent contract schema: `8` - Current capability standard: `0.5` — [`docs/capability-standard.md`](capability-standard.md) - Current capability lock schema: `0.6` — [`docs/capability-lock-schema.v0.6.json`](capability-lock-schema.v0.6.json) - Current capability lock diff schema: `0.7` — [`docs/capability-lock-diff-schema.v0.7.json`](capability-lock-diff-schema.v0.7.json) @@ -1128,9 +1163,15 @@ Downstream repos generated with ## Two read entry points -There are two correct "read first" paths; which one applies depends on who is -reading. They are not two decisions — they are two entry points into the same -one decision engine. +Both start at `agents-shipgate-reports/current-control.json` (`agents-shipgate +agent control`), which names the run that is current. Everything below it +describes *a* run; only the pointer says *which* run. A non-zero exit from the +reader means no control identity is current here and the caller holds no +authority — not that the previous answer still stands. + +Given a current pointer, there are two correct "read first" paths; which one +applies depends on who is reading. They are not two decisions — they are two +entry points into the same one decision engine. - **PR / controller flow** — an autonomous coding agent deciding *continue, repair, or stop*. Prefer @@ -1165,6 +1206,19 @@ filenames already present in the output directory: above. The receipt and handoff retain the content-addressed identity of that exact verify run. +`current-control.json` records which of those two just happened in its +`operation` field, so the choice does not have to be inferred from filenames at +all. Only an `operation: "verify"` pointer can carry `control.state: +"complete"`, and only when it also binds a `verification_receipt` for that exact +request; a `scan` or `preview` pointer is structurally incapable of authorizing +completion or merge. While a run is in flight the pointer reads +`lifecycle_state: "in_progress"` with `control.state: "unavailable"`, +`must_stop: true`, so an interrupted or crashed run leaves a directory that +denies cached control rather than one that still authorizes it. Consumers built +before the pointer existed fall back through +`current_control_fallback_read_order`; the pointer's absence is evidence of an +older producer, never permission. + When standalone `scan` replaces a report set in the same output directory, it removes the complete prior verifier route and its identity support: `verifier.json`, `agent-handoff.json`, `pr-comment.md`, `verify-run.json`, diff --git a/llms.txt b/llms.txt index 453b0fb6..ba550364 100644 --- a/llms.txt +++ b/llms.txt @@ -102,7 +102,7 @@ - Before editing protected surfaces, run `agents-shipgate preflight --workspace . --plan - --json` with a `PreflightPlanV1` object; stop when `requires_human_review` is true. - Configure the minimal preview route: `agents-shipgate init --workspace . --write --json`. CI and agent-instruction trust roots are separate reviewed changes. - Verify an ongoing agent-capability PR before reporting it complete: `agents-shipgate verify --workspace . --config shipgate.yaml --base origin/main --head HEAD --ci-mode advisory --format json`. -- Validate `verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `gate.merge_verdict`, `gate.can_merge_without_human`, `control.next_action`, `fix_task`, and `capability_review.top_changes`. The unified control model requires runtime contract 14. Then read `report.json.release_decision.decision`; it is the release gate. +- Read `agents-shipgate-reports/current-control.json` first (via `agents-shipgate agent control`); it names which run is current, and a non-zero exit means none is. Then validate the `verification-receipt.json` it binds, then read `agent-handoff.json` and switch on `control.state`; read `gate.merge_verdict`, `gate.can_merge_without_human`, `control.next_action`, `fix_task`, and `capability_review.top_changes`. The unified control model requires runtime contract 14. Then read `report.json.release_decision.decision`; it is the release gate. - Run the verify-native demo fixture: `agents-shipgate fixture run ai_generated_refund_pr`. - Scan a static repo/report fixture when you need the lower-level report path: `agents-shipgate fixture run support_refund_agent`. - Export a stable static capability lock: `agents-shipgate capability export -c shipgate.yaml`. diff --git a/plugins/agents-shipgate/skills/agents-shipgate/SKILL.md b/plugins/agents-shipgate/skills/agents-shipgate/SKILL.md index a34b43c0..fb404a2b 100644 --- a/plugins/agents-shipgate/skills/agents-shipgate/SKILL.md +++ b/plugins/agents-shipgate/skills/agents-shipgate/SKILL.md @@ -20,7 +20,7 @@ Do not use it for general linting, runtime monitoring, evals, model-output quali 5. Default first-time CI to advisory mode. Do not enable release-blocking CI or save a baseline until a human has reviewed current findings. 6. For local agent control, run `shipgate check --agent codex --workspace . --format agent-boundary-json` and read the stdout `shipgate.agent_boundary_result/v1` object. Switch on `control.state`; follow only `control.next_action`, `control.allowed_next_commands`, and `control.human_review`. Treat `decision` as diagnostic context only. 7. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. -8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. +8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control` before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. 9. Auto-apply only high-confidence safe patches. Do not auto-assert action effect, action authority, agent bindings, approval, confirmation, idempotency, broad-scope, prohibited-action, or runtime-trace evidence. 10. Ensure `.gitignore` covers `agents-shipgate-reports/` before committing. diff --git a/plugins/agents-shipgate/skills/agents-shipgate/references/report-reading.md b/plugins/agents-shipgate/skills/agents-shipgate/references/report-reading.md index 87c649eb..95110ddd 100644 --- a/plugins/agents-shipgate/skills/agents-shipgate/references/report-reading.md +++ b/plugins/agents-shipgate/skills/agents-shipgate/references/report-reading.md @@ -1,12 +1,16 @@ # Reading Agents Shipgate Reports -For verify runs, validate `agents-shipgate-reports/verification-receipt.json` -first. Then read `agents-shipgate-reports/agent-handoff.json`. After that, +For verify runs, read `agents-shipgate-reports/current-control.json` first — +via `agents-shipgate agent control` — because it names which run is current and +a non-zero exit means none is. Then validate the +`agents-shipgate-reports/verification-receipt.json` it binds. Then read +`agents-shipgate-reports/agent-handoff.json`. After that, read `agents-shipgate-reports/verifier.json` for detailed control context and `agents-shipgate-reports/report.json` for findings. Do not scrape Markdown. ## Order +0. `current-control.json.current_control_id` / `lifecycle_state` / `control.state`: which run is current, and whether any decision is. Re-read this before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete; if the id changed, discard cached control state and start again from the new identity. 1. `agent-handoff.json.control.state`: `complete`, `agent_action_required`, or `human_review_required`. 2. `agent-handoff.json.capability_review.top_changes[]`: the highest-signal tool/action or trust-root changes. 3. `agent-handoff.json.next_action` / `control.next_action` / `fix_task`: who acts next and whether a coding agent may safely attempt the fix. diff --git a/plugins/claude-code/commands/shipgate.md b/plugins/claude-code/commands/shipgate.md index 55f75f8c..c3004de1 100644 --- a/plugins/claude-code/commands/shipgate.md +++ b/plugins/claude-code/commands/shipgate.md @@ -48,7 +48,8 @@ Required behavior (do not skip): 1. Set `AGENTS_SHIPGATE_AGENT_MODE=1` for every CLI call so errors emit a `next_action` JSON line on stderr. 2. Run `agents-shipgate contract --json` when available and use it to verify the installed CLI's schema versions and gating signal. -3. For verifier runs, validate `agents-shipgate-reports/verification-receipt.json` first, +3. For verifier runs, read `agents-shipgate-reports/current-control.json` first, + then validate the `agents-shipgate-reports/verification-receipt.json` it binds, then parse `agents-shipgate-reports/agent-handoff.json`, then `verifier.json`, `verify-run.json`, and `report.json.release_decision.decision` as the release gate. @@ -78,7 +79,8 @@ permissions, policies, CI gates, or `shipgate.yaml`, run the verifier: agents-shipgate verify --base origin/main --head HEAD --json ``` -Validate `agents-shipgate-reports/verification-receipt.json` first, then read +Read `agents-shipgate-reports/current-control.json` first, then validate the +`agents-shipgate-reports/verification-receipt.json` it binds, then read `agents-shipgate-reports/agent-handoff.json` and lead with `gate.merge_verdict` (a deterministic projection of `release_decision.decision`, which remains the gate in `report.json`), then the authoritative substrate diff --git a/plugins/claude-code/skills/agents-shipgate/SKILL.md b/plugins/claude-code/skills/agents-shipgate/SKILL.md index 93c8815e..facedb67 100644 --- a/plugins/claude-code/skills/agents-shipgate/SKILL.md +++ b/plugins/claude-code/skills/agents-shipgate/SKILL.md @@ -52,7 +52,14 @@ Always: `control.state`, `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, and `capability_review.top_changes`. Then parse `agents-shipgate-reports/report.json.release_decision.decision`; it is the - release gate. + release gate. Refresh `agents-shipgate-reports/current-control.json` with + `agents-shipgate agent control` before you act on any of that, and again + before enforcing a cached `must_stop`, before commit/push/PR update, before + merge, and before declaring the task complete. A non-zero exit means no + control identity is current and you hold no authority; if + `current_control_id` changed, discard every cached control state and restart + from the new identity. A result remembered from earlier in the conversation + never outranks the current pointer, in either direction. 4. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. 5. Before finishing an agent-related diff, run `shipgate check --agent claude-code --workspace . --format agent-boundary-json`. For committed PR/CI verification, run `agents-shipgate verify --workspace . --config shipgate.yaml --base origin/main --head HEAD --ci-mode advisory --format json` after making the base ref available. `verify` never fetches. For host grants, run `shipgate audit --host --json --out agents-shipgate-reports/host-grants.json`. 6. Do not bypass the verifier by suppressing findings, lowering severity, expanding baselines or waivers, removing Shipgate CI, or weakening agent instructions; verify-mode `SHIP-VERIFY-*` checks make those trust-root edits release-visible. @@ -75,6 +82,7 @@ For non-GitHub CI (GitLab, CircleCI, Jenkins, Azure Pipelines, Buildkite, Bitbuc - **CLI surface** follows the current 0.x contract line — see https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/STABILITY.md. - **Installed CLI contract**: when available, run `agents-shipgate contract --json` to verify local schema versions, capability/research surfaces, `release_decision.decision`, and manual-review signal fields. Older installs should use [`docs/agent-contract-current.md`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md) or upgrade before automating against the local contract command. - **Verifier JSON**: `verifier_schema_version: "0.7"`. Switch on `control.state`, then read `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, `capability_review.top_changes`, `trust_root_touched`, and `policy_weakened` before summarizing an AI-generated PR. `merge_verdict` is a deterministic projection; the gate remains `report.json.release_decision.decision`. Check `diff_status.completeness` before you believe any negative result: only `"complete"` means the PR diff was actually read. Anything else (`reason` is one of `not_attempted`, `refs_missing`, `merge_base_missing`, `unrelated_histories`, `objects_missing`, `metadata_limit_exceeded`, `body_limit_exceeded`, `git_timeout`, `git_failed`) means evidence was missing: follow `remediation`, and never report the PR as unrelated to agent capabilities. Then read `trigger.evaluation_status` for what that cost. `"not_evaluated"` (with `trigger.should_run` `null`) means no verdict exists. `"evaluated"` on an incomplete diff is not a contradiction — evidence that did not depend on the missing bytes already proved Shipgate should run — so honor `should_run: true` instead of overriding it, and still recover the diff before trusting a merge verdict. That evidence is either a rule matched on the change set or, in an already-adopted repository, `force_run: true` from the manifest alone; check `matched_rules` before attributing the verdict to anything the diff showed. +- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt`; a `scan` or `preview` pointer never authorizes completion or merge. - **Verification receipt**: `verification-receipt.json` uses `schema_version: "shipgate.verification_receipt/v1"` and is written last. Validate it before trusting any projected verdict; it content-addresses the request, executor, unit result, decision, and complete artifact set. - **Verify run JSON**: `verify-run.json` uses `schema_version: "shipgate.verify_run/v3"`, embeds the content-addressed plan and executor, and binds unit-result and decision IDs. `run_id` is an exact compatibility alias of `request_id`; do not treat the run projection as a second gate. - **Report JSON**: `report_schema_version: "0.34"`. Read `release_decision.decision` first. A `passed` decision requires a complete root-reachable static binding graph plus complete, conflict-free identity, effect, and authority evidence for every reachable action; it does not prove runtime behavior. Preserve `release_decision.static_analysis_only=true`, `runtime_behavior_verified=false`, and `static_verdict_disclaimer` in summaries. Read `release_decision.evidence_coverage.binding_coverage`, `semantic_coverage`, `identity_coverage`, and `policy_gap_count`, then work every `evidence_gaps[].next_action` in order. Binding, semantic, and policy-applicability gaps are not Findings and cannot be suppressed, baselined, severity-overridden, cleared by `--no-heuristics`, or satisfied by `human_ack`; binding, effect, and authority declarations are human assertions and must never be auto-written. Use `tool_catalog[]` for diagnostics and `tool_inventory[]` for the proven reachable surface. The current schema is [`docs/report-schema.v0.34.json`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/report-schema.v0.34.json); v0.33 is a frozen compatibility reference. See the [current agent contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md), [verification identity contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/verification-reproducibility.md), and [evidence-backed passed contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/passed-verdict-contract.md). diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py index 3c00aa9d..52d92560 100644 --- a/scripts/generate_schemas.py +++ b/scripts/generate_schemas.py @@ -25,6 +25,7 @@ - docs/verification-unit-result-schema.v1.json - docs/verification-artifact-manifest-schema.v1.json - docs/verification-receipt-schema.v1.json +- docs/current-control-schema.v1.json (from agents_shipgate.schemas.verification_identity) - docs/human-authorization-schema.v1.json (authorization request, signed grant, @@ -1449,6 +1450,23 @@ def build_verification_receipt_schema() -> tuple[Path, str]: ) +def build_current_control_schema() -> tuple[Path, str]: + from agents_shipgate.schemas.current_control import CurrentControlPointer + + return _verification_identity_schema( + model=CurrentControlPointer, + filename="current-control-schema.v1.json", + title="Agents Shipgate Current Control Pointer v1", + description=( + "JSON Schema for agents-shipgate-reports/current-control.json, the " + "one atomic entry point naming the control identity that is current " + "now. Generated from " + "agents_shipgate.schemas.current_control.CurrentControlPointer. Do " + "not edit by hand." + ), + ) + + def build_human_authorization_schema() -> tuple[Path, str]: """Generate the signed authorization protocol schema family.""" @@ -1847,6 +1865,7 @@ def build_host_grants_drift_schema() -> tuple[Path, str]: ("verification_unit_result", build_verification_unit_result_schema), ("verification_artifact_manifest", build_verification_artifact_manifest_schema), ("verification_receipt", build_verification_receipt_schema), + ("current_control", build_current_control_schema), ("human_authorization", build_human_authorization_schema), ("agent_handoff", build_agent_handoff_schema), ("agent_result", build_agent_result_schema), diff --git a/skills/agents-shipgate/SKILL.md b/skills/agents-shipgate/SKILL.md index 93c8815e..facedb67 100644 --- a/skills/agents-shipgate/SKILL.md +++ b/skills/agents-shipgate/SKILL.md @@ -52,7 +52,14 @@ Always: `control.state`, `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, and `capability_review.top_changes`. Then parse `agents-shipgate-reports/report.json.release_decision.decision`; it is the - release gate. + release gate. Refresh `agents-shipgate-reports/current-control.json` with + `agents-shipgate agent control` before you act on any of that, and again + before enforcing a cached `must_stop`, before commit/push/PR update, before + merge, and before declaring the task complete. A non-zero exit means no + control identity is current and you hold no authority; if + `current_control_id` changed, discard every cached control state and restart + from the new identity. A result remembered from earlier in the conversation + never outranks the current pointer, in either direction. 4. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. 5. Before finishing an agent-related diff, run `shipgate check --agent claude-code --workspace . --format agent-boundary-json`. For committed PR/CI verification, run `agents-shipgate verify --workspace . --config shipgate.yaml --base origin/main --head HEAD --ci-mode advisory --format json` after making the base ref available. `verify` never fetches. For host grants, run `shipgate audit --host --json --out agents-shipgate-reports/host-grants.json`. 6. Do not bypass the verifier by suppressing findings, lowering severity, expanding baselines or waivers, removing Shipgate CI, or weakening agent instructions; verify-mode `SHIP-VERIFY-*` checks make those trust-root edits release-visible. @@ -75,6 +82,7 @@ For non-GitHub CI (GitLab, CircleCI, Jenkins, Azure Pipelines, Buildkite, Bitbuc - **CLI surface** follows the current 0.x contract line — see https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/STABILITY.md. - **Installed CLI contract**: when available, run `agents-shipgate contract --json` to verify local schema versions, capability/research surfaces, `release_decision.decision`, and manual-review signal fields. Older installs should use [`docs/agent-contract-current.md`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md) or upgrade before automating against the local contract command. - **Verifier JSON**: `verifier_schema_version: "0.7"`. Switch on `control.state`, then read `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, `capability_review.top_changes`, `trust_root_touched`, and `policy_weakened` before summarizing an AI-generated PR. `merge_verdict` is a deterministic projection; the gate remains `report.json.release_decision.decision`. Check `diff_status.completeness` before you believe any negative result: only `"complete"` means the PR diff was actually read. Anything else (`reason` is one of `not_attempted`, `refs_missing`, `merge_base_missing`, `unrelated_histories`, `objects_missing`, `metadata_limit_exceeded`, `body_limit_exceeded`, `git_timeout`, `git_failed`) means evidence was missing: follow `remediation`, and never report the PR as unrelated to agent capabilities. Then read `trigger.evaluation_status` for what that cost. `"not_evaluated"` (with `trigger.should_run` `null`) means no verdict exists. `"evaluated"` on an incomplete diff is not a contradiction — evidence that did not depend on the missing bytes already proved Shipgate should run — so honor `should_run: true` instead of overriding it, and still recover the diff before trusting a merge verdict. That evidence is either a rule matched on the change set or, in an already-adopted repository, `force_run: true` from the manifest alone; check `matched_rules` before attributing the verdict to anything the diff showed. +- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt`; a `scan` or `preview` pointer never authorizes completion or merge. - **Verification receipt**: `verification-receipt.json` uses `schema_version: "shipgate.verification_receipt/v1"` and is written last. Validate it before trusting any projected verdict; it content-addresses the request, executor, unit result, decision, and complete artifact set. - **Verify run JSON**: `verify-run.json` uses `schema_version: "shipgate.verify_run/v3"`, embeds the content-addressed plan and executor, and binds unit-result and decision IDs. `run_id` is an exact compatibility alias of `request_id`; do not treat the run projection as a second gate. - **Report JSON**: `report_schema_version: "0.34"`. Read `release_decision.decision` first. A `passed` decision requires a complete root-reachable static binding graph plus complete, conflict-free identity, effect, and authority evidence for every reachable action; it does not prove runtime behavior. Preserve `release_decision.static_analysis_only=true`, `runtime_behavior_verified=false`, and `static_verdict_disclaimer` in summaries. Read `release_decision.evidence_coverage.binding_coverage`, `semantic_coverage`, `identity_coverage`, and `policy_gap_count`, then work every `evidence_gaps[].next_action` in order. Binding, semantic, and policy-applicability gaps are not Findings and cannot be suppressed, baselined, severity-overridden, cleared by `--no-heuristics`, or satisfied by `human_ack`; binding, effect, and authority declarations are human assertions and must never be auto-written. Use `tool_catalog[]` for diagnostics and `tool_inventory[]` for the proven reachable surface. The current schema is [`docs/report-schema.v0.34.json`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/report-schema.v0.34.json); v0.33 is a frozen compatibility reference. See the [current agent contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md), [verification identity contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/verification-reproducibility.md), and [evidence-backed passed contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/passed-verdict-contract.md). diff --git a/src/agents_shipgate/cli/_register_scan.py b/src/agents_shipgate/cli/_register_scan.py index d0cf54ed..523fd21a 100644 --- a/src/agents_shipgate/cli/_register_scan.py +++ b/src/agents_shipgate/cli/_register_scan.py @@ -22,6 +22,7 @@ from agents_shipgate.cli.diagnostics import top_next_actions from agents_shipgate.cli.scan.orchestrator import run_scan from agents_shipgate.core.agent_controls import git_root_for +from agents_shipgate.core.current_control import CurrentControlPublishError from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError from agents_shipgate.core.logging import configure_logging from agents_shipgate.core.trust_roots import inspect_lexical_path_identity @@ -522,6 +523,34 @@ def scan( next_actions=[action.model_dump(mode="json") for action in actions], ) raise typer.Exit(4) from exc + except CurrentControlPublishError as exc: + # Fail closed: the pointer stayed non-terminal, so nothing in the + # directory is current even though report artifacts were written. + typer.echo(f"Agents Shipgate error: {exc}", err=True) + guidance = ( + f"Make {exc.path} writable, then re-run scan. Until the " + "control pointer publishes, no decision in this directory is " + "current and no cached control state may be acted on." + ) + actions = [ + NextAction( + kind="edit", + path=str(exc.path), + why=guidance, + expects=( + "current-control.json publishes, naming the control " + "identity that is current." + ), + ) + ] + _echo_next_action_hint(actions) + _emit_agent_mode_error( + "other_error", + message=str(exc), + next_action=guidance, + next_actions=[action.model_dump(mode="json") for action in actions], + ) + raise typer.Exit(4) from exc except AgentsShipgateError as exc: typer.echo(f"Agents Shipgate error: {exc}", err=True) guidance = ( diff --git a/src/agents_shipgate/cli/agent_interface.py b/src/agents_shipgate/cli/agent_interface.py index 1a5e7f85..8eb6a573 100644 --- a/src/agents_shipgate/cli/agent_interface.py +++ b/src/agents_shipgate/cli/agent_interface.py @@ -6,8 +6,16 @@ import typer +from agents_shipgate.cli.agent_mode import emit_agent_mode_error from agents_shipgate.core.agent_handoff import build_agent_handoff +from agents_shipgate.core.current_control import ( + CurrentControlUnavailable, + read_current_control, +) from agents_shipgate.core.errors import InputParseError +from agents_shipgate.schemas.contract import COMMANDS, DEFAULT_PATHS +from agents_shipgate.schemas.current_control import CURRENT_CONTROL_ARTIFACT_NAME +from agents_shipgate.schemas.diagnostics import NextAction agent_app = typer.Typer( help="Agent-native projection commands.", @@ -81,6 +89,57 @@ def handoff( typer.echo(f"Wrote agent handoff to {out}") +@agent_app.command("control") +def control( + reports_dir: Path = typer.Option( + Path(DEFAULT_PATHS["reports_dir"]), + "--reports-dir", + help="Directory holding current-control.json.", + ), +) -> None: + """Read the current control identity using the generation-safe protocol. + + This is the one refresh entry point. A zero exit means the printed pointer + was validated against every artifact it binds and did not move while it was + read. A non-zero exit means no control identity is current here: the caller + holds no authority and must not fall back on a control state it cached + earlier in the conversation. + """ + + try: + result = read_current_control(reports_dir) + except CurrentControlUnavailable as exc: + guidance = ( + "Re-run `agents-shipgate verify` and read " + f"{reports_dir / CURRENT_CONTROL_ARTIFACT_NAME} again. Until it " + "reads cleanly, treat completion, merge, and any cached must_stop " + "as unavailable rather than acting on a remembered result." + ) + typer.echo(f"Current control is unavailable ({exc.reason}): {exc}", err=True) + emit_agent_mode_error( + "input_parse_error" if exc.reason != "generation_changed" else "other_error", + message=str(exc), + exit_code=3 if exc.reason != "generation_changed" else 4, + next_action=guidance, + next_actions=[ + NextAction( + kind="command", + command=COMMANDS["verify_pr"], + why=guidance, + expects=( + "current-control.json is present, valid, and every " + "artifact it binds matches its recorded hash." + ), + ).model_dump(mode="json") + ], + ) + raise typer.Exit(3 if exc.reason != "generation_changed" else 4) from exc + + typer.echo( + json.dumps(result.pointer.model_dump(mode="json"), indent=2, sort_keys=True) + ) + + def _load_required_json(path: Path, label: str) -> dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) @@ -105,4 +164,4 @@ def _load_optional_json( return _load_required_json(path, label) -__all__ = ["agent_app", "handoff"] +__all__ = ["agent_app", "control", "handoff"] diff --git a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/_shared.py b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/_shared.py index dfbaa518..f6540247 100644 --- a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/_shared.py +++ b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/_shared.py @@ -12,3 +12,20 @@ "`agents-shipgate init --ci`. The default mode is `ci_mode: advisory`. " "Promotion to `ci_mode: strict` is a human decision after baseline review." ) + +# The refresh obligation. It exists because a control state is only true of the +# workspace it was computed from, and a coding agent's conversation outlives +# that workspace: a human commits, a rebase lands, another tool runs. Both +# directions are failures — enforcing a stale stop after a newer complete run +# exists, and acting on a stale complete after the workspace moved. The machine +# form of this list is `agent_refresh_triggers` in the runtime contract. +CURRENT_CONTROL_PARAGRAPH = """`agents-shipgate-reports/current-control.json` is the one entry point that +says which control identity is current. Read it with `agents-shipgate agent +control`; a non-zero exit means nothing is current here and you hold no +authority. Re-read it after any human or external-tool action, after commit, +rebase, checkout, pull, or any worktree change, after any agents-shipgate +command returns, before enforcing a cached `must_stop`, before commit/push/PR +update, before merge or release, and before declaring the task complete. If +`current_control_id` changed, discard every cached control state and restart +from the new identity. A result you remember from earlier in this conversation +never outranks the current pointer — in either direction.""" diff --git a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/agents_md.py b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/agents_md.py index 80edc5ee..8443d772 100644 --- a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/agents_md.py +++ b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/agents_md.py @@ -8,6 +8,7 @@ from agents_shipgate.cli.discovery.agent_instructions.renderers._shared import ( CI_POINTER_PARAGRAPH, + CURRENT_CONTROL_PARAGRAPH, ) @@ -64,6 +65,8 @@ def render_block() -> str: Legacy `agent-result.json` surfaces, where present, are supporting/provisional projections and not the CI gate. +{CURRENT_CONTROL_PARAGRAPH} + For coding-agent host grants, run `shipgate audit --host` and read the emitted host-grants inventory before changing MCP servers, permission rules, hooks, or workflow scopes. diff --git a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/claude_command.py b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/claude_command.py index 40b8fb79..eef16d06 100644 --- a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/claude_command.py +++ b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/claude_command.py @@ -21,7 +21,10 @@ def render_file() -> str: return (repo_root / ".claude/commands/shipgate.md").read_text(encoding="utf-8") -PRIOR_RENDER_SHA256: tuple[str, ...] = () +PRIOR_RENDER_SHA256: tuple[str, ...] = ( + # Before contract v20 added the current-control refresh rule. + "199ad507acde2c3fed69abf0f54891d8f9b2fd9a218b5fd9999f4329461ed871", +) __all__ = ["PRIOR_RENDER_SHA256", "render_file"] diff --git a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/claude_md.py b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/claude_md.py index 1cc1db10..7d0f994f 100644 --- a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/claude_md.py +++ b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/claude_md.py @@ -9,6 +9,7 @@ from agents_shipgate.cli.discovery.agent_instructions.renderers._shared import ( CI_POINTER_PARAGRAPH, + CURRENT_CONTROL_PARAGRAPH, ) @@ -56,6 +57,8 @@ def render_block() -> str: Legacy `agent-result.json` surfaces, where present, are supporting/provisional projections and not the CI gate. +{CURRENT_CONTROL_PARAGRAPH} + For coding-agent host grants, run `shipgate audit --host` and read the emitted host-grants inventory before changing MCP servers, permission rules, hooks, or workflow scopes. diff --git a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/cursor.py b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/cursor.py index efabc995..95a8c8a6 100644 --- a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/cursor.py +++ b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/cursor.py @@ -11,10 +11,14 @@ from __future__ import annotations +from agents_shipgate.cli.discovery.agent_instructions.renderers._shared import ( + CURRENT_CONTROL_PARAGRAPH, +) + def render_file() -> str: """Return the full file body for ``.cursor/rules/agents-shipgate.mdc``.""" - return """--- + return f"""--- description: Run Agents Shipgate as the deterministic merge gate for AI-generated agent capability changes. globs: - "shipgate.yaml" @@ -84,6 +88,8 @@ def render_file() -> str: Legacy `agent-result.json` surfaces, where present, are supporting/provisional projections and not the CI gate. +{CURRENT_CONTROL_PARAGRAPH} + For coding-agent host grants, run: shipgate audit --host --json --out agents-shipgate-reports/host-grants.json @@ -139,4 +145,9 @@ def render_file() -> str: # and the rendered content changes, the previous current-render hash moves into # this tuple so the next CLI run can safely overwrite v(N-1) files. Leave the # tuple empty when there is no prior shipped version (v=1 is the initial). -PRIOR_RENDER_SHA256: tuple[str, ...] = () +PRIOR_RENDER_SHA256: tuple[str, ...] = ( + # Before contract v20 added the current-control refresh rule. Without the + # outgoing hash here, a repo that already ran `init --write` would be read + # as user-modified and would never receive the new rule. + "b0c14c1d9eecebf177a8231eb017593ad09984592cd0aa717d87ef216aa8ca18", +) diff --git a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/local_contract.py b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/local_contract.py index 52a2fc83..97686f93 100644 --- a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/local_contract.py +++ b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/local_contract.py @@ -21,6 +21,8 @@ def render_file() -> str: "85d33d005d35f933b72e32c2d370efc2680e09d2ebe0c9997931c8ab4f352738", # v7 before verifier 0.6 -> 0.7 and trigger catalog 0.2 -> 0.3 "6041d5fc42ee4be37596c9c13b9752a8a511bb18bc987b32b0ffb49160ee6d93", + # v7 final, before local contract v8 added the current-control pointer + "e20840ce698c5bd81289de04f79674e4cac9dc670ae41d6e2580f13a38897b69", ) diff --git a/src/agents_shipgate/cli/discovery/local_contract.py b/src/agents_shipgate/cli/discovery/local_contract.py index de586019..db7dc392 100644 --- a/src/agents_shipgate/cli/discovery/local_contract.py +++ b/src/agents_shipgate/cli/discovery/local_contract.py @@ -16,6 +16,7 @@ AGENT_HANDOFF_SCHEMA_VERSION, AGENT_INTERFACE_OPERATIONS, AGENT_READ_ORDER, + AGENT_REFRESH_TRIGGERS, AGENT_RESULT_CONTROL_FIELDS, AGENT_RESULT_SCHEMA_PATH, AGENT_RESULT_SCHEMA_VERSION, @@ -24,6 +25,9 @@ CODEX_BOUNDARY_RESULT_SCHEMA_VERSION, COMMANDS, CONTRACT_VERSION, + CURRENT_CONTROL_FALLBACK_READ_ORDER, + CURRENT_CONTROL_SCHEMA_PATH, + CURRENT_CONTROL_SCHEMA_VERSION, DEFAULT_PATHS, DO_NOT_AUTO_ASSERT, EXIT_CODE_POLICY, @@ -54,7 +58,7 @@ ) from agents_shipgate.schemas.verifier import VerifierArtifact -LOCAL_CONTRACT_SCHEMA_VERSION = "7" +LOCAL_CONTRACT_SCHEMA_VERSION = "8" LOCAL_CONTRACT_RELATIVE_PATH = ".shipgate/agent-contract.json" @@ -80,6 +84,11 @@ class LocalAgentContract(BaseModel): verification_unit_result_schema_version: str verification_artifact_manifest_schema_version: str verification_receipt_schema_version: str + current_control_schema_version: str + current_control_schema_path: str + current_control_artifact: str + agent_refresh_triggers: list[str] + current_control_fallback_read_order: list[str] human_authorization_request_schema_version: str human_authorization_schema_version: str human_authorization_evaluation_schema_version: str @@ -137,6 +146,11 @@ def build_local_agent_contract() -> LocalAgentContract: VERIFICATION_ARTIFACT_MANIFEST_SCHEMA_VERSION ), verification_receipt_schema_version=VERIFICATION_RECEIPT_SCHEMA_VERSION, + current_control_schema_version=CURRENT_CONTROL_SCHEMA_VERSION, + current_control_schema_path=CURRENT_CONTROL_SCHEMA_PATH, + current_control_artifact=ARTIFACTS["current_control"], + agent_refresh_triggers=list(AGENT_REFRESH_TRIGGERS), + current_control_fallback_read_order=list(CURRENT_CONTROL_FALLBACK_READ_ORDER), human_authorization_request_schema_version=( HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION ), diff --git a/src/agents_shipgate/cli/scan/writing.py b/src/agents_shipgate/cli/scan/writing.py index 65d5b421..1db51d44 100644 --- a/src/agents_shipgate/cli/scan/writing.py +++ b/src/agents_shipgate/cli/scan/writing.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json from pathlib import Path from typing import Any @@ -9,9 +10,18 @@ SUGGESTED_INVENTORY_FILENAME, ) from agents_shipgate.cli._artifact_lifecycle import clear_verifier_route_artifacts +from agents_shipgate.core.current_control import ( + begin_current_control, + current_control_lifecycle_owner, + publish_current_control, +) from agents_shipgate.core.domain import Tool from agents_shipgate.core.privacy import sanitize_packet from agents_shipgate.packet.builder import build_packet +from agents_shipgate.schemas.current_control import ( + AgentActionRequiredCurrentControl, + CurrentControlWorkspaceIdentity, +) from agents_shipgate.schemas.manifest import AgentsShipgateManifest from agents_shipgate.schemas.report import ReadinessReport @@ -41,6 +51,21 @@ def _write_outputs( # A standalone scan supersedes the report set in this directory. Verify # also calls run_scan internally, but writes its fresh route/identity # artifacts only after this phase completes. + # + # ``owns_current_control`` is what tells the two apart. A supporting scan + # inside verify or preview must not take over the control identity for the + # PR: the enclosing command already invalidated the pointer and will + # publish the terminal one itself. + owns_control = current_control_lifecycle_owner() is None + if owns_control: + begin_current_control( + plan.out_dir, + operation="scan", + reason=( + "A scan is in progress; no decision in this directory is " + "current until it publishes one." + ), + ) clear_verifier_route_artifacts(plan.out_dir) _write_reports( public_report, @@ -83,6 +108,35 @@ def _write_outputs( plan.generated_paths, plan.packet_format_set, ) + if owns_control: + # A scan inventories a workspace; it never establishes merge authority. + # The pointer says so in the one place a coding agent is required to + # look, so a verifier route from an earlier run cannot be mistaken for + # the current permission to finish. A scan builds no verification + # plan, so the only identity it can bind is the manifest it read. + publish_current_control( + plan.out_dir, + operation="scan", + control=AgentActionRequiredCurrentControl( + state="agent_action_required", + reason=( + "A standalone scan produced the current report set. A scan " + "does not authorize completion or merge; run " + "`agents-shipgate verify` to obtain a merge decision." + ), + ), + workspace_identity=CurrentControlWorkspaceIdentity( + policy_snapshot_sha256=_manifest_snapshot_sha256(config_path), + ), + ) + + +def _manifest_snapshot_sha256(config_path: Path) -> str | None: + try: + data = config_path.read_bytes() + except OSError: + return None + return f"sha256:{hashlib.sha256(data).hexdigest()}" def _write_suggested_declarations( diff --git a/src/agents_shipgate/cli/verification.py b/src/agents_shipgate/cli/verification.py index e6c0db7d..b5f3c75d 100644 --- a/src/agents_shipgate/cli/verification.py +++ b/src/agents_shipgate/cli/verification.py @@ -36,6 +36,12 @@ ) from agents_shipgate.config.loader import load_yaml_file from agents_shipgate.core.agent_handoff import build_agent_handoff +from agents_shipgate.core.current_control import ( + begin_current_control, + project_agent_control, + publish_current_control, + workspace_identity_from_plan, +) from agents_shipgate.core.errors import ConfigError, InputParseError from agents_shipgate.core.static_inputs import ( StaticInputSnapshot, @@ -99,6 +105,24 @@ def prepare( head_ref = head or "HEAD" if not ref_exists(root, head_ref): raise typer.BadParameter(f"head ref is unavailable locally: {head_ref}") + # Invalidate before reading a single input. Preparing a portable plan starts + # a new verification lifecycle in this directory, so whatever was current + # describes the previous request and stops being current here; + # `verification assemble` publishes the next terminal pointer. Doing this + # first also keeps the pointer out of the window in which the plan hashes + # its inputs, so a worktree plan cannot capture one pointer generation and + # then be replayed against another. + out.parent.mkdir(parents=True, exist_ok=True) + begin_current_control( + out.parent, + operation="verify", + reason=( + "A portable verification run was prepared; no decision in this " + "directory is current until `agents-shipgate verification assemble` " + "closes it." + ), + repository=repository_identity(root), + ) changed, diff_text = ( diff_context(root, base, head_ref) if base @@ -646,6 +670,21 @@ def assemble( raise InputParseError("receipt output must remain under --artifacts-root") _write_model(resolved_artifact_root / "verification-artifacts.json", manifest) _write_model(out, receipt) + # The pointer is the last file the assembler makes visible, for the same + # reason it is last in `verify`: every artifact it binds now exists and has + # been hashed in its final form. + publish_current_control( + resolved_artifact_root, + operation="verify", + control=project_agent_control( + verifier.control, + operation="verify", + receipt_bound=(resolved_artifact_root / "verification-receipt.json").is_file(), + ), + request_id=plan.request_id, + decision_id=expected_decision_id, + workspace_identity=workspace_identity_from_plan(plan), + ) typer.echo(json.dumps({"receipt_id": receipt.receipt_id, "receipt": str(out)})) diff --git a/src/agents_shipgate/cli/verify/command.py b/src/agents_shipgate/cli/verify/command.py index dfef477a..717251ec 100644 --- a/src/agents_shipgate/cli/verify/command.py +++ b/src/agents_shipgate/cli/verify/command.py @@ -15,6 +15,7 @@ from agents_shipgate.cli.agent_mode import emit_agent_mode_error, is_agent_mode from agents_shipgate.cli.diagnostics import top_next_actions from agents_shipgate.cli.discovery.gitignore_block import REPORTS_DIR_NAME +from agents_shipgate.core.current_control import CurrentControlPublishError from agents_shipgate.core.disclaimers import STATIC_VERDICT_DISCLAIMER from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError from agents_shipgate.core.logging import configure_logging @@ -355,6 +356,34 @@ def verify( ], ) raise typer.Exit(4) from exc + except CurrentControlPublishError as exc: + # The pointer stays non-terminal, so nothing in the directory is + # current. Say so plainly rather than letting the caller assume the + # artifacts that were written are usable. + typer.echo(f"Agents Shipgate error: {exc}", err=True) + guidance = ( + f"Make {exc.path} writable, then re-run verify. Until the control " + "pointer publishes, no decision in this directory is current and " + "no cached control state may be acted on." + ) + emit_agent_mode_error( + "other_error", + message=str(exc), + exit_code=4, + next_action=guidance, + next_actions=[ + NextAction( + kind="edit", + path=str(exc.path), + why=guidance, + expects=( + "current-control.json publishes, naming the control " + "identity that is current." + ), + ).model_dump(mode="json") + ], + ) + raise typer.Exit(4) from exc except AgentsShipgateError as exc: typer.echo(f"Agents Shipgate error: {exc}", err=True) guidance = ( diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index 2d4e6641..84749ebf 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -34,6 +34,14 @@ render_capability_lock_diff_json, render_capability_lock_json, ) +from agents_shipgate.core.current_control import ( + VERIFIER_ROUTE_CONTROL_ARTIFACT_KEYS, + begin_current_control, + owns_current_control, + project_agent_control, + publish_current_control, + workspace_identity_from_plan, +) from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError from agents_shipgate.core.evaluation_clock import use_evaluation_date from agents_shipgate.core.human_authorization import ( @@ -72,6 +80,10 @@ HumanControlAction, ) from agents_shipgate.schemas.capabilities import CapabilityLockDiffV1, CapabilityLockFileV1 +from agents_shipgate.schemas.current_control import ( + CurrentControlOperation, + CurrentControlWorkspaceIdentity, +) from agents_shipgate.schemas.human_authorization import ( AuthorizationEvaluationV1, HumanAuthorizationV1, @@ -162,6 +174,7 @@ MAX_WORKTREE_CHANGED_FILE_BYTES = 64 * 1024 * 1024 +@owns_current_control("verify") def run_verify( *, workspace: Path, @@ -237,6 +250,19 @@ def run_verify( ], ) out_dir.mkdir(parents=True, exist_ok=True) + # Invalidate before anything else moves. A prior terminal pointer must not + # stay current for one instant of a run that is about to replace the + # artifacts it references, and a crash from here on must leave a directory + # that denies cached control rather than one that still authorizes it. + begin_current_control( + out_dir, + operation="verify", + reason=( + "A verification run is in progress; no decision in this directory " + "is current until it publishes one." + ), + repository=_safe_repository_identity(git_root), + ) clear_verifier_route_artifacts(out_dir) verifier_path = out_dir / "verifier.json" verify_run_path = out_dir / "verify-run.json" @@ -2456,6 +2482,7 @@ def _write_artifacts( *, report: ReadinessReport | None, git_root: Path, + operation: CurrentControlOperation = "verify", config_path: Path, config_logical_path: str | None = None, baseline_path: Path | None, @@ -2525,6 +2552,12 @@ def _write_artifacts( # identity. Error artifacts remain useful diagnostics, but they are not a # trusted receipt and downstream consumers must reject their absence. if not config_path.is_file() or not ref_exists(git_root, verifier.head_ref): + _publish_run_control( + verifier=verifier, + out_dir=verifier_path.parent, + git_root=git_root, + operation=operation, + ) return resolved_input_root = (input_root or git_root).resolve() active_snapshot = active_static_input_snapshot() @@ -2823,6 +2856,12 @@ def _finalize(snapshot: StaticInputSnapshot | None) -> None: # Failed executions retain their plan, failed unit IR, verifier, # verify-run, and actionable handoff, but never receive a terminal # success receipt. + _publish_run_control( + verifier=verifier, + out_dir=verifier_path.parent, + git_root=git_root, + operation=operation, + ) return identity_names = { "verification_plan_json", @@ -2860,13 +2899,98 @@ def _finalize(snapshot: StaticInputSnapshot | None) -> None: json.dumps(manifest.model_dump(mode="json"), indent=2, sort_keys=True), encoding="utf-8", ) - # Terminal receipt is written last. Its presence means every referenced - # artifact existed and was hashed after final serialization. + # Terminal receipt is written last of the evidence artifacts. Its presence + # means every referenced artifact existed and was hashed after final + # serialization. receipt_path = verifier_path.with_name("verification-receipt.json") receipt_path.write_text( json.dumps(receipt.model_dump(mode="json"), indent=2, sort_keys=True), encoding="utf-8", ) + _publish_run_control( + verifier=verifier, + out_dir=verifier_path.parent, + git_root=git_root, + operation=operation, + ) + + +def _publish_run_control( + *, + verifier: VerifierArtifact, + out_dir: Path, + git_root: Path, + operation: CurrentControlOperation, +) -> None: + """Publish the control pointer as the last visible file of a run. + + Called at every exit of :func:`_write_artifacts`, including the ones that + never reach a terminal receipt: an in-progress marker that is never + replaced would keep an otherwise usable diagnostic run looking like a crash. + Anything that raises before this point deliberately leaves the in-progress + marker current, which denies every cached decision. + """ + + publish_current_control( + out_dir, + operation=operation, + control=project_agent_control( + verifier.control, + operation=operation, + receipt_bound=(out_dir / "verification-receipt.json").is_file(), + ), + request_id=verifier.request_id, + decision_id=verifier.decision_id, + workspace_identity=_current_control_workspace_identity( + out_dir=out_dir, + git_root=git_root, + verifier=verifier, + ), + # A preview never runs a scan, so report.json and packet.json in this + # directory belong to some earlier run. Binding them would present two + # generations as one current artifact set. + artifact_keys=( + VERIFIER_ROUTE_CONTROL_ARTIFACT_KEYS if operation == "preview" else None + ), + ) + + +def _current_control_workspace_identity( + *, + out_dir: Path, + git_root: Path, + verifier: VerifierArtifact, +) -> CurrentControlWorkspaceIdentity: + """Bind what this run was evaluated against. + + The verification plan is the authoritative source when the run produced + one, because that is the same subject the receipt closes over. Runs that + stopped before plan construction fall back to the verifier's coarser view, + which is enough for a consumer to notice that HEAD moved. + """ + + plan_path = out_dir / "verification-plan.json" + if plan_path.is_file() and not plan_path.is_symlink(): + try: + plan = VerificationPlan.model_validate_json(plan_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + plan = None + if plan is not None: + return workspace_identity_from_plan(plan) + return CurrentControlWorkspaceIdentity( + repository=_safe_repository_identity(git_root), + head_ref=verifier.head_ref, + head_tree_sha=verifier.head_tree_sha, + ) + + +def _safe_repository_identity(workspace: Path) -> str | None: + """Resolve the credential-free repository locator, or ``None`` outside Git.""" + + try: + return repository_identity(workspace) + except Exception: # noqa: BLE001 - identity is advisory on this surface. + return None def _write_verify_run_artifact( @@ -3397,6 +3521,7 @@ def _without_github_step_summary(): os.environ["GITHUB_STEP_SUMMARY"] = prior +@owns_current_control("preview") def run_preview( *, workspace: Path, @@ -3427,6 +3552,17 @@ def run_preview( inputs=[("config", config_path)], ) out_dir.mkdir(parents=True, exist_ok=True) + # Preview is a non-terminal operation, so it starts by denying whatever a + # previous run left behind rather than leaving it current beside a preview. + begin_current_control( + out_dir, + operation="preview", + reason=( + "A verification preview is in progress; no decision in this " + "directory is current until it publishes one." + ), + repository=_safe_repository_identity(root), + ) clear_verifier_route_artifacts(out_dir) verifier_path = out_dir / "verifier.json" verify_run_path = out_dir / "verify-run.json" @@ -3601,6 +3737,10 @@ def run_preview( pr_comment_path, report=None, git_root=root, + # A preview is never a merge decision. Scoping the pointer to the + # preview operation makes "complete" unrepresentable for this run, so + # an agent cannot read a preview as authorization to finish. + operation="preview", config_path=config_path, config_logical_path=config_relative.as_posix(), baseline_path=None, diff --git a/src/agents_shipgate/core/current_control.py b/src/agents_shipgate/core/current_control.py new file mode 100644 index 00000000..50b0e7d5 --- /dev/null +++ b/src/agents_shipgate/core/current_control.py @@ -0,0 +1,564 @@ +"""Lifecycle and safe-read protocol for the current-control pointer. + +The pointer is the only artifact in the reports directory whose meaning is +"this is current *now*". Everything else in that directory means "this is what +some run produced". Three rules keep that distinction true across crashes, +concurrent readers, and coding-agent conversation boundaries: + +1. **Invalidate first.** A run replaces the pointer with a non-terminal + ``unavailable`` marker before it touches any other artifact. A process that + dies mid-run therefore leaves a directory that denies every cached decision + instead of one that still advertises the previous terminal verdict for a + workspace that has since moved. +2. **Publish last.** The terminal pointer is written only after every artifact + it references exists and has been hashed, and it is the last file made + visible for the run. +3. **Publish atomically.** Same-directory temporary file, ``fsync``, + ``os.replace``, then ``fsync`` of the directory. A reader never observes a + half-written pointer. + +Readers use :func:`read_current_control`, which re-reads the pointer after +validating the artifacts it binds. If the identity moved underneath the read, +the whole read is rejected rather than returning a mix of two generations. +""" + +from __future__ import annotations + +import contextlib +import functools +import hashlib +import json +import os +import tempfile +from collections.abc import Callable, Collection, Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from pathlib import Path +from typing import ParamSpec, TypeVar + +from agents_shipgate.core.errors import AgentsShipgateError +from agents_shipgate.core.verification_identity import read_regular_file_beneath +from agents_shipgate.schemas.agent_control import AgentControl +from agents_shipgate.schemas.current_control import ( + CURRENT_CONTROL_ARTIFACT_NAME, + RECEIPT_ARTIFACT_KEY, + AgentActionRequiredCurrentControl, + CompleteCurrentControl, + CurrentControlArtifactRef, + CurrentControlOperation, + CurrentControlPointer, + CurrentControlProjection, + CurrentControlWorkspaceIdentity, + HumanReviewRequiredCurrentControl, + UnavailableCurrentControl, + current_control_identity_payload, +) +from agents_shipgate.schemas.verification_identity import VerificationPlan, content_id + +MAX_CURRENT_CONTROL_BYTES = 1024 * 1024 +MAX_BOUND_ARTIFACT_BYTES = 256 * 1024 * 1024 + +# Stable key -> filename map for everything the pointer may bind. Keys are the +# contract-visible names; only files that actually exist are bound, so the +# artifact set itself tells a reader which evidence this run produced. +CURRENT_CONTROL_ARTIFACT_FILENAMES: dict[str, str] = { + RECEIPT_ARTIFACT_KEY: "verification-receipt.json", + "verification_artifact_manifest": "verification-artifacts.json", + "verification_plan": "verification-plan.json", + "agent_handoff": "agent-handoff.json", + "verifier": "verifier.json", + "verify_run": "verify-run.json", + "human_authorization": "human-authorization.json", + "report": "report.json", + "packet": "packet.json", + "pr_comment": "pr-comment.md", +} + +# ``report.json`` and ``packet.json`` come from a scan. A command that never +# runs one — preview — must not bind whatever an earlier run left in the +# directory, or the pointer would advertise two generations as one current set. +VERIFIER_ROUTE_CONTROL_ARTIFACT_KEYS: frozenset[str] = frozenset( + key for key in CURRENT_CONTROL_ARTIFACT_FILENAMES if key not in {"report", "packet"} +) + +_LIFECYCLE_OWNER: ContextVar[str | None] = ContextVar( + "agents_shipgate_current_control_owner", default=None +) + + +class CurrentControlPublishError(AgentsShipgateError): + """The pointer could not be moved to its next lifecycle state.""" + + def __init__(self, path: Path, detail: str) -> None: + self.path = path + super().__init__(f"Could not publish the current control pointer {path}: {detail}") + + +class CurrentControlUnavailable(AgentsShipgateError): + """No validated control identity is current in this directory. + + ``reason`` is a stable machine code. Every value means the same thing + operationally: the caller holds no current authority and must not act on a + control state it cached earlier. + """ + + def __init__(self, reason: str, detail: str, *, path: Path | None = None) -> None: + self.reason = reason + self.path = path + super().__init__(detail) + + +@contextmanager +def current_control_lifecycle(owner: str) -> Iterator[None]: + """Claim the pointer for one command for the duration of the block. + + ``verify`` runs a full scan into the same directory to produce its head + report. That supporting scan must not publish a scan-scoped pointer over + the verification the caller actually asked for, so publication from nested + scans is suppressed while an owner is active. + """ + + token = _LIFECYCLE_OWNER.set(owner) + try: + yield + finally: + _LIFECYCLE_OWNER.reset(token) + + +def current_control_lifecycle_owner() -> str | None: + return _LIFECYCLE_OWNER.get() + + +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def owns_current_control(operation: str) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: + """Mark a command entry point as the owner of the pointer it publishes.""" + + def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]: + @functools.wraps(function) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + with current_control_lifecycle(operation): + return function(*args, **kwargs) + + return wrapper + + return decorate + + +def current_control_path(out_dir: Path) -> Path: + return out_dir / CURRENT_CONTROL_ARTIFACT_NAME + + +def begin_current_control( + out_dir: Path, + *, + operation: CurrentControlOperation, + reason: str, + repository: str | None = None, +) -> CurrentControlPointer: + """Atomically replace whatever was current with an in-progress marker.""" + + pointer = _finalize( + operation=operation, + lifecycle_state="in_progress", + request_id=None, + decision_id=None, + workspace_identity=CurrentControlWorkspaceIdentity(repository=repository), + control=UnavailableCurrentControl(state="unavailable", reason=reason), + artifacts={}, + supersedes=_previous_control_id(out_dir), + ) + _publish(out_dir, pointer) + return pointer + + +def publish_current_control( + out_dir: Path, + *, + operation: CurrentControlOperation, + control: CurrentControlProjection, + request_id: str | None = None, + decision_id: str | None = None, + workspace_identity: CurrentControlWorkspaceIdentity | None = None, + artifact_keys: Collection[str] | None = None, +) -> CurrentControlPointer: + """Bind the artifacts now on disk and publish the terminal pointer. + + ``artifact_keys`` restricts the binding to what this operation can actually + have produced. Leaving it open would let a command bind a file an earlier, + different run wrote, which is the mixed artifact set the pointer exists to + make detectable. + """ + + artifacts = bind_current_control_artifacts(out_dir, artifact_keys=artifact_keys) + if RECEIPT_ARTIFACT_KEY not in artifacts and control.state == "complete": + # Defence in depth: the schema rejects this too, but downgrading here + # keeps a caller with an inconsistent view from losing the run. + control = HumanReviewRequiredCurrentControl( + state="human_review_required", + reason=( + "The run reported completion but published no terminal receipt, " + "so completion authority cannot be established." + ), + ) + pointer = _finalize( + operation=operation, + lifecycle_state="terminal", + request_id=request_id, + decision_id=decision_id, + workspace_identity=workspace_identity or CurrentControlWorkspaceIdentity(), + control=control, + artifacts=artifacts, + supersedes=_previous_control_id(out_dir), + ) + _publish(out_dir, pointer) + return pointer + + +def bind_current_control_artifacts( + out_dir: Path, + *, + artifact_keys: Collection[str] | None = None, +) -> dict[str, CurrentControlArtifactRef]: + """Hash every selected artifact that exists as a regular file in ``out_dir``.""" + + selected = ( + CURRENT_CONTROL_ARTIFACT_FILENAMES + if artifact_keys is None + else { + key: filename + for key, filename in CURRENT_CONTROL_ARTIFACT_FILENAMES.items() + if key in artifact_keys + } + ) + refs: dict[str, CurrentControlArtifactRef] = {} + for key, filename in selected.items(): + path = out_dir / filename + if path.is_symlink() or not path.is_file(): + continue + try: + data = read_regular_file_beneath( + out_dir, + filename, + max_size=MAX_BOUND_ARTIFACT_BYTES, + label="current control artifact", + ) + except ValueError as exc: + raise CurrentControlPublishError(out_dir / filename, str(exc)) from exc + refs[key] = CurrentControlArtifactRef( + path=filename, + sha256=_sha256_bytes(data), + size_bytes=len(data), + ) + return refs + + +def project_agent_control( + control: AgentControl, + *, + operation: CurrentControlOperation, + receipt_bound: bool, +) -> CurrentControlProjection: + """Narrow an authoritative :class:`AgentControl` onto the pointer. + + This is a projection, never a decision. The one place it deviates is + conservative: completion authority is refused unless this run is a verify + that also published a terminal receipt, because ``complete`` is the single + state a stale reader could act on destructively. + """ + + if control.state == "complete": + if operation != "verify" or not receipt_bound: + return HumanReviewRequiredCurrentControl( + state="human_review_required", + reason=( + "A completion-authorizing control was projected without a " + "verify receipt for this exact request, so it is refused." + ), + ) + return CompleteCurrentControl(state="complete", reason=control.reason) + if control.state == "human_review_required": + return HumanReviewRequiredCurrentControl( + state="human_review_required", reason=control.reason + ) + return AgentActionRequiredCurrentControl(state="agent_action_required", reason=control.reason) + + +def workspace_identity_from_plan(plan: VerificationPlan) -> CurrentControlWorkspaceIdentity: + """Derive the pointer's workspace identity from a verification plan. + + The plan's subject is the same one the terminal receipt closes over, so a + pointer built from it names exactly the workspace the decision was made + against — not the workspace the reader happens to be sitting in. + """ + + git = plan.subject.git + return CurrentControlWorkspaceIdentity( + repository=git.repository_id, + head_ref=git.head_ref, + head_commit_sha=git.head_commit_sha, + head_tree_sha=git.head_tree_sha, + worktree_overlay_sha256=git.worktree_overlay_sha256, + policy_snapshot_sha256=content_id( + { + "config": plan.inputs.config.sha256, + "policy_packs": [blob.sha256 for blob in plan.inputs.policy_packs], + "policy_catalog_sha256": plan.engine.policy_catalog_sha256, + } + ), + snapshot_kind=git.snapshot_kind, + ) + + +@dataclass(frozen=True) +class CurrentControlRead: + """A pointer that was validated against the artifacts it binds.""" + + pointer: CurrentControlPointer + path: Path + + +def read_current_control(out_dir: Path, *, attempts: int = 3) -> CurrentControlRead: + """Read the current control identity, or refuse. + + Implements the generation-safe protocol: read and validate the pointer, + validate every artifact it binds, then re-read the pointer and continue + only when ``current_control_id`` is unchanged. A run that republishes + while this read is in flight makes the read fail rather than return a + pointer describing one generation and artifacts from another. + """ + + path = current_control_path(out_dir) + moved = CurrentControlUnavailable( + "generation_changed", + ( + "The current control identity changed while it was being read; " + "no coherent generation could be observed." + ), + path=path, + ) + last: CurrentControlUnavailable | None = None + for _ in range(max(1, attempts)): + pointer = _load_pointer(out_dir, path) + try: + _validate_bound_artifacts(out_dir, pointer) + except CurrentControlUnavailable as mismatch: + # A run that republished mid-read moves the pointer too. Retry that + # case; a mismatch under a pointer that did not move is a real + # inconsistency and must surface. + if _current_control_id(path) == pointer.current_control_id: + raise + last = mismatch + continue + confirmation = _load_pointer(out_dir, path) + if confirmation.current_control_id == pointer.current_control_id: + return CurrentControlRead(pointer=pointer, path=path) + last = moved + raise last or CurrentControlUnavailable( + "generation_changed", + "The current control identity could not be read coherently.", + path=path, + ) + + +def _load_pointer(out_dir: Path, path: Path) -> CurrentControlPointer: + if path.is_symlink(): + raise CurrentControlUnavailable( + "unsafe_pointer", + f"{CURRENT_CONTROL_ARTIFACT_NAME} must be a regular file, not a symlink.", + path=path, + ) + if not path.is_file(): + raise CurrentControlUnavailable( + "missing", + ( + f"No {CURRENT_CONTROL_ARTIFACT_NAME} is present in {out_dir}; run a " + "Shipgate command that publishes one before acting on any cached " + "control state." + ), + path=path, + ) + try: + data = read_regular_file_beneath( + out_dir, + CURRENT_CONTROL_ARTIFACT_NAME, + max_size=MAX_CURRENT_CONTROL_BYTES, + label="current control pointer", + ) + except ValueError as exc: + raise CurrentControlUnavailable("unreadable", str(exc), path=path) from exc + try: + payload = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CurrentControlUnavailable( + "invalid_schema", + f"{CURRENT_CONTROL_ARTIFACT_NAME} is not one UTF-8 JSON object.", + path=path, + ) from exc + if not isinstance(payload, dict): + raise CurrentControlUnavailable( + "invalid_schema", + f"{CURRENT_CONTROL_ARTIFACT_NAME} must contain one JSON object.", + path=path, + ) + try: + return CurrentControlPointer.model_validate(payload) + except Exception as exc: # pydantic ValidationError and friends + raise CurrentControlUnavailable( + "invalid_schema", + f"{CURRENT_CONTROL_ARTIFACT_NAME} is not a valid control pointer: {exc}", + path=path, + ) from exc + + +def _validate_bound_artifacts(out_dir: Path, pointer: CurrentControlPointer) -> None: + for name, ref in sorted(pointer.artifacts.items()): + try: + data = read_regular_file_beneath( + out_dir, + ref.path, + max_size=MAX_BOUND_ARTIFACT_BYTES, + label="current control artifact", + ) + except ValueError as exc: + raise CurrentControlUnavailable( + "artifact_unreadable", + f"Bound artifact {name!r} could not be read safely: {exc}", + path=out_dir / ref.path, + ) from exc + if len(data) != ref.size_bytes or _sha256_bytes(data) != ref.sha256: + raise CurrentControlUnavailable( + "artifact_mismatch", + ( + f"Bound artifact {name!r} does not match the current control " + "pointer; the artifact set is mixed or was edited after " + "publication." + ), + path=out_dir / ref.path, + ) + + +def _finalize( + *, + operation: CurrentControlOperation, + lifecycle_state: str, + request_id: str | None, + decision_id: str | None, + workspace_identity: CurrentControlWorkspaceIdentity, + control: CurrentControlProjection, + artifacts: dict[str, CurrentControlArtifactRef], + supersedes: str | None, +) -> CurrentControlPointer: + draft = CurrentControlPointer.model_construct( + current_control_id="sha256:" + "0" * 64, + operation=operation, + lifecycle_state=lifecycle_state, # type: ignore[arg-type] + request_id=request_id, + decision_id=decision_id, + workspace_identity=workspace_identity, + control=control, + artifacts=artifacts, + supersedes=supersedes, + ) + identity = content_id(current_control_identity_payload(draft)) + # Re-validate through the full model so every structural invariant runs + # against the exact payload that will be written. + return CurrentControlPointer.model_validate( + {**draft.model_dump(mode="json"), "current_control_id": identity} + ) + + +def _previous_control_id(out_dir: Path) -> str | None: + """Best-effort read of the identity being replaced, for audit only.""" + + return _current_control_id(current_control_path(out_dir)) + + +def _current_control_id(path: Path) -> str | None: + """Cheap identity probe that never raises; ``None`` means "not readable".""" + + if path.is_symlink() or not path.is_file(): + return None + try: + payload = json.loads(path.read_bytes()[:MAX_CURRENT_CONTROL_BYTES].decode("utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + value = payload.get("current_control_id") if isinstance(payload, dict) else None + if isinstance(value, str) and value.startswith("sha256:") and len(value) == 71: + return value + return None + + +def _publish(out_dir: Path, pointer: CurrentControlPointer) -> None: + path = current_control_path(out_dir) + payload = (json.dumps(pointer.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode( + "utf-8" + ) + if path.is_symlink(): + raise CurrentControlPublishError( + path, "the existing pointer is a symlink and will not be followed" + ) + temporary: Path | None = None + try: + out_dir.mkdir(parents=True, exist_ok=True) + handle, name = tempfile.mkstemp( + dir=out_dir, prefix=f".{CURRENT_CONTROL_ARTIFACT_NAME}.", suffix=".tmp" + ) + temporary = Path(name) + with os.fdopen(handle, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + temporary = None + _fsync_directory(out_dir) + except OSError as exc: + raise CurrentControlPublishError(path, str(exc)) from exc + finally: + if temporary is not None: + with contextlib.suppress(OSError): + temporary.unlink() + + +def _fsync_directory(directory: Path) -> None: + # Durability of the rename itself. Not every platform supports opening a + # directory for fsync; where it does not, the rename is still atomic. + try: + descriptor = os.open(directory, os.O_RDONLY) + except OSError: + return + try: + os.fsync(descriptor) + except OSError: + pass + finally: + with contextlib.suppress(OSError): + os.close(descriptor) + + +def _sha256_bytes(value: bytes) -> str: + return f"sha256:{hashlib.sha256(value).hexdigest()}" + + +__all__ = [ + "CURRENT_CONTROL_ARTIFACT_FILENAMES", + "MAX_BOUND_ARTIFACT_BYTES", + "VERIFIER_ROUTE_CONTROL_ARTIFACT_KEYS", + "MAX_CURRENT_CONTROL_BYTES", + "CurrentControlPublishError", + "CurrentControlRead", + "CurrentControlUnavailable", + "begin_current_control", + "bind_current_control_artifacts", + "current_control_lifecycle", + "current_control_lifecycle_owner", + "current_control_path", + "owns_current_control", + "project_agent_control", + "publish_current_control", + "read_current_control", + "workspace_identity_from_plan", +] diff --git a/src/agents_shipgate/core/verification_identity.py b/src/agents_shipgate/core/verification_identity.py index 7203cf23..24f284ad 100644 --- a/src/agents_shipgate/core/verification_identity.py +++ b/src/agents_shipgate/core/verification_identity.py @@ -677,10 +677,24 @@ def load_validated_receipt_artifacts( return receipt, artifacts -def _read_regular_file_beneath(root: Path, logical_path: str, *, max_size: int) -> bytes: +def read_regular_file_beneath( + root: Path, + logical_path: str, + *, + max_size: int, + label: str = "receipt artifact", +) -> bytes: + """Read one regular file strictly beneath ``root``. + + Every path component is opened with ``O_NOFOLLOW`` against the parent + descriptor, so a symlink planted anywhere along the way fails instead of + escaping. The stat identity is compared before and after the read so a + file swapped mid-read is rejected rather than hashed as two generations. + """ + parts = Path(logical_path).parts if not parts or Path(logical_path).is_absolute() or any(part in {"", ".", ".."} for part in parts): - raise ValueError(f"receipt artifact path is not portable: {logical_path!r}") + raise ValueError(f"{label} path is not portable: {logical_path!r}") directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) descriptors: list[int] = [] @@ -694,9 +708,9 @@ def _read_regular_file_beneath(root: Path, logical_path: str, *, max_size: int) descriptors.append(file_descriptor) before = os.fstat(file_descriptor) if not stat.S_ISREG(before.st_mode): - raise ValueError(f"receipt artifact is not a regular file: {logical_path}") + raise ValueError(f"{label} is not a regular file: {logical_path}") if before.st_size > max_size: - raise ValueError(f"receipt artifact exceeds its size limit: {logical_path}") + raise ValueError(f"{label} exceeds its size limit: {logical_path}") chunks: list[bytes] = [] remaining = max_size + 1 while remaining: @@ -718,18 +732,23 @@ def _read_regular_file_beneath(root: Path, logical_path: str, *, max_size: int) after.st_size, after.st_mtime_ns, ): - raise ValueError(f"receipt artifact changed while it was read: {logical_path}") + raise ValueError(f"{label} changed while it was read: {logical_path}") if len(data) > max_size: - raise ValueError(f"receipt artifact exceeds its size limit: {logical_path}") + raise ValueError(f"{label} exceeds its size limit: {logical_path}") return data except OSError as exc: - raise ValueError(f"could not safely read receipt artifact {logical_path!r}: {exc}") from exc + raise ValueError(f"could not safely read {label} {logical_path!r}: {exc}") from exc finally: for descriptor in reversed(descriptors): with contextlib.suppress(OSError): os.close(descriptor) + + +_read_regular_file_beneath = read_regular_file_beneath + + def _json_object_bytes(data: bytes, label: str) -> dict[str, Any]: try: payload = json.loads(data.decode("utf-8")) diff --git a/src/agents_shipgate/schemas/contract.py b/src/agents_shipgate/schemas/contract.py index 23d9b917..9c600cd4 100644 --- a/src/agents_shipgate/schemas/contract.py +++ b/src/agents_shipgate/schemas/contract.py @@ -23,6 +23,10 @@ from agents_shipgate.schemas.codex_boundary_result import ( CODEX_BOUNDARY_RESULT_SCHEMA_VERSION, ) +from agents_shipgate.schemas.current_control import ( + CURRENT_CONTROL_SCHEMA_PATH, + CURRENT_CONTROL_SCHEMA_VERSION, +) from agents_shipgate.schemas.governance_benchmark import ( GOVERNANCE_BENCHMARK_CATALOG_SCHEMA_VERSION, GOVERNANCE_BENCHMARK_RESULT_SCHEMA_VERSION, @@ -54,7 +58,7 @@ from agents_shipgate.schemas.verifier import VerifierArtifact from agents_shipgate.schemas.verify_run import VERIFY_RUN_SCHEMA_VERSION -CONTRACT_VERSION: Literal["19"] = "19" +CONTRACT_VERSION: Literal["20"] = "20" MINIMUM_CONTROL_CONTRACT_VERSION: Literal["14"] = "14" GATING_SIGNAL: Literal["release_decision.decision"] = "release_decision.decision" AGENT_RESULT_SCHEMA_VERSION: Literal["agent_result_v2"] = "agent_result_v2" @@ -92,7 +96,35 @@ "agent_action_required", "human_review_required", ) +# v20: the boundaries at which a coding agent must re-read +# ``current_control_artifact`` before it acts. A control state remembered from +# earlier in a conversation is never authority: the pointer is. These are +# obligations on the consumer, so they are published as contract data rather +# than left to prose in the generated instructions. +AGENT_REFRESH_TRIGGERS: tuple[str, ...] = ( + "after any human action or external tool action", + "after commit, rebase, checkout, pull, or any other worktree mutation", + "after any agents-shipgate command returns", + "before enforcing a cached must_stop", + "before commit, push, or PR update when permission depends on Shipgate", + "before merge or release", + "before declaring the task complete", + "whenever the observed request_id, HEAD, worktree identity, or " + "current_control_id changes", +) +# The fallback order for a consumer built before ``current-control.json`` +# existed, or reading a directory produced by an older release. Absence of the +# pointer is not permission: such a consumer must treat the artifacts below as +# evidence of some run, never as evidence that the run is current. +CURRENT_CONTROL_FALLBACK_READ_ORDER: tuple[str, ...] = ( + "current-control.json", + "verification-receipt.json", + "agent-handoff.json", + "verifier.json", + "report.json", +) EXTERNAL_INTEGRATION_SURFACES: tuple[str, ...] = ( + "current_control", "agent_handoff", "preflight", "capability_lock", @@ -245,6 +277,9 @@ "agents-shipgate-reports/verification-receipt.json --artifacts-root " "agents-shipgate-reports" ), + "agent_control": ( + "agents-shipgate agent control --reports-dir agents-shipgate-reports" + ), "agent_handoff": ( "agents-shipgate agent handoff --from agents-shipgate-reports/verifier.json --json" ), @@ -276,6 +311,7 @@ "host_audit": ("shipgate audit --host --json --out agents-shipgate-reports/host-grants.json"), } ARTIFACTS: dict[str, str] = { + "current_control": "agents-shipgate-reports/current-control.json", "verifier": "agents-shipgate-reports/verifier.json", "verify_run": "agents-shipgate-reports/verify-run.json", "agent_handoff": "agents-shipgate-reports/agent-handoff.json", @@ -297,6 +333,13 @@ "registry": ".agents-shipgate/registry.jsonl", } AGENT_READ_ORDER: tuple[str, ...] = ( + # v20: the pointer is read first and re-read at every decision boundary in + # AGENT_REFRESH_TRIGGERS. Everything below it describes a run; only the + # pointer says which run is current. + "current-control.json", + "current-control.json.current_control_id", + "current-control.json.lifecycle_state", + "current-control.json.control.state", "verification-receipt.json", "verification-receipt.json.request_id", "verification-receipt.json.receipt_id", @@ -373,6 +416,11 @@ class ContractPayload(BaseModel): verification_unit_result_schema_version: str verification_artifact_manifest_schema_version: str verification_receipt_schema_version: str + current_control_schema_version: str + current_control_schema_path: str + current_control_artifact: str + agent_refresh_triggers: list[str] + current_control_fallback_read_order: list[str] human_authorization_request_schema_version: str human_authorization_schema_version: str human_authorization_evaluation_schema_version: str @@ -441,6 +489,11 @@ def build_contract_payload() -> ContractPayload: VERIFICATION_ARTIFACT_MANIFEST_SCHEMA_VERSION ), verification_receipt_schema_version=VERIFICATION_RECEIPT_SCHEMA_VERSION, + current_control_schema_version=CURRENT_CONTROL_SCHEMA_VERSION, + current_control_schema_path=CURRENT_CONTROL_SCHEMA_PATH, + current_control_artifact=ARTIFACTS["current_control"], + agent_refresh_triggers=list(AGENT_REFRESH_TRIGGERS), + current_control_fallback_read_order=list(CURRENT_CONTROL_FALLBACK_READ_ORDER), human_authorization_request_schema_version=( HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION ), @@ -513,6 +566,10 @@ def build_contract_payload() -> ContractPayload: "AGENT_RESULT_SCHEMA_PATH", "AGENT_RESULT_SCHEMA_VERSION", "AGENT_READ_ORDER", + "AGENT_REFRESH_TRIGGERS", + "CURRENT_CONTROL_FALLBACK_READ_ORDER", + "CURRENT_CONTROL_SCHEMA_PATH", + "CURRENT_CONTROL_SCHEMA_VERSION", "AGENT_HANDOFF_SCHEMA_PATH", "AGENT_HANDOFF_SCHEMA_VERSION", "AGENT_INTERFACE_OPERATIONS", diff --git a/src/agents_shipgate/schemas/current_control.py b/src/agents_shipgate/schemas/current_control.py new file mode 100644 index 00000000..b2699edf --- /dev/null +++ b/src/agents_shipgate/schemas/current_control.py @@ -0,0 +1,244 @@ +"""The one atomic entry point that answers "what is current now?". + +``current-control.json`` is a *pointer*, never a second release decision. It +binds identities and hashes that other authoritative artifacts already +published — the terminal receipt, the agent handoff, the verifier, the report — +so a consumer can decide in one read whether the control state it is holding +still describes this workspace. + +Two failure directions motivate it, and both are enforced structurally here +rather than by consumer discipline: + +* *Stale denial* — an agent keeps enforcing a cached ``must_stop`` after a + newer complete run exists. A published terminal pointer supersedes the + cached state, and its ``current_control_id`` changes when the run does. +* *Stale authorization* — an agent acts on a cached ``complete`` after the + workspace moved. ``complete`` is only representable when the pointer also + carries the request identity, the decision identity, and a bound terminal + receipt for exactly that run. + +The control projection is deliberately narrower than +:mod:`agents_shipgate.schemas.agent_control`. It carries no ``next_action`` +and no command list, because reproducing a route here would create a second +place where operational authority is decided. The route stays in the handoff +this pointer references. + +Nothing here is assertable by a coding agent. There is no field in which to +claim human approval — a human authorization can only reach the pointer as a +hash of the signed grant the verifier already accepted — and every field is +covered by ``current_control_id``, so a hand-edited pointer fails to validate +rather than being read as a weaker but still current one. +""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StringConstraints, + field_validator, + model_validator, +) + +from agents_shipgate.schemas.verification_identity import ( + CONTENT_ID_PATTERN, + GIT_OBJECT_PATTERN, + content_id, + validate_portable_path, +) + +CURRENT_CONTROL_SCHEMA_VERSION = "shipgate.current_control/v1" +CURRENT_CONTROL_SCHEMA_PATH = "docs/current-control-schema.v1.json" +CURRENT_CONTROL_ARTIFACT_NAME = "current-control.json" + +# Which command produced this pointer. Only ``verify`` can reach a state that +# authorizes completion; the others are structurally barred below. +CurrentControlOperation = Literal["verify", "preview", "scan"] +CurrentControlLifecycleState = Literal["in_progress", "terminal"] + +NonEmptyText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + +# The artifact key that must be bound before completion is representable. +RECEIPT_ARTIFACT_KEY = "verification_receipt" + + +class CurrentControlArtifactRef(BaseModel): + """A hash-bound reference to one artifact beside the pointer.""" + + model_config = ConfigDict(extra="forbid") + + path: str + sha256: str = Field(pattern=CONTENT_ID_PATTERN) + size_bytes: int = Field(ge=0) + + _path_is_portable = field_validator("path")(validate_portable_path) + + +class CurrentControlWorkspaceIdentity(BaseModel): + """What the pointer was published against. + + Every field is nullable because the pointer is published by commands that + resolve different amounts of identity — an in-progress marker knows only + the repository, a preview outside Git knows nothing. A consumer treats a + ``null`` as "not bound", never as "unchanged". + """ + + model_config = ConfigDict(extra="forbid") + + repository: str | None = None + head_ref: str | None = None + head_commit_sha: str | None = Field(default=None, pattern=GIT_OBJECT_PATTERN) + head_tree_sha: str | None = Field(default=None, pattern=GIT_OBJECT_PATTERN) + worktree_overlay_sha256: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + policy_snapshot_sha256: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + snapshot_kind: Literal["committed_tree", "worktree_overlay"] | None = None + + +class UnavailableCurrentControl(BaseModel): + """A lifecycle run is in flight; no decision in this directory is current.""" + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={"required": ["state", "reason", "completion_allowed", "must_stop"]}, + ) + + state: Literal["unavailable"] + reason: NonEmptyText + completion_allowed: Literal[False] = False + must_stop: Literal[True] = True + + +class CompleteCurrentControl(BaseModel): + """The referenced run authorizes reporting the task complete.""" + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={"required": ["state", "reason", "completion_allowed", "must_stop"]}, + ) + + state: Literal["complete"] + reason: NonEmptyText + completion_allowed: Literal[True] = True + must_stop: Literal[False] = False + + +class AgentActionRequiredCurrentControl(BaseModel): + """The referenced run leaves one coding-agent-owned step outstanding.""" + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={"required": ["state", "reason", "completion_allowed", "must_stop"]}, + ) + + state: Literal["agent_action_required"] + reason: NonEmptyText + completion_allowed: Literal[False] = False + must_stop: Literal[False] = False + + +class HumanReviewRequiredCurrentControl(BaseModel): + """The referenced run stops the coding agent pending human review.""" + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={"required": ["state", "reason", "completion_allowed", "must_stop"]}, + ) + + state: Literal["human_review_required"] + reason: NonEmptyText + completion_allowed: Literal[False] = False + must_stop: Literal[True] = True + + +type CurrentControlProjection = Annotated[ + UnavailableCurrentControl + | CompleteCurrentControl + | AgentActionRequiredCurrentControl + | HumanReviewRequiredCurrentControl, + Field(discriminator="state"), +] + + +class CurrentControlPointer(BaseModel): + """``agents-shipgate-reports/current-control.json``.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal["shipgate.current_control/v1"] = CURRENT_CONTROL_SCHEMA_VERSION + current_control_id: str = Field(pattern=CONTENT_ID_PATTERN) + operation: CurrentControlOperation + lifecycle_state: CurrentControlLifecycleState + request_id: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + decision_id: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + workspace_identity: CurrentControlWorkspaceIdentity + control: CurrentControlProjection + artifacts: dict[str, CurrentControlArtifactRef] = Field(default_factory=dict) + # The identity this pointer replaced, for audit only. Deliberately outside + # the identity hash: "what is current" is a statement about now, not about + # how the directory got here, and two runs that produce the same control + # must produce the same ``current_control_id``. + supersedes: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + + @model_validator(mode="after") + def _pointer_is_coherent(self) -> CurrentControlPointer: + if self.lifecycle_state == "in_progress": + if self.control.state != "unavailable": + raise ValueError("an in-progress pointer must deny every cached decision") + if self.artifacts: + raise ValueError("an in-progress pointer cannot bind a terminal artifact set") + if self.request_id is not None or self.decision_id is not None: + raise ValueError("an in-progress pointer has no settled request or decision") + else: + if self.control.state == "unavailable": + raise ValueError("a terminal pointer must project a settled control state") + if not self.artifacts: + raise ValueError("a terminal pointer must bind at least one artifact") + if self.operation != "verify" and self.control.state == "complete": + # Only the verifier decides merge authority. A scan or a preview + # that could reach ``complete`` would be exactly the stale + # authorization this pointer exists to prevent. + raise ValueError("only verify can publish a completion-authorizing control") + if self.control.state == "complete": + if self.request_id is None or self.decision_id is None: + raise ValueError("completion authority requires a bound request and decision") + if RECEIPT_ARTIFACT_KEY not in self.artifacts: + raise ValueError("completion authority requires a bound terminal receipt") + paths = [ref.path for ref in self.artifacts.values()] + if len(set(paths)) != len(paths): + raise ValueError("current control artifacts must not bind one path twice") + expected = content_id(current_control_identity_payload(self)) + if self.current_control_id != expected: + raise ValueError("current_control_id must hash the complete control pointer") + return self + + +def current_control_identity_payload(pointer: CurrentControlPointer) -> dict[str, Any]: + """Return the payload ``current_control_id`` hashes.""" + + return pointer.model_dump( + mode="json", + exclude={"current_control_id", "schema_version", "supersedes"}, + exclude_none=False, + ) + + +__all__ = [ + "CURRENT_CONTROL_ARTIFACT_NAME", + "CURRENT_CONTROL_SCHEMA_PATH", + "CURRENT_CONTROL_SCHEMA_VERSION", + "RECEIPT_ARTIFACT_KEY", + "AgentActionRequiredCurrentControl", + "CompleteCurrentControl", + "CurrentControlArtifactRef", + "CurrentControlLifecycleState", + "CurrentControlOperation", + "CurrentControlPointer", + "CurrentControlProjection", + "CurrentControlWorkspaceIdentity", + "HumanReviewRequiredCurrentControl", + "UnavailableCurrentControl", + "current_control_identity_payload", +] diff --git a/src/agents_shipgate/schemas/verification_identity.py b/src/agents_shipgate/schemas/verification_identity.py index 1a322854..6a99e89c 100644 --- a/src/agents_shipgate/schemas/verification_identity.py +++ b/src/agents_shipgate/schemas/verification_identity.py @@ -49,7 +49,14 @@ def _identity_payload(model: BaseModel, identity_field: str) -> dict[str, Any]: ) -def _validate_portable_path(value: str) -> str: +def validate_portable_path(value: str) -> str: + """Reject absolute, escaping, or non-normalized artifact/input paths. + + Every artifact reference that a consumer may resolve beneath a root goes + through this one check, so containment cannot drift between the receipt, + the artifact manifest, and the current-control pointer. + """ + path = PurePosixPath(value) if ( not value @@ -62,6 +69,9 @@ def _validate_portable_path(value: str) -> str: return value +_validate_portable_path = validate_portable_path + + class VerificationBlob(BaseModel): model_config = ConfigDict(extra="forbid") @@ -404,4 +414,5 @@ def _receipt_closes_one_identity_graph(self) -> VerificationReceipt: "VerificationUnitResult", "canonical_json", "content_id", + "validate_portable_path", ] diff --git a/tests/test_agent_instructions_apply.py b/tests/test_agent_instructions_apply.py index 32f43509..00c70b9a 100644 --- a/tests/test_agent_instructions_apply.py +++ b/tests/test_agent_instructions_apply.py @@ -189,14 +189,17 @@ def test_claude_command_current_file_matches_renderer() -> None: def test_local_contract_renderer_has_required_fields() -> None: payload = json.loads(render_local_contract_file()) - assert payload["schema_version"] == "7" - assert payload["contract_version"] == "19" + assert payload["schema_version"] == "8" + assert payload["contract_version"] == "20" assert "verify_local" not in payload["primary_commands"] assert payload["primary_commands"]["verify_pr"].startswith("agents-shipgate verify") assert payload["commands"]["verify_local"].startswith("agents-shipgate verify") assert payload["primary_commands"]["host_audit"].startswith("shipgate audit --host") assert payload["agent_handoff_schema_version"] == "shipgate.agent_handoff/v6" assert payload["agent_handoff_artifact"] == "agents-shipgate-reports/agent-handoff.json" + assert payload["current_control_artifact"] == ( + "agents-shipgate-reports/current-control.json" + ) assert payload["attestation_schema_version"] == "0.5" assert payload["registry_schema_version"] == "0.4" assert payload["org_evidence_bundle_schema_version"] == ("shipgate.org_evidence_bundle/v2") diff --git a/tests/test_agent_instructions_renderers.py b/tests/test_agent_instructions_renderers.py index 1a3fd47b..10559c56 100644 --- a/tests/test_agent_instructions_renderers.py +++ b/tests/test_agent_instructions_renderers.py @@ -45,7 +45,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent EXPECTED_CLAUDE_CODE_SKILL_RENDER_SHA256 = { ".claude/skills/agents-shipgate/SKILL.md": ( - "f6771ad16589854d51604cf35af0d78022b6a28e5ffa97a1d0c429ee6b6107da" + "60aae4e35c309253cc8a851b1096b586c02ae97e688a1e3885056e0f344ccaa7" ), ".claude/skills/agents-shipgate/ci-recipes/advisory-pr-comment.yml": ( # Renders {{ shipgate_version }}; changes on every version bump. @@ -81,7 +81,7 @@ } EXPECTED_CODEX_SKILL_RENDER_SHA256 = { ".agents/skills/agents-shipgate/SKILL.md": ( - "10fcba81a9d07d7f1736fd3177c162dfdc92d5a106469c3def907d233a270dd4" + "4bde76605784f71eaf6fe57f0c3f4e44e204f10f4eb023edb15424710928894f" ), ".agents/skills/agents-shipgate/agents/openai.yaml": ( "aa511e933ff663dcd1e0d2af3da2a7101206ce2bb1bb98c4dae801bb3f4e42ef" @@ -93,7 +93,7 @@ "97a8eb98fc560405c690581ac5542b2f94783480f2266af36f566ae12600e2cb" ), ".agents/skills/agents-shipgate/references/report-reading.md": ( - "a5dd0eec215e973c403a22bdbbf0eca76a1796e20be5ae9623829c9d11d2a8dc" + "7b70e3ab34ed2f74175e2fd14df0ef98bdddd41b7c0460d10684f17f8cf3c00c" ), } @@ -161,9 +161,9 @@ def test_committed_claude_command_matches_renderer() -> None: def test_local_contract_renderer_exposes_agent_operational_fields() -> None: payload = json.loads(render_local_contract_file()) - assert payload["schema_version"] == "7" + assert payload["schema_version"] == "8" assert payload["agents_shipgate_version"] - assert payload["contract_version"] == "19" + assert payload["contract_version"] == "20" assert payload["minimum_control_contract_version"] == "14" assert payload["primary_commands"]["verify_pr"].startswith("agents-shipgate verify") assert payload["primary_commands"]["host_audit"].startswith("shipgate audit --host") @@ -209,7 +209,25 @@ def test_local_contract_renderer_exposes_agent_operational_fields() -> None: assert payload["default_paths"]["local_contract"] == ".shipgate/agent-contract.json" assert payload["artifacts"]["verifier"] == "agents-shipgate-reports/verifier.json" assert payload["artifacts"]["verify_run"] == "agents-shipgate-reports/verify-run.json" + assert payload["current_control_schema_version"] == "shipgate.current_control/v1" + assert payload["current_control_schema_path"] == "docs/current-control-schema.v1.json" + assert payload["current_control_artifact"] == ( + "agents-shipgate-reports/current-control.json" + ) + assert payload["artifacts"]["current_control"] == ( + "agents-shipgate-reports/current-control.json" + ) + assert payload["commands"]["agent_control"].startswith("agents-shipgate agent control") + # The refresh obligation is contract data, not prose: a consumer must be + # able to enumerate the boundaries at which a cached control state expires. + assert "before enforcing a cached must_stop" in payload["agent_refresh_triggers"] + assert "before declaring the task complete" in payload["agent_refresh_triggers"] + assert payload["current_control_fallback_read_order"][0] == "current-control.json" assert payload["agent_read_order"] == [ + "current-control.json", + "current-control.json.current_control_id", + "current-control.json.lifecycle_state", + "current-control.json.control.state", "verification-receipt.json", "verification-receipt.json.request_id", "verification-receipt.json.receipt_id", diff --git a/tests/test_cli.py b/tests/test_cli.py index 8913a3f4..6cea3f18 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,6 +30,7 @@ AGENT_HANDOFF_SCHEMA_VERSION, AGENT_INTERFACE_OPERATIONS, AGENT_READ_ORDER, + AGENT_REFRESH_TRIGGERS, AGENT_RESULT_CONTROL_FIELDS, AGENT_RESULT_SCHEMA_PATH, AGENT_RESULT_SCHEMA_VERSION, @@ -38,6 +39,9 @@ CODEX_BOUNDARY_RESULT_SCHEMA_VERSION, COMMANDS, CONTRACT_VERSION, + CURRENT_CONTROL_FALLBACK_READ_ORDER, + CURRENT_CONTROL_SCHEMA_PATH, + CURRENT_CONTROL_SCHEMA_VERSION, DEFAULT_PATHS, DO_NOT_AUTO_ASSERT, EXIT_CODE_POLICY, @@ -275,6 +279,11 @@ def test_cli_contract_json_outputs_runtime_contract(): "verification_unit_result_schema_version", "verification_artifact_manifest_schema_version", "verification_receipt_schema_version", + "current_control_schema_version", + "current_control_schema_path", + "current_control_artifact", + "agent_refresh_triggers", + "current_control_fallback_read_order", "human_authorization_request_schema_version", "human_authorization_schema_version", "human_authorization_evaluation_schema_version", @@ -338,6 +347,11 @@ def test_cli_contract_json_outputs_runtime_contract(): VERIFICATION_ARTIFACT_MANIFEST_SCHEMA_VERSION ), "verification_receipt_schema_version": VERIFICATION_RECEIPT_SCHEMA_VERSION, + "current_control_schema_version": CURRENT_CONTROL_SCHEMA_VERSION, + "current_control_schema_path": CURRENT_CONTROL_SCHEMA_PATH, + "current_control_artifact": ARTIFACTS["current_control"], + "agent_refresh_triggers": list(AGENT_REFRESH_TRIGGERS), + "current_control_fallback_read_order": list(CURRENT_CONTROL_FALLBACK_READ_ORDER), "human_authorization_request_schema_version": ( HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION ), diff --git a/tests/test_current_control.py b/tests/test_current_control.py new file mode 100644 index 00000000..13ed976d --- /dev/null +++ b/tests/test_current_control.py @@ -0,0 +1,655 @@ +"""The current-control pointer and the refresh protocol built on it. + +The bug this covers is a control-plane one, not a verdict one: a coding agent +kept enforcing a `human_review_required` it had cached in conversation state +after a human committed the reviewed change and a newer complete run existed. +The same gap runs backward — a cached `complete` acted on after the workspace +moved. Both directions are exercised here against the real engine. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from agents_shipgate.cli._artifact_lifecycle import ArtifactLifecycleError +from agents_shipgate.cli.main import app +from agents_shipgate.cli.scan import writing as scan_writing +from agents_shipgate.cli.scan.orchestrator import run_scan +from agents_shipgate.cli.verification import assemble, prepare, worker +from agents_shipgate.cli.verify import orchestrator as verify_orchestrator +from agents_shipgate.cli.verify.orchestrator import run_preview, run_verify +from agents_shipgate.core import current_control as current_control_module +from agents_shipgate.core.current_control import ( + CurrentControlUnavailable, + begin_current_control, + current_control_path, + publish_current_control, + read_current_control, +) +from agents_shipgate.schemas.current_control import ( + CURRENT_CONTROL_ARTIFACT_NAME, + AgentActionRequiredCurrentControl, + CompleteCurrentControl, + CurrentControlArtifactRef, + CurrentControlPointer, + CurrentControlWorkspaceIdentity, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +SAMPLE = REPO_ROOT / "samples" / "clean_read_only_agent" +runner = CliRunner() + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """A committed workspace whose clean verify reaches ``complete``.""" + + workspace = tmp_path / "repo" + workspace.mkdir() + for name in ("shipgate.yaml", "tools.json"): + shutil.copy(SAMPLE / name, workspace / name) + # Every adopted workspace gitignores the reports directory (`init` writes + # this block). Without it, generated artifacts become worktree verification + # inputs, which is a separate pre-existing hazard this fixture should not + # simulate. + (workspace / ".gitignore").write_text("agents-shipgate-reports/\n", encoding="utf-8") + _git(workspace, "init") + _git(workspace, "config", "user.email", "test@example.test") + _git(workspace, "config", "user.name", "Test User") + _git(workspace, "add", ".") + _git(workspace, "commit", "-m", "fixture") + return workspace + + +def _verify(repo: Path, **overrides: object) -> tuple[object, object, int]: + options: dict[str, object] = { + "workspace": repo, + "config": Path("shipgate.yaml"), + "base": None, + "head": "HEAD", + "archive_head": True, + "out": repo / "agents-shipgate-reports", + "ci_mode": "advisory", + "fail_on": None, + "baseline": None, + "baseline_mode": "new-findings", + "diff_from": None, + "policy_packs": None, + "plugins_enabled": False, + "strict_plugins": False, + "suggest_patches": False, + "no_heuristics": False, + "verbose": False, + } + options.update(overrides) + return run_verify(**options) # type: ignore[arg-type] + + +def _pointer(repo: Path) -> dict[str, object]: + path = repo / "agents-shipgate-reports" / CURRENT_CONTROL_ARTIFACT_NAME + return json.loads(path.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# The two reported failure directions +# --------------------------------------------------------------------------- + + +def test_cached_stop_is_superseded_after_the_human_commits(repo: Path) -> None: + """Forward regression: the exact issue-#339 sequence. + + Worktree verify stops on a trust-root edit; a human commits the reviewed + change; the committed-ref run completes. An agent that refreshes must see + the new identity, not the `must_stop` it is holding. + """ + + manifest = repo / "shipgate.yaml" + manifest.write_text(manifest.read_text(encoding="utf-8") + "\n# reviewed\n", encoding="utf-8") + + stopped, _, _ = _verify(repo, archive_head=False) + assert stopped.control.state == "human_review_required" + cached = _pointer(repo) + assert cached["control"]["state"] == "human_review_required" + assert cached["control"]["must_stop"] is True + + _git(repo, "add", ".") + _git(repo, "commit", "-m", "human commits the reviewed change") + + completed, _, _ = _verify(repo) + assert completed.control.state == "complete" + + refreshed = read_current_control(repo / "agents-shipgate-reports").pointer + assert refreshed.current_control_id != cached["current_control_id"] + assert refreshed.control.state == "complete" + assert refreshed.control.completion_allowed is True + assert refreshed.control.must_stop is False + # Completion authority is only representable alongside the receipt that + # backs it, so "complete" can never be a leftover from another run. + assert "verification_receipt" in refreshed.artifacts + assert refreshed.request_id is not None and refreshed.decision_id is not None + + +def test_cached_completion_cannot_survive_a_workspace_mutation(repo: Path) -> None: + """Reverse regression: a remembered `complete` is not authority later. + + The pointer binds the workspace the decision was made against, so a + consumer can see that HEAD moved out from under a cached completion + without re-running anything — and the rerun then replaces the identity. + """ + + completed, _, _ = _verify(repo) + assert completed.control.state == "complete" + authorized = _pointer(repo) + assert authorized["control"]["completion_allowed"] is True + reports = repo / "agents-shipgate-reports" + + tools = repo / "tools.json" + payload = json.loads(tools.read_text(encoding="utf-8")) + payload["tools"].append( + { + "name": "docs.publish", + "description": "Publish an article to the public documentation site.", + "inputSchema": {"type": "object", "properties": {}}, + } + ) + tools.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "agent adds a write tool") + + # Nothing has re-run yet, so the pointer still reads `complete` — but it + # says which tree it was computed from, and that tree is no longer HEAD. + head_tree = subprocess.run( + ["git", "rev-parse", "HEAD^{tree}"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + cached = read_current_control(reports).pointer + assert cached.control.completion_allowed is True + assert cached.workspace_identity.head_tree_sha != head_tree + + _verify(repo) + current = read_current_control(reports).pointer + assert current.current_control_id != authorized["current_control_id"] + assert current.workspace_identity.head_tree_sha == head_tree + + +def test_an_interrupted_run_leaves_no_decision_current( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A crash must not leave the previous terminal decision advertised.""" + + _verify(repo) + assert _pointer(repo)["control"]["completion_allowed"] is True + + def explode(*args: object, **kwargs: object) -> None: + raise RuntimeError("interrupted mid-run") + + monkeypatch.setattr(verify_orchestrator, "_write_artifacts", explode) + with pytest.raises(RuntimeError): + _verify(repo) + + stranded = read_current_control(repo / "agents-shipgate-reports").pointer + assert stranded.lifecycle_state == "in_progress" + assert stranded.control.state == "unavailable" + assert stranded.control.must_stop is True + assert stranded.control.completion_allowed is False + assert stranded.artifacts == {} + + +def test_a_failed_lifecycle_cleanup_leaves_the_pointer_non_terminal( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Cleanup failure is fail-closed: invalidate happens before it.""" + + _verify(repo) + + def refuse(out_dir: Path) -> None: + raise ArtifactLifecycleError(out_dir / "verifier.json", OSError("locked")) + + monkeypatch.setattr(verify_orchestrator, "clear_verifier_route_artifacts", refuse) + with pytest.raises(ArtifactLifecycleError): + _verify(repo) + + stranded = read_current_control(repo / "agents-shipgate-reports").pointer + assert stranded.lifecycle_state == "in_progress" + assert stranded.control.must_stop is True + + +# --------------------------------------------------------------------------- +# Command scoping +# --------------------------------------------------------------------------- + + +def test_a_standalone_scan_never_retains_merge_authorization(repo: Path) -> None: + completed, _, _ = _verify(repo) + assert completed.control.state == "complete" + + run_scan( + config_path=repo / "shipgate.yaml", + output_dir=repo / "agents-shipgate-reports", + ci_mode="advisory", + plugins_enabled=False, + ) + + current = read_current_control(repo / "agents-shipgate-reports").pointer + assert current.operation == "scan" + assert current.control.state == "agent_action_required" + assert current.control.completion_allowed is False + assert "verification_receipt" not in current.artifacts + + +def test_a_preview_neither_completes_nor_binds_an_older_report(repo: Path) -> None: + _verify(repo) + reports = repo / "agents-shipgate-reports" + stale_report = (reports / "report.json").read_bytes() + + run_preview( + workspace=repo, + config=Path("shipgate.yaml"), + base=None, + head=None, + out=reports, + ) + + current = read_current_control(reports).pointer + assert current.operation == "preview" + assert current.control.completion_allowed is False + # The previous run's report survives on disk; the preview pointer must not + # present it as part of the artifact set that is current. + assert (reports / "report.json").read_bytes() == stale_report + assert "report" not in current.artifacts + assert "packet" not in current.artifacts + + +def test_verify_to_verify_supersedes_the_identity_it_replaced(repo: Path) -> None: + _verify(repo) + first = _pointer(repo) + ids: list[str] = [] + + real_begin = current_control_module.begin_current_control + + def record(out_dir: Path, **kwargs: object): + pointer = real_begin(out_dir, **kwargs) # type: ignore[arg-type] + ids.append(pointer.current_control_id) + return pointer + + verify_orchestrator.begin_current_control = record # type: ignore[assignment] + try: + _verify(repo) + finally: + verify_orchestrator.begin_current_control = real_begin # type: ignore[assignment] + + second = _pointer(repo) + # The in-progress marker supersedes the previous terminal pointer, and the + # new terminal pointer supersedes the marker: an unbroken chain with no + # instant where the old decision was current for the new run. + assert ids and ids[0] != first["current_control_id"] + assert second["supersedes"] == ids[0] + + +def test_verify_reruns_are_byte_identical_for_an_unchanged_workspace(repo: Path) -> None: + """Identity is a statement about state, not about how many runs happened.""" + + _verify(repo) + first = _pointer(repo)["current_control_id"] + _verify(repo) + assert _pointer(repo)["current_control_id"] == first + + +def test_baseline_save_does_not_disturb_current_pr_control(repo: Path) -> None: + _verify(repo) + before = _pointer(repo) + + result = runner.invoke( + app, + [ + "baseline", + "save", + "--config", + str(repo / "shipgate.yaml"), + "--out", + str(repo / ".agents-shipgate" / "baseline.json"), + ], + ) + + assert result.exit_code == 0, result.output + assert _pointer(repo) == before + + +def test_preparing_a_portable_plan_invalidates_before_it_reads_inputs(repo: Path) -> None: + """`prepare` opens a new lifecycle, so the previous decision stops being current. + + It invalidates before reading a single input rather than just before + writing the plan, so the plan and any later replay of it observe one stable + directory state. + """ + + _verify(repo) + reports = repo / "agents-shipgate-reports" + assert _pointer(repo)["control"]["completion_allowed"] is True + + prepare( + workspace=repo, + config=Path("shipgate.yaml"), + base=None, + head=None, + baseline=None, + diff_from=None, + policy_packs=None, + ci_mode="advisory", + no_plugins=True, + no_heuristics=False, + evaluation_date=None, + out=reports / "verification-plan.json", + ) + + opened = read_current_control(reports).pointer + assert opened.lifecycle_state == "in_progress" + assert opened.control.must_stop is True + # The prepared plan replays cleanly: nothing the invalidation touched moved + # after the plan hashed its inputs. + worker( + plan_path=reports / "verification-plan.json", + workspace=repo, + diff_path=reports / "verification-input.diff", + out=reports / "replayed-unit.json", + ) + assert (reports / "replayed-unit.json").is_file() + + +def test_the_assembler_publishes_the_terminal_pointer(repo: Path) -> None: + """The portable assembler closes the lifecycle the same way verify does.""" + + _verify(repo) + reports = repo / "agents-shipgate-reports" + unit = reports / "distributed-unit.json" + worker( + plan_path=reports / "verification-plan.json", + workspace=repo, + diff_path=reports / "verification-input.diff", + out=unit, + ) + begin_current_control(reports, operation="verify", reason="Assembly pending.") + + assemble( + plan_path=reports / "verification-plan.json", + unit_paths=[unit], + verifier_path=reports / "verifier.json", + artifacts_root=reports, + out=reports / "verification-receipt.json", + ) + + closed = read_current_control(reports).pointer + assert closed.operation == "verify" + assert closed.lifecycle_state == "terminal" + assert closed.control.state == "complete" + assert "verification_receipt" in closed.artifacts + assert closed.request_id is not None and closed.decision_id is not None + + +# --------------------------------------------------------------------------- +# Reader protocol +# --------------------------------------------------------------------------- + + +def test_reader_rejects_a_mixed_artifact_set(repo: Path) -> None: + _verify(repo) + reports = repo / "agents-shipgate-reports" + verifier = reports / "verifier.json" + verifier.write_text(verifier.read_text(encoding="utf-8") + "\n", encoding="utf-8") + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports) + assert raised.value.reason == "artifact_mismatch" + + +def test_reader_rejects_a_generation_change_underneath_it( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A run that republishes mid-read must fail the read, not blend it.""" + + _verify(repo) + reports = repo / "agents-shipgate-reports" + real_validate = current_control_module._validate_bound_artifacts + + def republish_then_validate(out_dir: Path, pointer: CurrentControlPointer) -> None: + real_validate(out_dir, pointer) + begin_current_control( + out_dir, + operation="verify", + reason="A competing run started while the pointer was being read.", + ) + + monkeypatch.setattr( + current_control_module, "_validate_bound_artifacts", republish_then_validate + ) + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports, attempts=1) + assert raised.value.reason == "generation_changed" + + +def test_reader_retries_past_a_republish_but_not_past_real_tampering( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A concurrent run should not permanently deny a reader. + + An artifact mismatch under a pointer that also moved is a race worth + retrying; the same mismatch under a pointer that did not move is a real + inconsistency and must surface. + """ + + _verify(repo) + reports = repo / "agents-shipgate-reports" + real_validate = current_control_module._validate_bound_artifacts + calls: list[int] = [] + + def fail_once_then_republish(out_dir: Path, pointer: CurrentControlPointer) -> None: + calls.append(1) + if len(calls) == 1: + begin_current_control( + out_dir, operation="verify", reason="A competing run started." + ) + publish_current_control( + out_dir, + operation="scan", + control=AgentActionRequiredCurrentControl( + state="agent_action_required", reason="The competing run finished." + ), + ) + raise CurrentControlUnavailable("artifact_mismatch", "raced", path=out_dir) + real_validate(out_dir, pointer) + + monkeypatch.setattr( + current_control_module, "_validate_bound_artifacts", fail_once_then_republish + ) + assert read_current_control(reports).pointer.operation == "scan" + assert len(calls) == 2 + + +def test_reader_refuses_a_missing_pointer_rather_than_falling_back(tmp_path: Path) -> None: + empty = tmp_path / "reports" + empty.mkdir() + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(empty) + assert raised.value.reason == "missing" + + +def test_reader_refuses_a_symlinked_pointer(tmp_path: Path) -> None: + reports = tmp_path / "reports" + reports.mkdir() + elsewhere = tmp_path / "elsewhere.json" + elsewhere.write_text("{}", encoding="utf-8") + os.symlink(elsewhere, current_control_path(reports)) + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports) + assert raised.value.reason == "unsafe_pointer" + + +def test_reader_refuses_a_symlinked_artifact(tmp_path: Path) -> None: + reports = tmp_path / "reports" + reports.mkdir() + (reports / "verifier.json").write_text("{}", encoding="utf-8") + publish_current_control( + reports, + operation="scan", + control=AgentActionRequiredCurrentControl( + state="agent_action_required", reason="Scan only." + ), + ) + outside = tmp_path / "outside.json" + outside.write_text("{}", encoding="utf-8") + (reports / "verifier.json").unlink() + os.symlink(outside, reports / "verifier.json") + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports) + assert raised.value.reason == "artifact_unreadable" + + +def test_publish_leaves_no_temporary_file_behind(tmp_path: Path) -> None: + reports = tmp_path / "reports" + reports.mkdir() + begin_current_control(reports, operation="scan", reason="Starting.") + assert [path.name for path in reports.iterdir()] == [CURRENT_CONTROL_ARTIFACT_NAME] + + +# --------------------------------------------------------------------------- +# Structural invariants +# --------------------------------------------------------------------------- + + +def _artifact_ref(name: str = "verifier.json") -> CurrentControlArtifactRef: + return CurrentControlArtifactRef(path=name, sha256="sha256:" + "a" * 64, size_bytes=1) + + +def _pointer_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "current_control_id": "sha256:" + "0" * 64, + "operation": "verify", + "lifecycle_state": "terminal", + "workspace_identity": CurrentControlWorkspaceIdentity(), + "control": CompleteCurrentControl(state="complete", reason="Release ready."), + "artifacts": {"verification_receipt": _artifact_ref("verification-receipt.json")}, + "request_id": "sha256:" + "1" * 64, + "decision_id": "sha256:" + "2" * 64, + } + payload.update(overrides) + return payload + + +@pytest.mark.parametrize("operation", ["scan", "preview"]) +def test_only_verify_can_publish_completion_authority(operation: str) -> None: + with pytest.raises(ValueError, match="only verify"): + CurrentControlPointer(**_pointer_payload(operation=operation)) + + +def test_completion_authority_requires_a_bound_receipt() -> None: + with pytest.raises(ValueError, match="terminal receipt"): + CurrentControlPointer(**_pointer_payload(artifacts={"verifier": _artifact_ref()})) + + +def test_completion_authority_requires_a_bound_request_and_decision() -> None: + with pytest.raises(ValueError, match="bound request and decision"): + CurrentControlPointer(**_pointer_payload(request_id=None)) + + +def test_an_in_progress_pointer_cannot_carry_a_settled_decision() -> None: + with pytest.raises(ValueError, match="deny every cached decision"): + CurrentControlPointer(**_pointer_payload(lifecycle_state="in_progress")) + + +def test_a_terminal_pointer_must_bind_something() -> None: + with pytest.raises(ValueError, match="at least one artifact"): + CurrentControlPointer( + **_pointer_payload( + artifacts={}, + control=AgentActionRequiredCurrentControl( + state="agent_action_required", reason="Run verify." + ), + request_id=None, + decision_id=None, + ) + ) + + +@pytest.mark.parametrize("path", ["../escape.json", "/absolute.json", "a/../b.json"]) +def test_bound_artifact_paths_stay_portable_and_contained(path: str) -> None: + with pytest.raises(ValueError, match="portable relative paths"): + CurrentControlArtifactRef(path=path, sha256="sha256:" + "a" * 64, size_bytes=1) + + +def test_a_pointer_cannot_be_hand_edited_without_breaking_its_identity() -> None: + """Every field the pointer asserts is inside `current_control_id`. + + ``_pointer_payload`` is otherwise valid, so the only thing left to reject + is its placeholder identity. + """ + + with pytest.raises(ValueError, match="current_control_id must hash"): + CurrentControlPointer(**_pointer_payload()) + + +# --------------------------------------------------------------------------- +# The reader command +# --------------------------------------------------------------------------- + + +def test_agent_control_command_prints_the_validated_pointer(repo: Path) -> None: + _verify(repo) + result = runner.invoke( + app, + ["agent", "control", "--reports-dir", str(repo / "agents-shipgate-reports")], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["schema_version"] == "shipgate.current_control/v1" + assert payload["control"]["state"] == "complete" + + +def test_agent_control_command_fails_closed_when_nothing_is_current(tmp_path: Path) -> None: + empty = tmp_path / "reports" + empty.mkdir() + result = runner.invoke(app, ["agent", "control", "--reports-dir", str(empty)]) + + assert result.exit_code == 3 + assert "unavailable" in result.output + + +def test_scan_publishes_its_own_pointer_only_when_it_owns_the_lifecycle( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify's internal head scan must not claim the PR's control identity.""" + + operations: list[str] = [] + real_publish = scan_writing.publish_current_control + + def record(out_dir: Path, **kwargs: object): + operations.append(str(kwargs["operation"])) + return real_publish(out_dir, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(scan_writing, "publish_current_control", record) + _verify(repo) + assert operations == [] + + run_scan( + config_path=repo / "shipgate.yaml", + output_dir=repo / "agents-shipgate-reports", + ci_mode="advisory", + plugins_enabled=False, + ) + assert operations == ["scan"] diff --git a/tests/test_local_contract.py b/tests/test_local_contract.py index 4f0c137b..4e6c777e 100644 --- a/tests/test_local_contract.py +++ b/tests/test_local_contract.py @@ -33,6 +33,11 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None: "verification_unit_result_schema_version", "verification_artifact_manifest_schema_version", "verification_receipt_schema_version", + "current_control_schema_version", + "current_control_schema_path", + "current_control_artifact", + "agent_refresh_triggers", + "current_control_fallback_read_order", "human_authorization_request_schema_version", "human_authorization_schema_version", "human_authorization_evaluation_schema_version", @@ -64,9 +69,9 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None: "release_decisions", "do_not_auto_assert", ] - assert payload["schema_version"] == LOCAL_CONTRACT_SCHEMA_VERSION == "7" + assert payload["schema_version"] == LOCAL_CONTRACT_SCHEMA_VERSION == "8" assert payload["agents_shipgate_version"] == __version__ - assert payload["contract_version"] == CONTRACT_VERSION == "19" + assert payload["contract_version"] == CONTRACT_VERSION == "20" assert payload["minimum_control_contract_version"] == "14" assert payload["default_paths"]["local_contract"] == LOCAL_CONTRACT_RELATIVE_PATH assert payload["primary_commands"] == dict(PRIMARY_COMMANDS) @@ -95,6 +100,10 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None: assert payload["artifacts"]["verify_run"] == "agents-shipgate-reports/verify-run.json" assert payload["artifacts"]["agent_handoff"] == "agents-shipgate-reports/agent-handoff.json" assert payload["agent_read_order"] == [ + "current-control.json", + "current-control.json.current_control_id", + "current-control.json.lifecycle_state", + "current-control.json.control.state", "verification-receipt.json", "verification-receipt.json.request_id", "verification-receipt.json.receipt_id", diff --git a/tests/test_public_surface_contract.py b/tests/test_public_surface_contract.py index 44cabc60..c46d6c91 100644 --- a/tests/test_public_surface_contract.py +++ b/tests/test_public_surface_contract.py @@ -2547,7 +2547,8 @@ def test_no_singular_underscore_module_name(relpath): def test_read_first_instructions_match_contract_agent_read_order(relpath): """Every 'Read `` first' instruction must name the first artifact in the runtime contract's agent_read_order - (verification-receipt.json since contract v17), optionally with a + (current-control.json since contract v20; verification-receipt.json in + v17-v19), optionally with a reports-dir prefix or a field path suffix. README shipped contradictory first-artifact instructions before this invariant; this pins the prose surfaces to the contract so the contradiction cannot @@ -2555,7 +2556,7 @@ def test_read_first_instructions_match_contract_agent_read_order(relpath): mentioning it is fine, telling an agent to read it *first* is not.""" contract = build_contract_payload().model_dump(mode="json") first_artifact = contract["agent_read_order"][0] - assert first_artifact == "verification-receipt.json", ( + assert first_artifact == "current-control.json", ( "contract agent_read_order[0] changed; sweep the read-first " "prose on READ_FIRST_SURFACES, then update this pin." ) diff --git a/tests/test_schema_boundaries.py b/tests/test_schema_boundaries.py index d97b5b66..f79524da 100644 --- a/tests/test_schema_boundaries.py +++ b/tests/test_schema_boundaries.py @@ -398,6 +398,11 @@ def test_representative_schema_payloads_keep_wire_fields() -> None: "shipgate.verification_artifact_manifest/v1" ), verification_receipt_schema_version="shipgate.verification_receipt/v1", + current_control_schema_version="shipgate.current_control/v1", + current_control_schema_path="docs/current-control-schema.v1.json", + current_control_artifact="agents-shipgate-reports/current-control.json", + agent_refresh_triggers=["before declaring the task complete"], + current_control_fallback_read_order=["current-control.json"], human_authorization_request_schema_version=( "shipgate.human_authorization_request/v1" ), @@ -476,6 +481,11 @@ def test_representative_schema_payloads_keep_wire_fields() -> None: "shipgate.verification_artifact_manifest/v1" ), "verification_receipt_schema_version": "shipgate.verification_receipt/v1", + "current_control_schema_version": "shipgate.current_control/v1", + "current_control_schema_path": "docs/current-control-schema.v1.json", + "current_control_artifact": "agents-shipgate-reports/current-control.json", + "agent_refresh_triggers": ["before declaring the task complete"], + "current_control_fallback_read_order": ["current-control.json"], "human_authorization_request_schema_version": ( "shipgate.human_authorization_request/v1" ), From 550dd18aee9e52a8618e20b7599b4780f93ed571 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Fri, 7 Aug 2026 20:13:06 -0700 Subject: [PATCH 2/5] fix(control): make the pointer generation-consistent, not just byte-consistent Review of #347 found three flows where the pointer's hashes all still checked out while the thing they described had moved on. Byte consistency is not generation consistency. 1. `agent control` never compared the pointer's recorded workspace identity with the live repository, so a single unrelated commit left an intact artifact set advertising `completion_allowed: true` for a workspace that no longer existed -- exactly the reverse stale-authorization failure the pointer exists to prevent, and exactly what the documented "refresh after commit/rebase/checkout" trigger was supposed to catch. The reader is now workspace-aware via `--workspace` (default `.`): it compares repository, HEAD commit, and HEAD tree, and for a worktree run it recomputes the overlay from the plan's changed-path set using the same normalization the plan used. The change *set* is compared too, because a file edited after the decision appears in neither HEAD nor that overlay. Completion authority is never returned without the comparison: a workspace that cannot be resolved is reported as unverified rather than passing. The reports directory is excluded from the change set exactly as `verify` excludes it, so a run's own output is not mistaken for drift. 2. `assemble` accepts any `--out` name beneath `--artifacts-root`, but the pointer binds the canonical `verification-receipt.json`. With an older canonical receipt present and a new receipt emitted elsewhere, assembly published `complete` for one request while binding a receipt for another. Completion now requires the bound receipt's `request_id` and `decision_id` to be the ones the pointer records -- enforced when publishing, and again when reading, so a pointer from an older producer is caught too. 3. A standalone scan bound every recognized file that happened to exist, so `scan --format markdown` after a verify claimed that verifier's untouched `report.json` as current -- a pointer with null request/decision ids beside a report still carrying the previous run's. The scan pointer's artifact set is now derived from the formats that scan actually wrote. `report.md` and `report.sarif` became bindable keys so a markdown-only scan still binds real evidence, and a scan configured to emit nothing binds nothing rather than adopting another run's output. Refusal reasons now map explicitly to exit codes: an unreadable or inconsistent artifact set exits 3 (the missing/parse family), while a set that reads fine but is no longer current exits 4. Both deny authority; the split tells a caller whether to repair a directory or simply re-verify. Six regressions added, each reproducing its finding first. Co-Authored-By: Claude Opus 5 --- .agents/skills/agents-shipgate/SKILL.md | 2 +- .../references/report-reading.md | 5 +- .cursor/rules/agents-shipgate.mdc | 4 +- AGENTS.md | 10 +- CHANGELOG.md | 20 +- README.md | 5 +- .../.agents-shipgate-kit-metadata.json | 3 +- adoption-kits/claude-code-skill/SKILL.md | 6 +- .../.agents-shipgate-kit-metadata.json | 6 +- adoption-kits/codex-skill/SKILL.md | 2 +- .../codex-skill/references/report-reading.md | 5 +- docs/agent-contract-current.md | 26 +- docs/target-repo-agent-snippets.md | 12 +- llms-full.txt | 36 ++- llms.txt | 2 +- .../skills/agents-shipgate/SKILL.md | 2 +- .../references/report-reading.md | 5 +- .../skills/agents-shipgate/SKILL.md | 6 +- skills/agents-shipgate/SKILL.md | 6 +- src/agents_shipgate/cli/agent_interface.py | 93 +++++- .../agent_instructions/renderers/_shared.py | 4 +- src/agents_shipgate/cli/scan/writing.py | 11 + src/agents_shipgate/core/current_control.py | 278 +++++++++++++++++- .../core/verification_identity.py | 13 + src/agents_shipgate/schemas/contract.py | 3 +- .../schemas/current_control.py | 7 +- tests/test_agent_instructions_renderers.py | 6 +- tests/test_current_control.py | 257 +++++++++++++--- 28 files changed, 716 insertions(+), 119 deletions(-) diff --git a/.agents/skills/agents-shipgate/SKILL.md b/.agents/skills/agents-shipgate/SKILL.md index fb404a2b..02ed685c 100644 --- a/.agents/skills/agents-shipgate/SKILL.md +++ b/.agents/skills/agents-shipgate/SKILL.md @@ -20,7 +20,7 @@ Do not use it for general linting, runtime monitoring, evals, model-output quali 5. Default first-time CI to advisory mode. Do not enable release-blocking CI or save a baseline until a human has reviewed current findings. 6. For local agent control, run `shipgate check --agent codex --workspace . --format agent-boundary-json` and read the stdout `shipgate.agent_boundary_result/v1` object. Switch on `control.state`; follow only `control.next_action`, `control.allowed_next_commands`, and `control.human_review`. Treat `decision` as diagnostic context only. 7. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. -8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control` before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. +8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control --workspace .` — which refuses the read when HEAD, the tree, or the working tree has moved since the decision — before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. 9. Auto-apply only high-confidence safe patches. Do not auto-assert action effect, action authority, agent bindings, approval, confirmation, idempotency, broad-scope, prohibited-action, or runtime-trace evidence. 10. Ensure `.gitignore` covers `agents-shipgate-reports/` before committing. diff --git a/.agents/skills/agents-shipgate/references/report-reading.md b/.agents/skills/agents-shipgate/references/report-reading.md index 95110ddd..42ea39a8 100644 --- a/.agents/skills/agents-shipgate/references/report-reading.md +++ b/.agents/skills/agents-shipgate/references/report-reading.md @@ -1,8 +1,9 @@ # Reading Agents Shipgate Reports For verify runs, read `agents-shipgate-reports/current-control.json` first — -via `agents-shipgate agent control` — because it names which run is current and -a non-zero exit means none is. Then validate the +via `agents-shipgate agent control --workspace .` — because it names which run +is current, checks it against the repository as it stands right now, and a +non-zero exit means none is. Then validate the `agents-shipgate-reports/verification-receipt.json` it binds. Then read `agents-shipgate-reports/agent-handoff.json`. After that, read `agents-shipgate-reports/verifier.json` for detailed control context diff --git a/.cursor/rules/agents-shipgate.mdc b/.cursor/rules/agents-shipgate.mdc index 553c9d07..8c8b5c59 100644 --- a/.cursor/rules/agents-shipgate.mdc +++ b/.cursor/rules/agents-shipgate.mdc @@ -70,7 +70,9 @@ projections and not the CI gate. `agents-shipgate-reports/current-control.json` is the one entry point that says which control identity is current. Read it with `agents-shipgate agent -control`; a non-zero exit means nothing is current here and you hold no +control --workspace .`, which checks the pointer against the repository as it +stands right now — a moved HEAD, a changed tree, or an edited working file +refuses the read. A non-zero exit means nothing is current here and you hold no authority. Re-read it after any human or external-tool action, after commit, rebase, checkout, pull, or any worktree change, after any agents-shipgate command returns, before enforcing a cached `must_stop`, before commit/push/PR diff --git a/AGENTS.md b/AGENTS.md index 8ff6767b..de4773fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,12 +150,16 @@ override. Read the pointer with: ```bash -agents-shipgate agent control --reports-dir agents-shipgate-reports +agents-shipgate agent control --workspace . --reports-dir agents-shipgate-reports ``` A zero exit means the printed pointer was validated against every artifact it -binds and did not move while it was read. A non-zero exit means no control -identity is current here — you hold no authority, and a remembered result does +binds, still describes the repository as it stands right now, and did not move +while it was read. Byte consistency is not generation consistency: one commit +is enough to make an intact artifact set describe a workspace that has moved, +so the read compares the pointer's HEAD, tree, and worktree overlay against the +live repository and refuses on drift. A non-zero exit means no control identity +is current here — you hold no authority, and a remembered result does not substitute for one. Re-read it after any human or external-tool action, after commit, rebase, checkout, pull, or any worktree change, after any agents-shipgate command returns, before enforcing a cached `must_stop`, before diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c9c68b..1b30ecf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,11 +28,23 @@ agent control`: validate the pointer, validate every artifact hash it binds, re-read the pointer, and continue only if `current_control_id` is unchanged — a run that republishes mid-read makes the read fail rather than return one - generation's pointer beside another's artifacts. Two invariants are structural - rather than advisory: only an `operation: "verify"` pointer can carry + generation's pointer beside another's artifacts. And because byte consistency + is not generation consistency — every bound artifact still hashes correctly + one unrelated commit later — the read also compares the pointer's + `workspace_identity` against the live repository: repository, HEAD commit, + HEAD tree, and, for a worktree run, both the recomputed overlay and the + current set of uncommitted paths, since a file edited *after* the decision + appears in neither HEAD nor that overlay. Completion authority is never + returned without that comparison. Two invariants are structural rather than + advisory: only an `operation: "verify"` pointer can carry `control.state: "complete"`, and only when it also binds a - `verification_receipt` for that exact request, so a scan or a preview cannot - represent completion authority at all. Supporting scans stay isolated — + `verification_receipt` whose request and decision are the ones the pointer + records — the assembler accepts any `--out` name under its artifacts root, so + an older canonical receipt cannot be mistaken for the one a run just closed. + A scan or a preview cannot represent completion authority at all, and each + pointer binds only the artifacts its own run wrote: a `scan --format markdown` + after a verify no longer claims that verifier's `report.json`. + Supporting scans stay isolated — `verify`'s internal head scan does not take over the PR's control identity, and `baseline save` already scanned into a temporary directory. Contract `19 → 20` adds `current_control_schema_version`, `current_control_artifact`, diff --git a/README.md b/README.md index a1bbbb5c..61f264d6 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,9 @@ The PR/control surface is `agents-shipgate-reports/verifier.json` → `merge_verdict` (`mergeable | human_review_required | insufficient_evidence | blocked | unknown`), a deterministic projection of the release decision. Read `agents-shipgate-reports/current-control.json` first — it names which run -is current, and `agents-shipgate agent control` validates it against every -artifact it binds. Then validate the `verification-receipt.json` it binds; then +is current, and `agents-shipgate agent control --workspace .` validates it +against every artifact it binds and against the live repository, refusing the +read when HEAD or the working tree has moved since the decision. Then validate the `verification-receipt.json` it binds; then read `agent-handoff.json` (`control.state`, then `gate.merge_verdict`), followed by the authoritative control substrate `verifier.json` for `control`, `merge_verdict`, diff --git a/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json b/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json index 592214c5..d389beba 100644 --- a/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json +++ b/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json @@ -38,7 +38,8 @@ "02e780f5a1506d948e4c1d77f6ee4c6b4193227a4fd2ced081847d1fb2e5fbd0", "bc5cd31a5c4d4f6a1ebf6a04db3f80480e7cc5f9ab2b7a6f7e3f62e8ddfc3937", "58ea3b6bba89078ec54d6b5493ffebf9250d9619fbacef5090285b009e58cdcd", - "f6771ad16589854d51604cf35af0d78022b6a28e5ffa97a1d0c429ee6b6107da" + "f6771ad16589854d51604cf35af0d78022b6a28e5ffa97a1d0c429ee6b6107da", + "60aae4e35c309253cc8a851b1096b586c02ae97e688a1e3885056e0f344ccaa7" ], "prompts/add-shipgate-to-repo.md": [ "ea3c37cfbbd42c40d164abfe21d468a3a5550d5384125f94a53c947dea6b4b2a", diff --git a/adoption-kits/claude-code-skill/SKILL.md b/adoption-kits/claude-code-skill/SKILL.md index facedb67..fb5965cf 100644 --- a/adoption-kits/claude-code-skill/SKILL.md +++ b/adoption-kits/claude-code-skill/SKILL.md @@ -53,7 +53,9 @@ Always: `fix_task`, and `capability_review.top_changes`. Then parse `agents-shipgate-reports/report.json.release_decision.decision`; it is the release gate. Refresh `agents-shipgate-reports/current-control.json` with - `agents-shipgate agent control` before you act on any of that, and again + `agents-shipgate agent control --workspace .` — which refuses the read when + HEAD, the tree, or the working tree has moved since the decision — before + you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if @@ -82,7 +84,7 @@ For non-GitHub CI (GitLab, CircleCI, Jenkins, Azure Pipelines, Buildkite, Bitbuc - **CLI surface** follows the current 0.x contract line — see https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/STABILITY.md. - **Installed CLI contract**: when available, run `agents-shipgate contract --json` to verify local schema versions, capability/research surfaces, `release_decision.decision`, and manual-review signal fields. Older installs should use [`docs/agent-contract-current.md`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md) or upgrade before automating against the local contract command. - **Verifier JSON**: `verifier_schema_version: "0.7"`. Switch on `control.state`, then read `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, `capability_review.top_changes`, `trust_root_touched`, and `policy_weakened` before summarizing an AI-generated PR. `merge_verdict` is a deterministic projection; the gate remains `report.json.release_decision.decision`. Check `diff_status.completeness` before you believe any negative result: only `"complete"` means the PR diff was actually read. Anything else (`reason` is one of `not_attempted`, `refs_missing`, `merge_base_missing`, `unrelated_histories`, `objects_missing`, `metadata_limit_exceeded`, `body_limit_exceeded`, `git_timeout`, `git_failed`) means evidence was missing: follow `remediation`, and never report the PR as unrelated to agent capabilities. Then read `trigger.evaluation_status` for what that cost. `"not_evaluated"` (with `trigger.should_run` `null`) means no verdict exists. `"evaluated"` on an incomplete diff is not a contradiction — evidence that did not depend on the missing bytes already proved Shipgate should run — so honor `should_run: true` instead of overriding it, and still recover the diff before trusting a merge verdict. That evidence is either a rule matched on the change set or, in an already-adopted repository, `force_run: true` from the manifest alone; check `matched_rules` before attributing the verdict to anything the diff showed. -- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt`; a `scan` or `preview` pointer never authorizes completion or merge. +- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt` that closes that exact request; a `scan` or `preview` pointer never authorizes completion or merge. Reading it is workspace-checked: byte-intact artifacts still describe a workspace that a single commit has moved past, so the reader compares the bound HEAD, tree, and worktree overlay against the live repository and refuses on drift. - **Verification receipt**: `verification-receipt.json` uses `schema_version: "shipgate.verification_receipt/v1"` and is written last. Validate it before trusting any projected verdict; it content-addresses the request, executor, unit result, decision, and complete artifact set. - **Verify run JSON**: `verify-run.json` uses `schema_version: "shipgate.verify_run/v3"`, embeds the content-addressed plan and executor, and binds unit-result and decision IDs. `run_id` is an exact compatibility alias of `request_id`; do not treat the run projection as a second gate. - **Report JSON**: `report_schema_version: "0.34"`. Read `release_decision.decision` first. A `passed` decision requires a complete root-reachable static binding graph plus complete, conflict-free identity, effect, and authority evidence for every reachable action; it does not prove runtime behavior. Preserve `release_decision.static_analysis_only=true`, `runtime_behavior_verified=false`, and `static_verdict_disclaimer` in summaries. Read `release_decision.evidence_coverage.binding_coverage`, `semantic_coverage`, `identity_coverage`, and `policy_gap_count`, then work every `evidence_gaps[].next_action` in order. Binding, semantic, and policy-applicability gaps are not Findings and cannot be suppressed, baselined, severity-overridden, cleared by `--no-heuristics`, or satisfied by `human_ack`; binding, effect, and authority declarations are human assertions and must never be auto-written. Use `tool_catalog[]` for diagnostics and `tool_inventory[]` for the proven reachable surface. The current schema is [`docs/report-schema.v0.34.json`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/report-schema.v0.34.json); v0.33 is a frozen compatibility reference. See the [current agent contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md), [verification identity contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/verification-reproducibility.md), and [evidence-backed passed contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/passed-verdict-contract.md). diff --git a/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json b/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json index 6f98a9b0..6c15dc35 100644 --- a/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json +++ b/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json @@ -20,7 +20,8 @@ "a8dd8e22d9a9dd3358f9d7d328f6f5da5d80ac142681dfdb28b96b44bc68004b", "ad6ca3c53872f1d7e2d8a42794a766f51f0c50a8b4599bb3b76ddd2e31260af1", "4cbd6a9b978bb142908b8601f52fa1b8fe6a0aa89ecd05eec28f3b00f470ff04", - "10fcba81a9d07d7f1736fd3177c162dfdc92d5a106469c3def907d233a270dd4" + "10fcba81a9d07d7f1736fd3177c162dfdc92d5a106469c3def907d233a270dd4", + "4bde76605784f71eaf6fe57f0c3f4e44e204f10f4eb023edb15424710928894f" ], "references/recipes.md": [ "df5110bfa05eeabd9b918d8902b5c054fa547d1155be61ef6e7d7d63378bf210", @@ -45,7 +46,8 @@ "6d2848f3436f6e246bf553e6cf061c990888d6ff39eb82fec9a41f291b2e94fe", "7baeb0715e59daf92cf78fb9fee6e4f659174f5fcb6c9a1e3eca9f73fc697503", "b652d59a846ccf131df71cf284e10f2e5a72c6c1da6d5454067f1a6a457452d5", - "a5dd0eec215e973c403a22bdbbf0eca76a1796e20be5ae9623829c9d11d2a8dc" + "a5dd0eec215e973c403a22bdbbf0eca76a1796e20be5ae9623829c9d11d2a8dc", + "7b70e3ab34ed2f74175e2fd14df0ef98bdddd41b7c0460d10684f17f8cf3c00c" ], "agents/openai.yaml": [ "4d94a724336e5d36a2769630495f341007580e4dee306bc42a1aeca1af9e867b" diff --git a/adoption-kits/codex-skill/SKILL.md b/adoption-kits/codex-skill/SKILL.md index fb404a2b..02ed685c 100644 --- a/adoption-kits/codex-skill/SKILL.md +++ b/adoption-kits/codex-skill/SKILL.md @@ -20,7 +20,7 @@ Do not use it for general linting, runtime monitoring, evals, model-output quali 5. Default first-time CI to advisory mode. Do not enable release-blocking CI or save a baseline until a human has reviewed current findings. 6. For local agent control, run `shipgate check --agent codex --workspace . --format agent-boundary-json` and read the stdout `shipgate.agent_boundary_result/v1` object. Switch on `control.state`; follow only `control.next_action`, `control.allowed_next_commands`, and `control.human_review`. Treat `decision` as diagnostic context only. 7. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. -8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control` before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. +8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control --workspace .` — which refuses the read when HEAD, the tree, or the working tree has moved since the decision — before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. 9. Auto-apply only high-confidence safe patches. Do not auto-assert action effect, action authority, agent bindings, approval, confirmation, idempotency, broad-scope, prohibited-action, or runtime-trace evidence. 10. Ensure `.gitignore` covers `agents-shipgate-reports/` before committing. diff --git a/adoption-kits/codex-skill/references/report-reading.md b/adoption-kits/codex-skill/references/report-reading.md index 95110ddd..42ea39a8 100644 --- a/adoption-kits/codex-skill/references/report-reading.md +++ b/adoption-kits/codex-skill/references/report-reading.md @@ -1,8 +1,9 @@ # Reading Agents Shipgate Reports For verify runs, read `agents-shipgate-reports/current-control.json` first — -via `agents-shipgate agent control` — because it names which run is current and -a non-zero exit means none is. Then validate the +via `agents-shipgate agent control --workspace .` — because it names which run +is current, checks it against the repository as it stands right now, and a +non-zero exit means none is. Then validate the `agents-shipgate-reports/verification-receipt.json` it binds. Then read `agents-shipgate-reports/agent-handoff.json`. After that, read `agents-shipgate-reports/verifier.json` for detailed control context diff --git a/docs/agent-contract-current.md b/docs/agent-contract-current.md index e340c292..f57ff1bd 100644 --- a/docs/agent-contract-current.md +++ b/docs/agent-contract-current.md @@ -120,10 +120,18 @@ Downstream repos generated with ## Two read entry points Both start at `agents-shipgate-reports/current-control.json` (`agents-shipgate -agent control`), which names the run that is current. Everything below it -describes *a* run; only the pointer says *which* run. A non-zero exit from the -reader means no control identity is current here and the caller holds no -authority — not that the previous answer still stands. +agent control --workspace .`), which names the run that is current. Everything +below it describes *a* run; only the pointer says *which* run. A non-zero exit +from the reader means no control identity is current here and the caller holds +no authority — not that the previous answer still stands. + +Byte consistency is not generation consistency. A pointer whose artifacts all +still hash correctly can describe a workspace that one commit has moved past, so +the reader compares the bound `workspace_identity` against the live repository — +repository, HEAD commit, HEAD tree, and, for a worktree run, both the recomputed +overlay and the current set of uncommitted paths — and refuses on any drift. +Completion authority is never returned without that comparison: a reader that +cannot resolve the workspace reports it as unverified rather than passing. Given a current pointer, there are two correct "read first" paths; which one applies depends on who is reading. They are not two decisions — they are two @@ -165,9 +173,13 @@ filenames already present in the output directory: `current-control.json` records which of those two just happened in its `operation` field, so the choice does not have to be inferred from filenames at all. Only an `operation: "verify"` pointer can carry `control.state: -"complete"`, and only when it also binds a `verification_receipt` for that exact -request; a `scan` or `preview` pointer is structurally incapable of authorizing -completion or merge. While a run is in flight the pointer reads +"complete"`, and only when it also binds a `verification_receipt` whose +`request_id` and `decision_id` are the ones the pointer records — the assembler +accepts any `--out` name under its artifacts root, so an older canonical receipt +must not be mistaken for the one a run just closed. A `scan` or `preview` +pointer is structurally incapable of authorizing completion or merge, and each +binds only the artifacts it actually wrote: a `scan --format markdown` after a +verify does not claim that verifier's `report.json`. While a run is in flight the pointer reads `lifecycle_state: "in_progress"` with `control.state: "unavailable"`, `must_stop: true`, so an interrupted or crashed run leaves a directory that denies cached control rather than one that still authorizes it. Consumers built diff --git a/docs/target-repo-agent-snippets.md b/docs/target-repo-agent-snippets.md index dfedf2e9..3ce0e408 100644 --- a/docs/target-repo-agent-snippets.md +++ b/docs/target-repo-agent-snippets.md @@ -87,7 +87,9 @@ projections and not the CI gate. `agents-shipgate-reports/current-control.json` is the one entry point that says which control identity is current. Read it with `agents-shipgate agent -control`; a non-zero exit means nothing is current here and you hold no +control --workspace .`, which checks the pointer against the repository as it +stands right now — a moved HEAD, a changed tree, or an edited working file +refuses the read. A non-zero exit means nothing is current here and you hold no authority. Re-read it after any human or external-tool action, after commit, rebase, checkout, pull, or any worktree change, after any agents-shipgate command returns, before enforcing a cached `must_stop`, before commit/push/PR @@ -206,7 +208,9 @@ projections and not the CI gate. `agents-shipgate-reports/current-control.json` is the one entry point that says which control identity is current. Read it with `agents-shipgate agent -control`; a non-zero exit means nothing is current here and you hold no +control --workspace .`, which checks the pointer against the repository as it +stands right now — a moved HEAD, a changed tree, or an edited working file +refuses the read. A non-zero exit means nothing is current here and you hold no authority. Re-read it after any human or external-tool action, after commit, rebase, checkout, pull, or any worktree change, after any agents-shipgate command returns, before enforcing a cached `must_stop`, before commit/push/PR @@ -303,7 +307,9 @@ projections and not the CI gate. `agents-shipgate-reports/current-control.json` is the one entry point that says which control identity is current. Read it with `agents-shipgate agent -control`; a non-zero exit means nothing is current here and you hold no +control --workspace .`, which checks the pointer against the repository as it +stands right now — a moved HEAD, a changed tree, or an edited working file +refuses the read. A non-zero exit means nothing is current here and you hold no authority. Re-read it after any human or external-tool action, after commit, rebase, checkout, pull, or any worktree change, after any agents-shipgate command returns, before enforcing a cached `must_stop`, before commit/push/PR diff --git a/llms-full.txt b/llms-full.txt index de8b570a..f2378ef9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -175,12 +175,16 @@ override. Read the pointer with: ```bash -agents-shipgate agent control --reports-dir agents-shipgate-reports +agents-shipgate agent control --workspace . --reports-dir agents-shipgate-reports ``` A zero exit means the printed pointer was validated against every artifact it -binds and did not move while it was read. A non-zero exit means no control -identity is current here — you hold no authority, and a remembered result does +binds, still describes the repository as it stands right now, and did not move +while it was read. Byte consistency is not generation consistency: one commit +is enough to make an intact artifact set describe a workspace that has moved, +so the read compares the pointer's HEAD, tree, and worktree overlay against the +live repository and refuses on drift. A non-zero exit means no control identity +is current here — you hold no authority, and a remembered result does not substitute for one. Re-read it after any human or external-tool action, after commit, rebase, checkout, pull, or any worktree change, after any agents-shipgate command returns, before enforcing a cached `must_stop`, before @@ -1164,10 +1168,18 @@ Downstream repos generated with ## Two read entry points Both start at `agents-shipgate-reports/current-control.json` (`agents-shipgate -agent control`), which names the run that is current. Everything below it -describes *a* run; only the pointer says *which* run. A non-zero exit from the -reader means no control identity is current here and the caller holds no -authority — not that the previous answer still stands. +agent control --workspace .`), which names the run that is current. Everything +below it describes *a* run; only the pointer says *which* run. A non-zero exit +from the reader means no control identity is current here and the caller holds +no authority — not that the previous answer still stands. + +Byte consistency is not generation consistency. A pointer whose artifacts all +still hash correctly can describe a workspace that one commit has moved past, so +the reader compares the bound `workspace_identity` against the live repository — +repository, HEAD commit, HEAD tree, and, for a worktree run, both the recomputed +overlay and the current set of uncommitted paths — and refuses on any drift. +Completion authority is never returned without that comparison: a reader that +cannot resolve the workspace reports it as unverified rather than passing. Given a current pointer, there are two correct "read first" paths; which one applies depends on who is reading. They are not two decisions — they are two @@ -1209,9 +1221,13 @@ filenames already present in the output directory: `current-control.json` records which of those two just happened in its `operation` field, so the choice does not have to be inferred from filenames at all. Only an `operation: "verify"` pointer can carry `control.state: -"complete"`, and only when it also binds a `verification_receipt` for that exact -request; a `scan` or `preview` pointer is structurally incapable of authorizing -completion or merge. While a run is in flight the pointer reads +"complete"`, and only when it also binds a `verification_receipt` whose +`request_id` and `decision_id` are the ones the pointer records — the assembler +accepts any `--out` name under its artifacts root, so an older canonical receipt +must not be mistaken for the one a run just closed. A `scan` or `preview` +pointer is structurally incapable of authorizing completion or merge, and each +binds only the artifacts it actually wrote: a `scan --format markdown` after a +verify does not claim that verifier's `report.json`. While a run is in flight the pointer reads `lifecycle_state: "in_progress"` with `control.state: "unavailable"`, `must_stop: true`, so an interrupted or crashed run leaves a directory that denies cached control rather than one that still authorizes it. Consumers built diff --git a/llms.txt b/llms.txt index ba550364..a4ef8cc5 100644 --- a/llms.txt +++ b/llms.txt @@ -102,7 +102,7 @@ - Before editing protected surfaces, run `agents-shipgate preflight --workspace . --plan - --json` with a `PreflightPlanV1` object; stop when `requires_human_review` is true. - Configure the minimal preview route: `agents-shipgate init --workspace . --write --json`. CI and agent-instruction trust roots are separate reviewed changes. - Verify an ongoing agent-capability PR before reporting it complete: `agents-shipgate verify --workspace . --config shipgate.yaml --base origin/main --head HEAD --ci-mode advisory --format json`. -- Read `agents-shipgate-reports/current-control.json` first (via `agents-shipgate agent control`); it names which run is current, and a non-zero exit means none is. Then validate the `verification-receipt.json` it binds, then read `agent-handoff.json` and switch on `control.state`; read `gate.merge_verdict`, `gate.can_merge_without_human`, `control.next_action`, `fix_task`, and `capability_review.top_changes`. The unified control model requires runtime contract 14. Then read `report.json.release_decision.decision`; it is the release gate. +- Read `agents-shipgate-reports/current-control.json` first (via `agents-shipgate agent control --workspace .`); it names which run is current, checks the pointer against the live repository, and a non-zero exit means none is. Then validate the `verification-receipt.json` it binds, then read `agent-handoff.json` and switch on `control.state`; read `gate.merge_verdict`, `gate.can_merge_without_human`, `control.next_action`, `fix_task`, and `capability_review.top_changes`. The unified control model requires runtime contract 14. Then read `report.json.release_decision.decision`; it is the release gate. - Run the verify-native demo fixture: `agents-shipgate fixture run ai_generated_refund_pr`. - Scan a static repo/report fixture when you need the lower-level report path: `agents-shipgate fixture run support_refund_agent`. - Export a stable static capability lock: `agents-shipgate capability export -c shipgate.yaml`. diff --git a/plugins/agents-shipgate/skills/agents-shipgate/SKILL.md b/plugins/agents-shipgate/skills/agents-shipgate/SKILL.md index fb404a2b..02ed685c 100644 --- a/plugins/agents-shipgate/skills/agents-shipgate/SKILL.md +++ b/plugins/agents-shipgate/skills/agents-shipgate/SKILL.md @@ -20,7 +20,7 @@ Do not use it for general linting, runtime monitoring, evals, model-output quali 5. Default first-time CI to advisory mode. Do not enable release-blocking CI or save a baseline until a human has reviewed current findings. 6. For local agent control, run `shipgate check --agent codex --workspace . --format agent-boundary-json` and read the stdout `shipgate.agent_boundary_result/v1` object. Switch on `control.state`; follow only `control.next_action`, `control.allowed_next_commands`, and `control.human_review`. Treat `decision` as diagnostic context only. 7. Before editing `shipgate.yaml`, Shipgate CI, AGENTS/CLAUDE/Cursor rules, policy packs, baselines, waivers, suppressions, Codex hooks/config, Codex plugin manifests, `.mcp.json`, `.app.json`, or `SKILL.md`, plan to run `agents-shipgate verify` before completion and route trust-root review to a human when the verifier requires it. -8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control` before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. +8. For full PR verification, validate `agents-shipgate-reports/verification-receipt.json` first, then read `agent-handoff.json` and switch on `control.state`; read `verifier.json` for detailed control state, `verify-run.json` for the request graph, and `report.json` for reviewer detail. `report.json.release_decision.decision` remains the release gate. Refresh `agents-shipgate-reports/current-control.json` with `agents-shipgate agent control --workspace .` — which refuses the read when HEAD, the tree, or the working tree has moved since the decision — before you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if `current_control_id` changed, discard every cached control state and restart from the new identity. A result remembered from earlier in the conversation never outranks the current pointer, in either direction. 9. Auto-apply only high-confidence safe patches. Do not auto-assert action effect, action authority, agent bindings, approval, confirmation, idempotency, broad-scope, prohibited-action, or runtime-trace evidence. 10. Ensure `.gitignore` covers `agents-shipgate-reports/` before committing. diff --git a/plugins/agents-shipgate/skills/agents-shipgate/references/report-reading.md b/plugins/agents-shipgate/skills/agents-shipgate/references/report-reading.md index 95110ddd..42ea39a8 100644 --- a/plugins/agents-shipgate/skills/agents-shipgate/references/report-reading.md +++ b/plugins/agents-shipgate/skills/agents-shipgate/references/report-reading.md @@ -1,8 +1,9 @@ # Reading Agents Shipgate Reports For verify runs, read `agents-shipgate-reports/current-control.json` first — -via `agents-shipgate agent control` — because it names which run is current and -a non-zero exit means none is. Then validate the +via `agents-shipgate agent control --workspace .` — because it names which run +is current, checks it against the repository as it stands right now, and a +non-zero exit means none is. Then validate the `agents-shipgate-reports/verification-receipt.json` it binds. Then read `agents-shipgate-reports/agent-handoff.json`. After that, read `agents-shipgate-reports/verifier.json` for detailed control context diff --git a/plugins/claude-code/skills/agents-shipgate/SKILL.md b/plugins/claude-code/skills/agents-shipgate/SKILL.md index facedb67..fb5965cf 100644 --- a/plugins/claude-code/skills/agents-shipgate/SKILL.md +++ b/plugins/claude-code/skills/agents-shipgate/SKILL.md @@ -53,7 +53,9 @@ Always: `fix_task`, and `capability_review.top_changes`. Then parse `agents-shipgate-reports/report.json.release_decision.decision`; it is the release gate. Refresh `agents-shipgate-reports/current-control.json` with - `agents-shipgate agent control` before you act on any of that, and again + `agents-shipgate agent control --workspace .` — which refuses the read when + HEAD, the tree, or the working tree has moved since the decision — before + you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if @@ -82,7 +84,7 @@ For non-GitHub CI (GitLab, CircleCI, Jenkins, Azure Pipelines, Buildkite, Bitbuc - **CLI surface** follows the current 0.x contract line — see https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/STABILITY.md. - **Installed CLI contract**: when available, run `agents-shipgate contract --json` to verify local schema versions, capability/research surfaces, `release_decision.decision`, and manual-review signal fields. Older installs should use [`docs/agent-contract-current.md`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md) or upgrade before automating against the local contract command. - **Verifier JSON**: `verifier_schema_version: "0.7"`. Switch on `control.state`, then read `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, `capability_review.top_changes`, `trust_root_touched`, and `policy_weakened` before summarizing an AI-generated PR. `merge_verdict` is a deterministic projection; the gate remains `report.json.release_decision.decision`. Check `diff_status.completeness` before you believe any negative result: only `"complete"` means the PR diff was actually read. Anything else (`reason` is one of `not_attempted`, `refs_missing`, `merge_base_missing`, `unrelated_histories`, `objects_missing`, `metadata_limit_exceeded`, `body_limit_exceeded`, `git_timeout`, `git_failed`) means evidence was missing: follow `remediation`, and never report the PR as unrelated to agent capabilities. Then read `trigger.evaluation_status` for what that cost. `"not_evaluated"` (with `trigger.should_run` `null`) means no verdict exists. `"evaluated"` on an incomplete diff is not a contradiction — evidence that did not depend on the missing bytes already proved Shipgate should run — so honor `should_run: true` instead of overriding it, and still recover the diff before trusting a merge verdict. That evidence is either a rule matched on the change set or, in an already-adopted repository, `force_run: true` from the manifest alone; check `matched_rules` before attributing the verdict to anything the diff showed. -- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt`; a `scan` or `preview` pointer never authorizes completion or merge. +- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt` that closes that exact request; a `scan` or `preview` pointer never authorizes completion or merge. Reading it is workspace-checked: byte-intact artifacts still describe a workspace that a single commit has moved past, so the reader compares the bound HEAD, tree, and worktree overlay against the live repository and refuses on drift. - **Verification receipt**: `verification-receipt.json` uses `schema_version: "shipgate.verification_receipt/v1"` and is written last. Validate it before trusting any projected verdict; it content-addresses the request, executor, unit result, decision, and complete artifact set. - **Verify run JSON**: `verify-run.json` uses `schema_version: "shipgate.verify_run/v3"`, embeds the content-addressed plan and executor, and binds unit-result and decision IDs. `run_id` is an exact compatibility alias of `request_id`; do not treat the run projection as a second gate. - **Report JSON**: `report_schema_version: "0.34"`. Read `release_decision.decision` first. A `passed` decision requires a complete root-reachable static binding graph plus complete, conflict-free identity, effect, and authority evidence for every reachable action; it does not prove runtime behavior. Preserve `release_decision.static_analysis_only=true`, `runtime_behavior_verified=false`, and `static_verdict_disclaimer` in summaries. Read `release_decision.evidence_coverage.binding_coverage`, `semantic_coverage`, `identity_coverage`, and `policy_gap_count`, then work every `evidence_gaps[].next_action` in order. Binding, semantic, and policy-applicability gaps are not Findings and cannot be suppressed, baselined, severity-overridden, cleared by `--no-heuristics`, or satisfied by `human_ack`; binding, effect, and authority declarations are human assertions and must never be auto-written. Use `tool_catalog[]` for diagnostics and `tool_inventory[]` for the proven reachable surface. The current schema is [`docs/report-schema.v0.34.json`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/report-schema.v0.34.json); v0.33 is a frozen compatibility reference. See the [current agent contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md), [verification identity contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/verification-reproducibility.md), and [evidence-backed passed contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/passed-verdict-contract.md). diff --git a/skills/agents-shipgate/SKILL.md b/skills/agents-shipgate/SKILL.md index facedb67..fb5965cf 100644 --- a/skills/agents-shipgate/SKILL.md +++ b/skills/agents-shipgate/SKILL.md @@ -53,7 +53,9 @@ Always: `fix_task`, and `capability_review.top_changes`. Then parse `agents-shipgate-reports/report.json.release_decision.decision`; it is the release gate. Refresh `agents-shipgate-reports/current-control.json` with - `agents-shipgate agent control` before you act on any of that, and again + `agents-shipgate agent control --workspace .` — which refuses the read when + HEAD, the tree, or the working tree has moved since the decision — before + you act on any of that, and again before enforcing a cached `must_stop`, before commit/push/PR update, before merge, and before declaring the task complete. A non-zero exit means no control identity is current and you hold no authority; if @@ -82,7 +84,7 @@ For non-GitHub CI (GitLab, CircleCI, Jenkins, Azure Pipelines, Buildkite, Bitbuc - **CLI surface** follows the current 0.x contract line — see https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/STABILITY.md. - **Installed CLI contract**: when available, run `agents-shipgate contract --json` to verify local schema versions, capability/research surfaces, `release_decision.decision`, and manual-review signal fields. Older installs should use [`docs/agent-contract-current.md`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md) or upgrade before automating against the local contract command. - **Verifier JSON**: `verifier_schema_version: "0.7"`. Switch on `control.state`, then read `merge_verdict`, `can_merge_without_human`, `control.next_action`, `fix_task`, `capability_review.top_changes`, `trust_root_touched`, and `policy_weakened` before summarizing an AI-generated PR. `merge_verdict` is a deterministic projection; the gate remains `report.json.release_decision.decision`. Check `diff_status.completeness` before you believe any negative result: only `"complete"` means the PR diff was actually read. Anything else (`reason` is one of `not_attempted`, `refs_missing`, `merge_base_missing`, `unrelated_histories`, `objects_missing`, `metadata_limit_exceeded`, `body_limit_exceeded`, `git_timeout`, `git_failed`) means evidence was missing: follow `remediation`, and never report the PR as unrelated to agent capabilities. Then read `trigger.evaluation_status` for what that cost. `"not_evaluated"` (with `trigger.should_run` `null`) means no verdict exists. `"evaluated"` on an incomplete diff is not a contradiction — evidence that did not depend on the missing bytes already proved Shipgate should run — so honor `should_run: true` instead of overriding it, and still recover the diff before trusting a merge verdict. That evidence is either a rule matched on the change set or, in an already-adopted repository, `force_run: true` from the manifest alone; check `matched_rules` before attributing the verdict to anything the diff showed. -- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt`; a `scan` or `preview` pointer never authorizes completion or merge. +- **Current control pointer**: `agents-shipgate-reports/current-control.json` uses `schema_version: "shipgate.current_control/v1"` and is the one entry point naming which control identity is current. It is invalidated to `lifecycle_state: "in_progress"` before a run starts and published atomically last, so an interrupted run leaves a directory that denies cached control rather than one that still authorizes it. Only a `verify` pointer can carry `control.state: "complete"`, and only with a bound `verification_receipt` that closes that exact request; a `scan` or `preview` pointer never authorizes completion or merge. Reading it is workspace-checked: byte-intact artifacts still describe a workspace that a single commit has moved past, so the reader compares the bound HEAD, tree, and worktree overlay against the live repository and refuses on drift. - **Verification receipt**: `verification-receipt.json` uses `schema_version: "shipgate.verification_receipt/v1"` and is written last. Validate it before trusting any projected verdict; it content-addresses the request, executor, unit result, decision, and complete artifact set. - **Verify run JSON**: `verify-run.json` uses `schema_version: "shipgate.verify_run/v3"`, embeds the content-addressed plan and executor, and binds unit-result and decision IDs. `run_id` is an exact compatibility alias of `request_id`; do not treat the run projection as a second gate. - **Report JSON**: `report_schema_version: "0.34"`. Read `release_decision.decision` first. A `passed` decision requires a complete root-reachable static binding graph plus complete, conflict-free identity, effect, and authority evidence for every reachable action; it does not prove runtime behavior. Preserve `release_decision.static_analysis_only=true`, `runtime_behavior_verified=false`, and `static_verdict_disclaimer` in summaries. Read `release_decision.evidence_coverage.binding_coverage`, `semantic_coverage`, `identity_coverage`, and `policy_gap_count`, then work every `evidence_gaps[].next_action` in order. Binding, semantic, and policy-applicability gaps are not Findings and cannot be suppressed, baselined, severity-overridden, cleared by `--no-heuristics`, or satisfied by `human_ack`; binding, effect, and authority declarations are human assertions and must never be auto-written. Use `tool_catalog[]` for diagnostics and `tool_inventory[]` for the proven reachable surface. The current schema is [`docs/report-schema.v0.34.json`](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/report-schema.v0.34.json); v0.33 is a frozen compatibility reference. See the [current agent contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/agent-contract-current.md), [verification identity contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/verification-reproducibility.md), and [evidence-backed passed contract](https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/passed-verdict-contract.md). diff --git a/src/agents_shipgate/cli/agent_interface.py b/src/agents_shipgate/cli/agent_interface.py index 8eb6a573..cc0570ae 100644 --- a/src/agents_shipgate/cli/agent_interface.py +++ b/src/agents_shipgate/cli/agent_interface.py @@ -7,9 +7,17 @@ import typer from agents_shipgate.cli.agent_mode import emit_agent_mode_error +from agents_shipgate.cli.verify.git import ( + commit_sha, + ensure_git_workspace, + repository_identity, + tree_sha, + working_tree_context, +) from agents_shipgate.core.agent_handoff import build_agent_handoff from agents_shipgate.core.current_control import ( CurrentControlUnavailable, + LiveWorkspace, read_current_control, ) from agents_shipgate.core.errors import InputParseError @@ -22,6 +30,23 @@ no_args_is_help=True, ) +# Refusal reason -> (agent-mode error kind, exit code). Reasons about the +# artifact set itself map to the missing/parse family; reasons about currency +# map to "other". Anything unlisted falls through to the conservative 4. +_UNAVAILABLE_EXIT: dict[str, tuple[str, int]] = { + "missing": ("input_parse_error", 3), + "unreadable": ("input_parse_error", 3), + "invalid_schema": ("input_parse_error", 3), + "unsafe_pointer": ("input_parse_error", 3), + "artifact_unreadable": ("input_parse_error", 3), + "artifact_mismatch": ("input_parse_error", 3), + "generation_changed": ("other_error", 4), + "workspace_changed": ("other_error", 4), + "workspace_unverified": ("other_error", 4), + "workspace_unverifiable": ("other_error", 4), + "receipt_mismatch": ("other_error", 4), +} + @agent_app.command("handoff") def handoff( @@ -91,6 +116,14 @@ def handoff( @agent_app.command("control") def control( + workspace: Path = typer.Option( + Path("."), + "--workspace", + help=( + "Repository the pointer must still describe. Drift in HEAD, the " + "tree, or the worktree overlay refuses the read." + ), + ), reports_dir: Path = typer.Option( Path(DEFAULT_PATHS["reports_dir"]), "--reports-dir", @@ -100,14 +133,16 @@ def control( """Read the current control identity using the generation-safe protocol. This is the one refresh entry point. A zero exit means the printed pointer - was validated against every artifact it binds and did not move while it was - read. A non-zero exit means no control identity is current here: the caller - holds no authority and must not fall back on a control state it cached - earlier in the conversation. + was validated against every artifact it binds, still describes ``--workspace`` + as it stands right now, and did not move while it was read. A non-zero exit + means no control identity is current here: the caller holds no authority and + must not fall back on a control state it cached earlier in the conversation. """ try: - result = read_current_control(reports_dir) + result = read_current_control( + reports_dir, live=_live_workspace(workspace, reports_dir) + ) except CurrentControlUnavailable as exc: guidance = ( "Re-run `agents-shipgate verify` and read " @@ -115,11 +150,17 @@ def control( "reads cleanly, treat completion, merge, and any cached must_stop " "as unavailable rather than acting on a remembered result." ) + # Two different failures, two different exit codes: the artifact set is + # unreadable or inconsistent (3, the missing/parse family), or it reads + # fine but no longer describes anything current (4). Both deny + # authority; the split tells a caller whether to repair a directory or + # simply re-verify. + kind, exit_code = _UNAVAILABLE_EXIT.get(exc.reason, ("other_error", 4)) typer.echo(f"Current control is unavailable ({exc.reason}): {exc}", err=True) emit_agent_mode_error( - "input_parse_error" if exc.reason != "generation_changed" else "other_error", + kind, message=str(exc), - exit_code=3 if exc.reason != "generation_changed" else 4, + exit_code=exit_code, next_action=guidance, next_actions=[ NextAction( @@ -127,19 +168,51 @@ def control( command=COMMANDS["verify_pr"], why=guidance, expects=( - "current-control.json is present, valid, and every " - "artifact it binds matches its recorded hash." + "current-control.json is present, valid, every artifact " + "it binds matches its recorded hash, and it still " + "describes this workspace." ), ).model_dump(mode="json") ], ) - raise typer.Exit(3 if exc.reason != "generation_changed" else 4) from exc + raise typer.Exit(exit_code) from exc typer.echo( json.dumps(result.pointer.model_dump(mode="json"), indent=2, sort_keys=True) ) +def _live_workspace(workspace: Path, reports_dir: Path) -> LiveWorkspace | None: + """Resolve the repository as it stands now, or ``None`` outside Git. + + ``None`` is not "no drift" — it means the comparison could not be made, and + the reader refuses completion authority on that basis rather than assuming + the pointer still holds. + + The reports directory is excluded from the change set for the same reason + ``verify`` excludes it when building the plan: the run's own output is not + part of the change it evaluated, and including it here would make every + refresh disagree with the decision it is checking. + """ + + try: + root = ensure_git_workspace(workspace.resolve()) + try: + changed, _ = working_tree_context(root, exclude=reports_dir) + changed_paths: tuple[str, ...] | None = tuple(changed) + except Exception: # noqa: BLE001 - an unreadable worktree is "unverified". + changed_paths = None + return LiveWorkspace( + root=root, + repository=repository_identity(root), + head_commit_sha=commit_sha(root, "HEAD"), + head_tree_sha=tree_sha(root, "HEAD"), + changed_paths=changed_paths, + ) + except Exception: # noqa: BLE001 - an unresolvable workspace is "unverified". + return None + + def _load_required_json(path: Path, label: str) -> dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) diff --git a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/_shared.py b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/_shared.py index f6540247..91d0db69 100644 --- a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/_shared.py +++ b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/_shared.py @@ -21,7 +21,9 @@ # form of this list is `agent_refresh_triggers` in the runtime contract. CURRENT_CONTROL_PARAGRAPH = """`agents-shipgate-reports/current-control.json` is the one entry point that says which control identity is current. Read it with `agents-shipgate agent -control`; a non-zero exit means nothing is current here and you hold no +control --workspace .`, which checks the pointer against the repository as it +stands right now — a moved HEAD, a changed tree, or an edited working file +refuses the read. A non-zero exit means nothing is current here and you hold no authority. Re-read it after any human or external-tool action, after commit, rebase, checkout, pull, or any worktree change, after any agents-shipgate command returns, before enforcing a cached `must_stop`, before commit/push/PR diff --git a/src/agents_shipgate/cli/scan/writing.py b/src/agents_shipgate/cli/scan/writing.py index 1db51d44..e73d6a4e 100644 --- a/src/agents_shipgate/cli/scan/writing.py +++ b/src/agents_shipgate/cli/scan/writing.py @@ -11,6 +11,7 @@ ) from agents_shipgate.cli._artifact_lifecycle import clear_verifier_route_artifacts from agents_shipgate.core.current_control import ( + SCAN_FORMAT_ARTIFACT_KEYS, begin_current_control, current_control_lifecycle_owner, publish_current_control, @@ -114,6 +115,11 @@ def _write_outputs( # look, so a verifier route from an earlier run cannot be mistaken for # the current permission to finish. A scan builds no verification # plan, so the only identity it can bind is the manifest it read. + # + # The binding is restricted to the formats this scan actually wrote. + # `scan --format markdown` after a verify leaves that verifier's + # `report.json` in place, and binding it would present the previous + # run's JSON report as part of the current set. publish_current_control( plan.out_dir, operation="scan", @@ -128,6 +134,11 @@ def _write_outputs( workspace_identity=CurrentControlWorkspaceIdentity( policy_snapshot_sha256=_manifest_snapshot_sha256(config_path), ), + artifact_keys={ + SCAN_FORMAT_ARTIFACT_KEYS[name] + for name in plan.generated_paths + if name in SCAN_FORMAT_ARTIFACT_KEYS + }, ) diff --git a/src/agents_shipgate/core/current_control.py b/src/agents_shipgate/core/current_control.py index 50b0e7d5..ee9d946f 100644 --- a/src/agents_shipgate/core/current_control.py +++ b/src/agents_shipgate/core/current_control.py @@ -38,7 +38,10 @@ from typing import ParamSpec, TypeVar from agents_shipgate.core.errors import AgentsShipgateError -from agents_shipgate.core.verification_identity import read_regular_file_beneath +from agents_shipgate.core.verification_identity import ( + read_regular_file_beneath, + worktree_overlay, +) from agents_shipgate.schemas.agent_control import AgentControl from agents_shipgate.schemas.current_control import ( CURRENT_CONTROL_ARTIFACT_NAME, @@ -54,7 +57,11 @@ UnavailableCurrentControl, current_control_identity_payload, ) -from agents_shipgate.schemas.verification_identity import VerificationPlan, content_id +from agents_shipgate.schemas.verification_identity import ( + VerificationPlan, + VerificationReceipt, + content_id, +) MAX_CURRENT_CONTROL_BYTES = 1024 * 1024 MAX_BOUND_ARTIFACT_BYTES = 256 * 1024 * 1024 @@ -71,16 +78,31 @@ "verify_run": "verify-run.json", "human_authorization": "human-authorization.json", "report": "report.json", + "report_markdown": "report.md", + "report_sarif": "report.sarif", "packet": "packet.json", "pr_comment": "pr-comment.md", } -# ``report.json`` and ``packet.json`` come from a scan. A command that never -# runs one — preview — must not bind whatever an earlier run left in the -# directory, or the pointer would advertise two generations as one current set. +# Artifacts a scan produces, and only in the formats that scan was configured to +# emit. A run must never bind one of these unless it wrote it: `scan --format +# markdown` after a verify leaves the verifier's `report.json` untouched, and +# binding it would advertise two generations as one current set. +SCAN_CONTROL_ARTIFACT_KEYS: frozenset[str] = frozenset( + {"report", "report_markdown", "report_sarif", "packet"} +) +# The complement: what a verifier route writes. `preview` runs no scan at all, +# so it binds only these. VERIFIER_ROUTE_CONTROL_ARTIFACT_KEYS: frozenset[str] = frozenset( - key for key in CURRENT_CONTROL_ARTIFACT_FILENAMES if key not in {"report", "packet"} + key for key in CURRENT_CONTROL_ARTIFACT_FILENAMES if key not in SCAN_CONTROL_ARTIFACT_KEYS ) +# Scan output format name (``_OutputPlan.generated_paths`` key) -> artifact key. +SCAN_FORMAT_ARTIFACT_KEYS: dict[str, str] = { + "markdown": "report_markdown", + "json": "report", + "sarif": "report_sarif", + "packet_json": "packet", +} _LIFECYCLE_OWNER: ContextVar[str | None] = ContextVar( "agents_shipgate_current_control_owner", default=None @@ -194,16 +216,23 @@ def publish_current_control( """ artifacts = bind_current_control_artifacts(out_dir, artifact_keys=artifact_keys) - if RECEIPT_ARTIFACT_KEY not in artifacts and control.state == "complete": - # Defence in depth: the schema rejects this too, but downgrading here - # keeps a caller with an inconsistent view from losing the run. - control = HumanReviewRequiredCurrentControl( - state="human_review_required", - reason=( - "The run reported completion but published no terminal receipt, " - "so completion authority cannot be established." - ), + if control.state == "complete": + # Defence in depth: the schema rejects a receipt-less completion too, + # but downgrading here keeps a caller with an inconsistent view from + # losing the run. The identity check matters independently — a producer + # may write its receipt somewhere other than the canonical path (the + # assembler's `--out` accepts any name under its artifacts root), which + # leaves an older canonical receipt to be bound beside a newer decision. + refusal = _receipt_binding_refusal( + out_dir, + artifacts, + request_id=request_id, + decision_id=decision_id, ) + if refusal is not None: + control = HumanReviewRequiredCurrentControl( + state="human_review_required", reason=refusal + ) pointer = _finalize( operation=operation, lifecycle_state="terminal", @@ -218,6 +247,46 @@ def publish_current_control( return pointer +def _receipt_binding_refusal( + out_dir: Path, + artifacts: dict[str, CurrentControlArtifactRef], + *, + request_id: str | None, + decision_id: str | None, +) -> str | None: + """Return why completion must be refused, or ``None`` when it may stand.""" + + ref = artifacts.get(RECEIPT_ARTIFACT_KEY) + if ref is None: + return ( + "The run reported completion but published no terminal receipt, " + "so completion authority cannot be established." + ) + try: + receipt = _load_receipt(out_dir, ref) + except ValueError as exc: + return f"The bound terminal receipt could not be read: {exc}" + if receipt.request_id != request_id or receipt.decision_id != decision_id: + return ( + "The bound terminal receipt closes a different request than the one " + "this run decided, so completion authority cannot be established." + ) + return None + + +def _load_receipt(out_dir: Path, ref: CurrentControlArtifactRef) -> VerificationReceipt: + data = read_regular_file_beneath( + out_dir, + ref.path, + max_size=MAX_BOUND_ARTIFACT_BYTES, + label="current control receipt", + ) + try: + return VerificationReceipt.model_validate_json(data) + except Exception as exc: # pydantic ValidationError and friends + raise ValueError(str(exc)) from exc + + def bind_current_control_artifacts( out_dir: Path, *, @@ -313,6 +382,23 @@ def workspace_identity_from_plan(plan: VerificationPlan) -> CurrentControlWorksp ) +@dataclass(frozen=True) +class LiveWorkspace: + """The repository as it stands right now, for comparison with the pointer. + + Resolved by the caller because the Git helpers live in the CLI layer. + ``root`` is the repository root, used to recompute a worktree overlay. + """ + + root: Path + repository: str | None = None + head_commit_sha: str | None = None + head_tree_sha: str | None = None + # The paths that differ from HEAD right now. ``None`` means the caller could + # not determine them, which is treated as unverified rather than unchanged. + changed_paths: tuple[str, ...] | None = None + + @dataclass(frozen=True) class CurrentControlRead: """A pointer that was validated against the artifacts it binds.""" @@ -321,7 +407,12 @@ class CurrentControlRead: path: Path -def read_current_control(out_dir: Path, *, attempts: int = 3) -> CurrentControlRead: +def read_current_control( + out_dir: Path, + *, + live: LiveWorkspace | None = None, + attempts: int = 3, +) -> CurrentControlRead: """Read the current control identity, or refuse. Implements the generation-safe protocol: read and validate the pointer, @@ -329,6 +420,13 @@ def read_current_control(out_dir: Path, *, attempts: int = 3) -> CurrentControlR only when ``current_control_id`` is unchanged. A run that republishes while this read is in flight makes the read fail rather than return a pointer describing one generation and artifacts from another. + + Byte consistency is not generation consistency. A pointer whose artifacts + all still hash correctly can still describe a workspace that has since + moved — one commit is enough — so ``live`` is compared against the bound + ``workspace_identity`` and any drift refuses the read. Completion + authority is never returned without that comparison: passing ``live=None`` + means "not verified", which downgrades to a refusal rather than a pass. """ path = current_control_path(out_dir) @@ -353,6 +451,7 @@ def read_current_control(out_dir: Path, *, attempts: int = 3) -> CurrentControlR raise last = mismatch continue + _validate_control_currency(out_dir, pointer, live) confirmation = _load_pointer(out_dir, path) if confirmation.current_control_id == pointer.current_control_id: return CurrentControlRead(pointer=pointer, path=path) @@ -414,6 +513,150 @@ def _load_pointer(out_dir: Path, path: Path) -> CurrentControlPointer: ) from exc +def _validate_control_currency( + out_dir: Path, + pointer: CurrentControlPointer, + live: LiveWorkspace | None, +) -> None: + """Refuse a pointer whose evidence no longer describes this workspace.""" + + grants_authority = pointer.control.state == "complete" + if grants_authority: + refusal = _receipt_binding_refusal( + out_dir, + pointer.artifacts, + request_id=pointer.request_id, + decision_id=pointer.decision_id, + ) + if refusal is not None: + raise CurrentControlUnavailable("receipt_mismatch", refusal, path=out_dir) + + identity = pointer.workspace_identity + if live is None: + if grants_authority: + raise CurrentControlUnavailable( + "workspace_unverified", + ( + "This pointer authorizes completion, but the live workspace " + "was not supplied, so it could not be confirmed that the " + "decision still describes it." + ), + path=out_dir, + ) + return + + for field, observed in ( + ("repository", live.repository), + ("head_commit_sha", live.head_commit_sha), + ("head_tree_sha", live.head_tree_sha), + ): + expected = getattr(identity, field) + if expected is not None and observed != expected: + raise CurrentControlUnavailable( + "workspace_changed", + ( + f"The workspace moved since this decision was made: " + f"{field} is {observed!r}, but the pointer was published " + f"against {expected!r}. Re-run verification." + ), + path=out_dir, + ) + if grants_authority and ( + identity.head_commit_sha is None or identity.head_tree_sha is None + ): + raise CurrentControlUnavailable( + "workspace_unverifiable", + ( + "This pointer authorizes completion but binds no HEAD identity, " + "so it cannot be confirmed against the live workspace." + ), + path=out_dir, + ) + if identity.snapshot_kind == "worktree_overlay": + _validate_worktree_overlay(out_dir, pointer, live, required=grants_authority) + + +def _validate_worktree_overlay( + out_dir: Path, + pointer: CurrentControlPointer, + live: LiveWorkspace, + *, + required: bool, +) -> None: + """Recompute the overlay a worktree decision committed to. + + A worktree run's evidence is the uncommitted content of its changed files, + which HEAD says nothing about — editing one of them leaves both commit and + tree identical. The overlay is recomputed from the same changed-path set + the plan recorded, using the same normalization the plan used. + """ + + ref = pointer.artifacts.get("verification_plan") + if ref is None: + if required: + raise CurrentControlUnavailable( + "workspace_unverifiable", + ( + "This pointer authorizes completion of a worktree " + "verification but binds no plan, so the overlay it decided " + "on cannot be recomputed." + ), + path=out_dir, + ) + return + try: + data = read_regular_file_beneath( + out_dir, + ref.path, + max_size=MAX_BOUND_ARTIFACT_BYTES, + label="current control plan", + ) + plan = VerificationPlan.model_validate_json(data) + decided_paths = list(plan.inputs.changed_paths) + rows = worktree_overlay(live.root, decided_paths) + except (ValueError, OSError) as exc: + raise CurrentControlUnavailable( + "workspace_unverifiable", + f"The worktree overlay this decision committed to could not be recomputed: {exc}", + path=out_dir, + ) from exc + observed = content_id(rows) if rows else None + if observed != pointer.workspace_identity.worktree_overlay_sha256: + raise CurrentControlUnavailable( + "workspace_changed", + ( + "The working tree changed since this decision was made: the " + "recomputed overlay does not match the one the pointer was " + "published against. Re-run verification." + ), + path=out_dir, + ) + # The overlay only covers the paths that already differed from HEAD when the + # decision was made. A file edited afterwards leaves HEAD, the tree, and + # that overlay all identical, so the change *set* has to be compared too. + if live.changed_paths is None: + if required: + raise CurrentControlUnavailable( + "workspace_unverifiable", + ( + "This pointer authorizes completion of a worktree " + "verification, but the current set of uncommitted changes " + "could not be determined." + ), + path=out_dir, + ) + return + if set(live.changed_paths) != set(decided_paths): + raise CurrentControlUnavailable( + "workspace_changed", + ( + "The set of uncommitted changes is no longer the one this " + "decision was made against. Re-run verification." + ), + path=out_dir, + ) + + def _validate_bound_artifacts(out_dir: Path, pointer: CurrentControlPointer) -> None: for name, ref in sorted(pointer.artifacts.items()): try: @@ -546,10 +789,13 @@ def _sha256_bytes(value: bytes) -> str: __all__ = [ "CURRENT_CONTROL_ARTIFACT_FILENAMES", "MAX_BOUND_ARTIFACT_BYTES", + "SCAN_CONTROL_ARTIFACT_KEYS", + "SCAN_FORMAT_ARTIFACT_KEYS", "VERIFIER_ROUTE_CONTROL_ARTIFACT_KEYS", "MAX_CURRENT_CONTROL_BYTES", "CurrentControlPublishError", "CurrentControlRead", + "LiveWorkspace", "CurrentControlUnavailable", "begin_current_control", "bind_current_control_artifacts", diff --git a/src/agents_shipgate/core/verification_identity.py b/src/agents_shipgate/core/verification_identity.py index 24f284ad..1504344b 100644 --- a/src/agents_shipgate/core/verification_identity.py +++ b/src/agents_shipgate/core/verification_identity.py @@ -979,6 +979,17 @@ def _existing_changed_blobs(paths: list[str], *, root: Path, source: str) -> lis return _blobs(candidates, root=root, source=source) +def worktree_overlay(root: Path, paths: list[str]) -> list[dict[str, Any]]: + """Return the normalized present/deleted+hash rows for ``paths`` under ``root``. + + The same function builds the overlay a plan commits to and the overlay a + later reader recomputes, so worktree drift cannot hide behind two slightly + different normalizations. + """ + + return _worktree_overlay(root, paths) + + def _worktree_overlay(root: Path, paths: list[str]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] snapshot = active_static_input_snapshot() @@ -1217,6 +1228,8 @@ def _normalized_distribution_name(value: str) -> str: __all__ = [ "build_engine_requirement", + "read_regular_file_beneath", + "worktree_overlay", "build_executor", "build_terminal_receipt", "build_unit_result", diff --git a/src/agents_shipgate/schemas/contract.py b/src/agents_shipgate/schemas/contract.py index 9c600cd4..34e29388 100644 --- a/src/agents_shipgate/schemas/contract.py +++ b/src/agents_shipgate/schemas/contract.py @@ -278,7 +278,8 @@ "agents-shipgate-reports" ), "agent_control": ( - "agents-shipgate agent control --reports-dir agents-shipgate-reports" + "agents-shipgate agent control --workspace . " + "--reports-dir agents-shipgate-reports" ), "agent_handoff": ( "agents-shipgate agent handoff --from agents-shipgate-reports/verifier.json --json" diff --git a/src/agents_shipgate/schemas/current_control.py b/src/agents_shipgate/schemas/current_control.py index b2699edf..1971598f 100644 --- a/src/agents_shipgate/schemas/current_control.py +++ b/src/agents_shipgate/schemas/current_control.py @@ -194,7 +194,12 @@ def _pointer_is_coherent(self) -> CurrentControlPointer: else: if self.control.state == "unavailable": raise ValueError("a terminal pointer must project a settled control state") - if not self.artifacts: + if not self.artifacts and self.operation != "scan": + # Verify and preview always write at least a verifier artifact, + # so an empty binding there means the run lost its evidence. A + # scan configured to emit no report format genuinely produces + # nothing, and saying so is more honest than binding a file + # some earlier run left behind. raise ValueError("a terminal pointer must bind at least one artifact") if self.operation != "verify" and self.control.state == "complete": # Only the verifier decides merge authority. A scan or a preview diff --git a/tests/test_agent_instructions_renderers.py b/tests/test_agent_instructions_renderers.py index 10559c56..6772e8b1 100644 --- a/tests/test_agent_instructions_renderers.py +++ b/tests/test_agent_instructions_renderers.py @@ -45,7 +45,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent EXPECTED_CLAUDE_CODE_SKILL_RENDER_SHA256 = { ".claude/skills/agents-shipgate/SKILL.md": ( - "60aae4e35c309253cc8a851b1096b586c02ae97e688a1e3885056e0f344ccaa7" + "ce417b1351675cf8c96d5846c7bc73842b481b07fbf1720f6352d431135f5a52" ), ".claude/skills/agents-shipgate/ci-recipes/advisory-pr-comment.yml": ( # Renders {{ shipgate_version }}; changes on every version bump. @@ -81,7 +81,7 @@ } EXPECTED_CODEX_SKILL_RENDER_SHA256 = { ".agents/skills/agents-shipgate/SKILL.md": ( - "4bde76605784f71eaf6fe57f0c3f4e44e204f10f4eb023edb15424710928894f" + "caba08258bdb5d89b0b2370b02a58f79e2ef26ea2ed36a1c1bd42357322dbbd9" ), ".agents/skills/agents-shipgate/agents/openai.yaml": ( "aa511e933ff663dcd1e0d2af3da2a7101206ce2bb1bb98c4dae801bb3f4e42ef" @@ -93,7 +93,7 @@ "97a8eb98fc560405c690581ac5542b2f94783480f2266af36f566ae12600e2cb" ), ".agents/skills/agents-shipgate/references/report-reading.md": ( - "7b70e3ab34ed2f74175e2fd14df0ef98bdddd41b7c0460d10684f17f8cf3c00c" + "d35c6d6a332ea53ea37e50e9ad7a46b5f124d982922e90f09379107e850830cf" ), } diff --git a/tests/test_current_control.py b/tests/test_current_control.py index 13ed976d..e07590d2 100644 --- a/tests/test_current_control.py +++ b/tests/test_current_control.py @@ -24,10 +24,17 @@ from agents_shipgate.cli.scan.orchestrator import run_scan from agents_shipgate.cli.verification import assemble, prepare, worker from agents_shipgate.cli.verify import orchestrator as verify_orchestrator +from agents_shipgate.cli.verify.git import ( + commit_sha, + repository_identity, + tree_sha, + working_tree_context, +) from agents_shipgate.cli.verify.orchestrator import run_preview, run_verify from agents_shipgate.core import current_control as current_control_module from agents_shipgate.core.current_control import ( CurrentControlUnavailable, + LiveWorkspace, begin_current_control, current_control_path, publish_current_control, @@ -96,6 +103,19 @@ def _verify(repo: Path, **overrides: object) -> tuple[object, object, int]: return run_verify(**options) # type: ignore[arg-type] +def _live(repo: Path) -> LiveWorkspace: + """The live workspace, resolved the way `agents-shipgate agent control` does.""" + + changed, _ = working_tree_context(repo, exclude=repo / "agents-shipgate-reports") + return LiveWorkspace( + root=repo, + repository=repository_identity(repo), + head_commit_sha=commit_sha(repo, "HEAD"), + head_tree_sha=tree_sha(repo, "HEAD"), + changed_paths=tuple(changed), + ) + + def _pointer(repo: Path) -> dict[str, object]: path = repo / "agents-shipgate-reports" / CURRENT_CONTROL_ARTIFACT_NAME return json.loads(path.read_text(encoding="utf-8")) @@ -129,7 +149,9 @@ def test_cached_stop_is_superseded_after_the_human_commits(repo: Path) -> None: completed, _, _ = _verify(repo) assert completed.control.state == "complete" - refreshed = read_current_control(repo / "agents-shipgate-reports").pointer + refreshed = read_current_control( + repo / "agents-shipgate-reports", live=_live(repo) + ).pointer assert refreshed.current_control_id != cached["current_control_id"] assert refreshed.control.state == "complete" assert refreshed.control.completion_allowed is True @@ -140,50 +162,66 @@ def test_cached_stop_is_superseded_after_the_human_commits(repo: Path) -> None: assert refreshed.request_id is not None and refreshed.decision_id is not None -def test_cached_completion_cannot_survive_a_workspace_mutation(repo: Path) -> None: +def test_cached_completion_cannot_survive_a_committed_change(repo: Path) -> None: """Reverse regression: a remembered `complete` is not authority later. - The pointer binds the workspace the decision was made against, so a - consumer can see that HEAD moved out from under a cached completion - without re-running anything — and the rerun then replaces the identity. + Byte consistency is not generation consistency. Every bound artifact still + hashes correctly after an unrelated commit, so the read has to compare the + pointer's workspace identity against the live repository or it hands back + completion authority for a workspace that has moved on. """ completed, _, _ = _verify(repo) assert completed.control.state == "complete" - authorized = _pointer(repo) - assert authorized["control"]["completion_allowed"] is True reports = repo / "agents-shipgate-reports" + authorized = read_current_control(reports, live=_live(repo)).pointer + assert authorized.control.completion_allowed is True - tools = repo / "tools.json" - payload = json.loads(tools.read_text(encoding="utf-8")) - payload["tools"].append( - { - "name": "docs.publish", - "description": "Publish an article to the public documentation site.", - "inputSchema": {"type": "object", "properties": {}}, - } - ) - tools.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - _git(repo, "add", ".") - _git(repo, "commit", "-m", "agent adds a write tool") - - # Nothing has re-run yet, so the pointer still reads `complete` — but it - # says which tree it was computed from, and that tree is no longer HEAD. - head_tree = subprocess.run( - ["git", "rev-parse", "HEAD^{tree}"], - cwd=repo, - check=True, - capture_output=True, - text=True, - ).stdout.strip() - cached = read_current_control(reports).pointer - assert cached.control.completion_allowed is True - assert cached.workspace_identity.head_tree_sha != head_tree + _git(repo, "commit", "--allow-empty", "-m", "an unrelated commit") + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports, live=_live(repo)) + assert raised.value.reason == "workspace_changed" _verify(repo) - current = read_current_control(reports).pointer - assert current.current_control_id != authorized["current_control_id"] - assert current.workspace_identity.head_tree_sha == head_tree + current = read_current_control(reports, live=_live(repo)).pointer + assert current.current_control_id != authorized.current_control_id + assert current.control.completion_allowed is True + + +def test_cached_completion_cannot_survive_an_uncommitted_edit(repo: Path) -> None: + """A worktree decision is about working-tree content, which HEAD hides. + + Editing a file leaves HEAD and its tree byte-identical, so the overlay the + decision committed to has to be recomputed -- both its content and the set + of paths it covered, since a file changed *after* the decision appears in + neither. + """ + + completed, _, _ = _verify(repo, archive_head=False) + assert completed.control.state == "complete" + reports = repo / "agents-shipgate-reports" + assert read_current_control(reports, live=_live(repo)).pointer.control.completion_allowed + + head_before = commit_sha(repo, "HEAD") + tools = repo / "tools.json" + tools.write_text(tools.read_text(encoding="utf-8") + "\n", encoding="utf-8") + assert commit_sha(repo, "HEAD") == head_before + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports, live=_live(repo)) + assert raised.value.reason == "workspace_changed" + + +def test_completion_is_refused_when_the_workspace_cannot_be_checked(repo: Path) -> None: + """`live=None` means "not compared", which is never a pass for completion.""" + + completed, _, _ = _verify(repo) + assert completed.control.state == "complete" + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(repo / "agents-shipgate-reports") + assert raised.value.reason == "workspace_unverified" def test_an_interrupted_run_leaves_no_decision_current( @@ -251,6 +289,84 @@ def test_a_standalone_scan_never_retains_merge_authorization(repo: Path) -> None assert "verification_receipt" not in current.artifacts +def test_a_scan_binds_only_the_formats_it_wrote(repo: Path) -> None: + """`scan --format markdown` must not claim an earlier run's JSON report. + + A scan only replaces the formats it emits, so a verifier's `report.json` + survives a markdown-only scan. Binding it would present the previous run's + decision ids as part of the current set. + """ + + _verify(repo) + reports = repo / "agents-shipgate-reports" + stale_report = json.loads((reports / "report.json").read_text(encoding="utf-8")) + assert stale_report["request_id"] + + run_scan( + config_path=repo / "shipgate.yaml", + output_dir=reports, + formats=["markdown"], + ci_mode="advisory", + plugins_enabled=False, + packet_enabled=False, + ) + + current = read_current_control(reports, live=_live(repo)).pointer + assert current.operation == "scan" + assert "report" not in current.artifacts + assert "packet" not in current.artifacts + assert current.artifacts["report_markdown"].path == "report.md" + # The stale JSON report is still on disk; it is simply no longer current. + assert (reports / "report.json").is_file() + assert current.request_id is None + + +def test_assembly_refuses_completion_when_it_binds_another_request(repo: Path) -> None: + """`assemble --out` accepts any name, so the canonical receipt may be stale. + + The bound receipt has to close the same request and decision the pointer + records, or completion authority is refused. + """ + + _verify(repo) + reports = repo / "agents-shipgate-reports" + stale_receipt = (reports / "verification-receipt.json").read_text(encoding="utf-8") + stale_request = json.loads(stale_receipt)["request_id"] + + # A second run against a different tree produces a different request. + (repo / "notes.txt").write_text("notes\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "unrelated file") + _verify(repo) + fresh_request = json.loads( + (reports / "verification-receipt.json").read_text(encoding="utf-8") + )["request_id"] + assert fresh_request != stale_request + + unit = reports / "distributed-unit.json" + worker( + plan_path=reports / "verification-plan.json", + workspace=repo, + diff_path=reports / "verification-input.diff", + out=unit, + ) + # The stale canonical receipt is what `publish_current_control` will bind. + (reports / "verification-receipt.json").write_text(stale_receipt, encoding="utf-8") + assemble( + plan_path=reports / "verification-plan.json", + unit_paths=[unit], + verifier_path=reports / "verifier.json", + artifacts_root=reports, + out=reports / "custom-receipt.json", + ) + + published = _pointer(repo) + assert published["request_id"] == fresh_request + assert published["control"]["state"] == "human_review_required" + assert published["control"]["completion_allowed"] is False + assert "different request" in published["control"]["reason"] + + def test_a_preview_neither_completes_nor_binds_an_older_report(repo: Path) -> None: _verify(repo) reports = repo / "agents-shipgate-reports" @@ -392,7 +508,7 @@ def test_the_assembler_publishes_the_terminal_pointer(repo: Path) -> None: out=reports / "verification-receipt.json", ) - closed = read_current_control(reports).pointer + closed = read_current_control(reports, live=_live(repo)).pointer assert closed.operation == "verify" assert closed.lifecycle_state == "terminal" assert closed.control.state == "complete" @@ -437,7 +553,7 @@ def republish_then_validate(out_dir: Path, pointer: CurrentControlPointer) -> No current_control_module, "_validate_bound_artifacts", republish_then_validate ) with pytest.raises(CurrentControlUnavailable) as raised: - read_current_control(reports, attempts=1) + read_current_control(reports, live=_live(repo), attempts=1) assert raised.value.reason == "generation_changed" @@ -612,7 +728,14 @@ def test_agent_control_command_prints_the_validated_pointer(repo: Path) -> None: _verify(repo) result = runner.invoke( app, - ["agent", "control", "--reports-dir", str(repo / "agents-shipgate-reports")], + [ + "agent", + "control", + "--workspace", + str(repo), + "--reports-dir", + str(repo / "agents-shipgate-reports"), + ], ) assert result.exit_code == 0, result.output @@ -621,6 +744,42 @@ def test_agent_control_command_prints_the_validated_pointer(repo: Path) -> None: assert payload["control"]["state"] == "complete" +def test_agent_control_ignores_its_own_output_directory(tmp_path: Path) -> None: + """The run's own artifacts are not part of the change it evaluated. + + A workspace that does not gitignore the reports directory would otherwise + report every generated file as an uncommitted change the decision never saw, + and every refresh would refuse. + """ + + workspace = tmp_path / "repo" + workspace.mkdir() + for name in ("shipgate.yaml", "tools.json"): + shutil.copy(SAMPLE / name, workspace / name) + _git(workspace, "init") + _git(workspace, "config", "user.email", "test@example.test") + _git(workspace, "config", "user.name", "Test User") + _git(workspace, "add", ".") + _git(workspace, "commit", "-m", "fixture without a reports gitignore") + + _verify(workspace, archive_head=False) + assert not (workspace / ".gitignore").exists() + + result = runner.invoke( + app, + [ + "agent", + "control", + "--workspace", + str(workspace), + "--reports-dir", + str(workspace / "agents-shipgate-reports"), + ], + ) + + assert result.exit_code == 0, result.output + + def test_agent_control_command_fails_closed_when_nothing_is_current(tmp_path: Path) -> None: empty = tmp_path / "reports" empty.mkdir() @@ -630,6 +789,28 @@ def test_agent_control_command_fails_closed_when_nothing_is_current(tmp_path: Pa assert "unavailable" in result.output +def test_agent_control_command_fails_closed_on_workspace_drift(repo: Path) -> None: + """Drift is a currency failure, not a parse failure, so it exits 4.""" + + _verify(repo) + _git(repo, "commit", "--allow-empty", "-m", "an unrelated commit") + + result = runner.invoke( + app, + [ + "agent", + "control", + "--workspace", + str(repo), + "--reports-dir", + str(repo / "agents-shipgate-reports"), + ], + ) + + assert result.exit_code == 4 + assert "workspace_changed" in result.output + + def test_scan_publishes_its_own_pointer_only_when_it_owns_the_lifecycle( repo: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 1fb1e502dc2b8cccb5b3f8f806c83792b41b82a3 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Fri, 7 Aug 2026 22:18:52 -0700 Subject: [PATCH 3/5] fix(control): check uncommitted work against what the decision covered Re-review of `b1895227` found the live-workspace comparison using the wrong path set in one direction and no path set at all in the other. `plan.inputs.changed_paths` is not the uncommitted set. A local run without `--head` carries the union of `base...HEAD` and the worktree, so requiring it to equal the live uncommitted set refused a clean workspace the instant the run that produced it finished -- 52 plan paths against 0 live paths, before any mutation. That is stale denial, the failure direction this pointer exists to prevent, and my own tests missed it because they exercised `base=None` rather than the canonical local flow. The correct test is a subset, and it is complete. A worktree decision's evidence is the overlay rows for the paths it recorded; everything outside that set was identical to HEAD when the decision was made, which is why it was not in the change set. So the working tree is pinned by two conditions: those paths still hash to the committed overlay, and no path outside the set differs from HEAD now. A file edited afterwards is not in the recorded set, so it is caught; a file committed since moves HEAD; a file reverted or deleted changes the overlay. The converse was open too. A committed-tree decision stops at HEAD, and the branch never examined live changes at all, so an untracked tool file added beside a clean `complete` kept completion authority. Uncommitted changes now block completion for those decisions. Only completion: re-running the same committed verification would reproduce the same pointer, so refusing the read outright would leave a caller with no route at all and no way to clear it. Separately, `assemble --out ` still could not publish its own completed generation. `receipt_bound` and the artifact map both knew only the canonical filename, so a valid relocated receipt was bound as nothing (downgrading a correct run) or the stale canonical one was bound instead. `publish_current_control` now takes an `artifact_paths` override -- a producer may relocate a known artifact, never invent a kind of one or point outside the directory the pointer describes -- and assembly binds the receipt it actually emitted. The request/decision cross-check stays as defence in depth for producers that get it wrong, now covered by its own test rather than only through assembly. Regressions added for both flows the review named: the canonical local run with a base on a clean tree, and committed-ref verification followed by a new uncommitted file. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 ++- docs/agent-contract-current.md | 23 +++- llms-full.txt | 23 +++- src/agents_shipgate/cli/verification.py | 10 +- src/agents_shipgate/core/current_control.py | 112 ++++++++++++++++---- tests/test_current_control.py | 93 ++++++++++++++-- 6 files changed, 229 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b30ecf2..dc32ad7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,11 +31,13 @@ generation's pointer beside another's artifacts. And because byte consistency is not generation consistency — every bound artifact still hashes correctly one unrelated commit later — the read also compares the pointer's - `workspace_identity` against the live repository: repository, HEAD commit, - HEAD tree, and, for a worktree run, both the recomputed overlay and the - current set of uncommitted paths, since a file edited *after* the decision - appears in neither HEAD nor that overlay. Completion authority is never - returned without that comparison. Two invariants are structural rather than + `workspace_identity` against the live repository: repository, HEAD commit, and + HEAD tree. Uncommitted work is checked against what the decision actually + covered — a worktree decision must still hash to the overlay it committed to + *and* see no live change outside the set it recorded, while a committed-tree + decision, which stops at HEAD, has completion blocked by any uncommitted + change that appeared afterwards. Completion authority is never returned + without that comparison. Two invariants are structural rather than advisory: only an `operation: "verify"` pointer can carry `control.state: "complete"`, and only when it also binds a `verification_receipt` whose request and decision are the ones the pointer diff --git a/docs/agent-contract-current.md b/docs/agent-contract-current.md index f57ff1bd..7c6f5104 100644 --- a/docs/agent-contract-current.md +++ b/docs/agent-contract-current.md @@ -128,10 +128,25 @@ no authority — not that the previous answer still stands. Byte consistency is not generation consistency. A pointer whose artifacts all still hash correctly can describe a workspace that one commit has moved past, so the reader compares the bound `workspace_identity` against the live repository — -repository, HEAD commit, HEAD tree, and, for a worktree run, both the recomputed -overlay and the current set of uncommitted paths — and refuses on any drift. -Completion authority is never returned without that comparison: a reader that -cannot resolve the workspace reports it as unverified rather than passing. +repository, HEAD commit, and HEAD tree — and refuses on any drift. Completion +authority is never returned without that comparison: a reader that cannot +resolve the workspace reports it as unverified rather than passing. + +Uncommitted work is checked according to what the decision actually covered: + +- A **worktree** decision (`snapshot_kind: "worktree_overlay"`) is re-checked + two ways. Every path it covered must still hash to the overlay it committed + to, and no path *outside* that set may differ from HEAD now — anything outside + it was identical to HEAD when the decision was made, so a live change the plan + never recorded is evidence the decision never saw. That second test is a + subset test, not equality: `plan.inputs.changed_paths` is the union of + `base...HEAD` and the worktree, not the uncommitted set, so requiring equality + would refuse a clean workspace the moment the run that produced it finished. +- A **committed-tree** decision (`snapshot_kind: "committed_tree"`) stops at + HEAD. Uncommitted changes appearing afterwards therefore block *completion* + but do not make the pointer unreadable — re-running the same committed + verification would reproduce it, so refusing the read outright would leave the + caller with no route at all. Given a current pointer, there are two correct "read first" paths; which one applies depends on who is reading. They are not two decisions — they are two diff --git a/llms-full.txt b/llms-full.txt index f2378ef9..d135d708 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1176,10 +1176,25 @@ no authority — not that the previous answer still stands. Byte consistency is not generation consistency. A pointer whose artifacts all still hash correctly can describe a workspace that one commit has moved past, so the reader compares the bound `workspace_identity` against the live repository — -repository, HEAD commit, HEAD tree, and, for a worktree run, both the recomputed -overlay and the current set of uncommitted paths — and refuses on any drift. -Completion authority is never returned without that comparison: a reader that -cannot resolve the workspace reports it as unverified rather than passing. +repository, HEAD commit, and HEAD tree — and refuses on any drift. Completion +authority is never returned without that comparison: a reader that cannot +resolve the workspace reports it as unverified rather than passing. + +Uncommitted work is checked according to what the decision actually covered: + +- A **worktree** decision (`snapshot_kind: "worktree_overlay"`) is re-checked + two ways. Every path it covered must still hash to the overlay it committed + to, and no path *outside* that set may differ from HEAD now — anything outside + it was identical to HEAD when the decision was made, so a live change the plan + never recorded is evidence the decision never saw. That second test is a + subset test, not equality: `plan.inputs.changed_paths` is the union of + `base...HEAD` and the worktree, not the uncommitted set, so requiring equality + would refuse a clean workspace the moment the run that produced it finished. +- A **committed-tree** decision (`snapshot_kind: "committed_tree"`) stops at + HEAD. Uncommitted changes appearing afterwards therefore block *completion* + but do not make the pointer unreadable — re-running the same committed + verification would reproduce it, so refusing the read outright would leave the + caller with no route at all. Given a current pointer, there are two correct "read first" paths; which one applies depends on who is reading. They are not two decisions — they are two diff --git a/src/agents_shipgate/cli/verification.py b/src/agents_shipgate/cli/verification.py index b5f3c75d..147d9b4e 100644 --- a/src/agents_shipgate/cli/verification.py +++ b/src/agents_shipgate/cli/verification.py @@ -61,6 +61,7 @@ ) from agents_shipgate.packet.json_packet import load_packet_json, write_packet_json from agents_shipgate.report.json_report import report_json_payload +from agents_shipgate.schemas.current_control import RECEIPT_ARTIFACT_KEY from agents_shipgate.schemas.diagnostics import NextAction from agents_shipgate.schemas.report import ReadinessReport from agents_shipgate.schemas.verification_identity import ( @@ -673,17 +674,24 @@ def assemble( # The pointer is the last file the assembler makes visible, for the same # reason it is last in `verify`: every artifact it binds now exists and has # been hashed in its final form. + # + # `--out` accepts any name beneath the artifacts root, so the receipt this + # run closed is not necessarily at the canonical path. Bind the one that was + # actually emitted: binding the canonical path instead would either miss + # this run's receipt entirely or bind an older run's. + receipt_path = out.resolve().relative_to(resolved_artifact_root).as_posix() publish_current_control( resolved_artifact_root, operation="verify", control=project_agent_control( verifier.control, operation="verify", - receipt_bound=(resolved_artifact_root / "verification-receipt.json").is_file(), + receipt_bound=out.is_file(), ), request_id=plan.request_id, decision_id=expected_decision_id, workspace_identity=workspace_identity_from_plan(plan), + artifact_paths={RECEIPT_ARTIFACT_KEY: receipt_path}, ) typer.echo(json.dumps({"receipt_id": receipt.receipt_id, "receipt": str(out)})) diff --git a/src/agents_shipgate/core/current_control.py b/src/agents_shipgate/core/current_control.py index ee9d946f..465bfdb7 100644 --- a/src/agents_shipgate/core/current_control.py +++ b/src/agents_shipgate/core/current_control.py @@ -30,7 +30,7 @@ import json import os import tempfile -from collections.abc import Callable, Collection, Iterator +from collections.abc import Callable, Collection, Iterator, Mapping from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass @@ -61,6 +61,7 @@ VerificationPlan, VerificationReceipt, content_id, + validate_portable_path, ) MAX_CURRENT_CONTROL_BYTES = 1024 * 1024 @@ -206,6 +207,7 @@ def publish_current_control( decision_id: str | None = None, workspace_identity: CurrentControlWorkspaceIdentity | None = None, artifact_keys: Collection[str] | None = None, + artifact_paths: Mapping[str, str] | None = None, ) -> CurrentControlPointer: """Bind the artifacts now on disk and publish the terminal pointer. @@ -213,9 +215,16 @@ def publish_current_control( have produced. Leaving it open would let a command bind a file an earlier, different run wrote, which is the mixed artifact set the pointer exists to make detectable. + + ``artifact_paths`` overrides where a key lives, for producers that do not + write to the canonical filename — ``verification assemble --out`` accepts any + name beneath its artifacts root, and the pointer has to bind the receipt + that run actually emitted rather than whatever occupies the canonical path. """ - artifacts = bind_current_control_artifacts(out_dir, artifact_keys=artifact_keys) + artifacts = bind_current_control_artifacts( + out_dir, artifact_keys=artifact_keys, artifact_paths=artifact_paths + ) if control.state == "complete": # Defence in depth: the schema rejects a receipt-less completion too, # but downgrading here keeps a caller with an inconsistent view from @@ -291,17 +300,26 @@ def bind_current_control_artifacts( out_dir: Path, *, artifact_keys: Collection[str] | None = None, + artifact_paths: Mapping[str, str] | None = None, ) -> dict[str, CurrentControlArtifactRef]: """Hash every selected artifact that exists as a regular file in ``out_dir``.""" + filenames = dict(CURRENT_CONTROL_ARTIFACT_FILENAMES) + for key, filename in (artifact_paths or {}).items(): + # A producer may relocate an artifact, never invent a new kind of one, + # and never point outside the directory the pointer describes. + if key not in filenames: + raise CurrentControlPublishError( + out_dir, f"unknown current control artifact key: {key!r}" + ) + try: + filenames[key] = validate_portable_path(filename) + except ValueError as exc: + raise CurrentControlPublishError(out_dir / filename, str(exc)) from exc selected = ( - CURRENT_CONTROL_ARTIFACT_FILENAMES + filenames if artifact_keys is None - else { - key: filename - for key, filename in CURRENT_CONTROL_ARTIFACT_FILENAMES.items() - if key in artifact_keys - } + else {key: filename for key, filename in filenames.items() if key in artifact_keys} ) refs: dict[str, CurrentControlArtifactRef] = {} for key, filename in selected.items(): @@ -573,22 +591,34 @@ def _validate_control_currency( path=out_dir, ) if identity.snapshot_kind == "worktree_overlay": - _validate_worktree_overlay(out_dir, pointer, live, required=grants_authority) + _validate_worktree_currency(out_dir, pointer, live, required=grants_authority) + elif grants_authority and identity.snapshot_kind == "committed_tree": + _require_clean_worktree(out_dir, live) -def _validate_worktree_overlay( +def _validate_worktree_currency( out_dir: Path, pointer: CurrentControlPointer, live: LiveWorkspace, *, required: bool, ) -> None: - """Recompute the overlay a worktree decision committed to. - - A worktree run's evidence is the uncommitted content of its changed files, - which HEAD says nothing about — editing one of them leaves both commit and - tree identical. The overlay is recomputed from the same changed-path set - the plan recorded, using the same normalization the plan used. + """Confirm the working tree is still the one a worktree decision saw. + + Two things have to hold, and neither implies the other: + + * every path the decision covered still has the content it had — recomputed + with the same normalization the plan used, because editing one of those + files leaves HEAD and its tree byte-identical; and + * nothing *outside* that set differs from HEAD now. Everything outside it + was identical to HEAD when the decision was made — that is why it was not + in the change set — so a live path the plan never recorded is new + evidence the decision never saw. + + The second is a subset test, not equality. ``plan.inputs.changed_paths`` is + not the uncommitted set: a local run with a base carries the union of + ``base...HEAD`` and the worktree, so requiring equality would refuse a clean + workspace the instant the run that produced it finished. """ ref = pointer.artifacts.get("verification_plan") @@ -631,9 +661,6 @@ def _validate_worktree_overlay( ), path=out_dir, ) - # The overlay only covers the paths that already differed from HEAD when the - # decision was made. A file edited afterwards leaves HEAD, the tree, and - # that overlay all identical, so the change *set* has to be compared too. if live.changed_paths is None: if required: raise CurrentControlUnavailable( @@ -646,17 +673,58 @@ def _validate_worktree_overlay( path=out_dir, ) return - if set(live.changed_paths) != set(decided_paths): + unseen = sorted(set(live.changed_paths) - set(decided_paths)) + if unseen: raise CurrentControlUnavailable( "workspace_changed", + _unseen_change_detail(unseen, "this decision never saw"), + path=out_dir, + ) + + +def _require_clean_worktree(out_dir: Path, live: LiveWorkspace) -> None: + """A committed-tree decision says nothing about uncommitted work. + + ``verify --head `` evaluates an archived commit, so its evidence stops + at HEAD. Completion authority from such a run must not survive uncommitted + changes that appeared afterwards — a new tool file added next to a clean + ``complete`` is exactly the capability change the decision could not have + covered. Only completion is gated: a non-authorizing committed-tree pointer + stays readable, because re-running the same committed verification would + reproduce it and refusing would leave the caller with no route at all. + """ + + if live.changed_paths is None: + raise CurrentControlUnavailable( + "workspace_unverifiable", ( - "The set of uncommitted changes is no longer the one this " - "decision was made against. Re-run verification." + "This pointer authorizes completion of a committed-tree " + "verification, but the current set of uncommitted changes could " + "not be determined." + ), + path=out_dir, + ) + if live.changed_paths: + raise CurrentControlUnavailable( + "workspace_changed", + _unseen_change_detail( + sorted(live.changed_paths), + "this committed-tree decision could not have covered", ), path=out_dir, ) +def _unseen_change_detail(paths: list[str], clause: str) -> str: + shown = ", ".join(paths[:3]) + if len(paths) > 3: + shown += f", and {len(paths) - 3} more" + return ( + f"The working tree carries {len(paths)} uncommitted change(s) {clause} " + f"({shown}). Re-run verification." + ) + + def _validate_bound_artifacts(out_dir: Path, pointer: CurrentControlPointer) -> None: for name, ref in sorted(pointer.artifacts.items()): try: diff --git a/tests/test_current_control.py b/tests/test_current_control.py index e07590d2..11691993 100644 --- a/tests/test_current_control.py +++ b/tests/test_current_control.py @@ -224,6 +224,57 @@ def test_completion_is_refused_when_the_workspace_cannot_be_checked(repo: Path) assert raised.value.reason == "workspace_unverified" +def test_a_local_run_with_a_base_stays_current_on_a_clean_tree(repo: Path) -> None: + """Stale denial: the canonical local flow must not refuse itself. + + `verify` without `--head` carries the union of `base...HEAD` and the + worktree in `plan.inputs.changed_paths`, so that set is not the uncommitted + set. Comparing them for equality refused a clean workspace the instant the + run that produced it finished -- the exact failure direction this pointer + exists to prevent. + """ + + _git(repo, "checkout", "-b", "feature") + tools = repo / "tools.json" + tools.write_text(tools.read_text(encoding="utf-8") + "\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "a committed change on the branch") + + _verify(repo, base="main", archive_head=False) + reports = repo / "agents-shipgate-reports" + plan = json.loads((reports / "verification-plan.json").read_text(encoding="utf-8")) + # The committed change is in the plan's set; the working tree is clean. + assert plan["subject"]["git"]["snapshot_kind"] == "worktree_overlay" + assert plan["inputs"]["changed_paths"] + live = _live(repo) + assert live.changed_paths == () + + assert read_current_control(reports, live=live).pointer.lifecycle_state == "terminal" + + +def test_committed_tree_completion_cannot_survive_a_new_uncommitted_file( + repo: Path, +) -> None: + """A committed-tree decision stops at HEAD; later working changes are new. + + An untracked tool file added beside a clean `complete` is exactly the + capability change an archived-commit run could not have covered. + """ + + completed, _, _ = _verify(repo) + assert completed.control.state == "complete" + reports = repo / "agents-shipgate-reports" + pointer = read_current_control(reports, live=_live(repo)).pointer + assert pointer.workspace_identity.snapshot_kind == "committed_tree" + assert pointer.control.completion_allowed is True + + (repo / "extra-tools.json").write_text('{"tools": []}\n', encoding="utf-8") + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports, live=_live(repo)) + assert raised.value.reason == "workspace_changed" + + def test_an_interrupted_run_leaves_no_decision_current( repo: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -321,11 +372,13 @@ def test_a_scan_binds_only_the_formats_it_wrote(repo: Path) -> None: assert current.request_id is None -def test_assembly_refuses_completion_when_it_binds_another_request(repo: Path) -> None: - """`assemble --out` accepts any name, so the canonical receipt may be stale. +def test_assembly_binds_the_receipt_it_emitted(repo: Path) -> None: + """`assemble --out` accepts any name, so the canonical path may be stale. - The bound receipt has to close the same request and decision the pointer - records, or completion authority is refused. + The pointer has to bind the receipt this run actually closed. Binding the + canonical path instead would either miss it entirely -- leaving a valid + custom-output run unable to publish its own completion -- or bind an older + run's receipt beside a newer decision. """ _verify(repo) @@ -350,7 +403,7 @@ def test_assembly_refuses_completion_when_it_binds_another_request(repo: Path) - diff_path=reports / "verification-input.diff", out=unit, ) - # The stale canonical receipt is what `publish_current_control` will bind. + # The canonical path now holds the previous run's receipt. (reports / "verification-receipt.json").write_text(stale_receipt, encoding="utf-8") assemble( plan_path=reports / "verification-plan.json", @@ -362,9 +415,33 @@ def test_assembly_refuses_completion_when_it_binds_another_request(repo: Path) - published = _pointer(repo) assert published["request_id"] == fresh_request - assert published["control"]["state"] == "human_review_required" - assert published["control"]["completion_allowed"] is False - assert "different request" in published["control"]["reason"] + assert published["artifacts"]["verification_receipt"]["path"] == "custom-receipt.json" + assert published["control"]["state"] == "complete" + assert published["control"]["completion_allowed"] is True + assert json.loads((reports / "verification-receipt.json").read_text())["request_id"] == ( + stale_request + ) + + +def test_completion_is_refused_when_the_bound_receipt_closes_another_request( + repo: Path, +) -> None: + """The cross-check stands on its own, for producers that get it wrong.""" + + _verify(repo) + reports = repo / "agents-shipgate-reports" + receipt = json.loads((reports / "verification-receipt.json").read_text(encoding="utf-8")) + + published = publish_current_control( + reports, + operation="verify", + control=CompleteCurrentControl(state="complete", reason="Release ready."), + request_id="sha256:" + "1" * 64, + decision_id=receipt["decision_id"], + ) + + assert published.control.state == "human_review_required" + assert "different request" in published.control.reason def test_a_preview_neither_completes_nor_binds_an_older_report(repo: Path) -> None: From bbb5a1026c133b3fe6ba8ce290064c1a457100c2 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Fri, 7 Aug 2026 22:41:42 -0700 Subject: [PATCH 4/5] test(control): do not assume the host's default branch name CI failed on `test_a_local_run_with_a_base_stays_current_on_a_clean_tree` with `assert []`. The fixture called plain `git init`, which takes the initial branch from the host's `init.defaultBranch`: `main` on my machine, `master` on the runner. The test then verified against `--base main`, which did not resolve there, so the diff came back empty and the plan carried no changed paths -- the assertion caught it, but for the wrong reason. The union path set the test exists to cover was never exercised on CI at all. The fixture now pins `git init -q -b main`, matching every other git fixture in the suite, and the changed-path assertion is exact rather than truthy so a base that silently fails to resolve cannot pass as a no-op again. Verified by reproducing the CI failure locally under `init.defaultBranch=master`, then running the file green under `master`, `main`, and `trunk`, and the full suite green under `master`. Co-Authored-By: Claude Opus 5 --- tests/test_current_control.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_current_control.py b/tests/test_current_control.py index 11691993..464283e6 100644 --- a/tests/test_current_control.py +++ b/tests/test_current_control.py @@ -71,7 +71,7 @@ def repo(tmp_path: Path) -> Path: # inputs, which is a separate pre-existing hazard this fixture should not # simulate. (workspace / ".gitignore").write_text("agents-shipgate-reports/\n", encoding="utf-8") - _git(workspace, "init") + _git(workspace, "init", "-q", "-b", "main") _git(workspace, "config", "user.email", "test@example.test") _git(workspace, "config", "user.name", "Test User") _git(workspace, "add", ".") @@ -243,9 +243,11 @@ def test_a_local_run_with_a_base_stays_current_on_a_clean_tree(repo: Path) -> No _verify(repo, base="main", archive_head=False) reports = repo / "agents-shipgate-reports" plan = json.loads((reports / "verification-plan.json").read_text(encoding="utf-8")) - # The committed change is in the plan's set; the working tree is clean. + # The committed change is in the plan's set; the working tree is clean. A + # base that did not resolve would leave changed_paths empty and quietly stop + # this test exercising the union set it exists to cover. assert plan["subject"]["git"]["snapshot_kind"] == "worktree_overlay" - assert plan["inputs"]["changed_paths"] + assert plan["inputs"]["changed_paths"] == ["tools.json"] live = _live(repo) assert live.changed_paths == () @@ -833,7 +835,7 @@ def test_agent_control_ignores_its_own_output_directory(tmp_path: Path) -> None: workspace.mkdir() for name in ("shipgate.yaml", "tools.json"): shutil.copy(SAMPLE / name, workspace / name) - _git(workspace, "init") + _git(workspace, "init", "-q", "-b", "main") _git(workspace, "config", "user.email", "test@example.test") _git(workspace, "config", "user.name", "Test User") _git(workspace, "add", ".") From e627c3e0d126bb8372641a225ab8ca55a7c4073b Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sat, 8 Aug 2026 00:41:55 -0700 Subject: [PATCH 5/5] fix(control): bind base, entry kind, and mode; invalidate stale committed stops Three currentness gaps, each reproduced on `1ce7e6dd`. A committed-tree `human_review_required` survived a worktree edit. I had scoped the clean-worktree check to `complete` to avoid what I thought was a livelock: re-running the same committed verification reproduces the same pointer, so refusing the read would leave the caller stuck. The premise was wrong. The clearing route for worktree drift is a *worktree* verification, not a rerun of the same archived one, so there is no livelock -- and leaving the check off meant the mandatory refresh kept enforcing a pre-change stop against a workspace a human had since edited, which is the stale-denial direction this pointer exists to eliminate. Committed-tree currency now applies to every terminal pointer, and the refusal names the worktree verification that clears it. Only the *undeterminable* case stays scoped to completion: a change set that could not be read is unknown rather than known-drifted, so it blocks authority without denying a caller its route. `worktree_overlay()` committed only path, present/deleted, and content hash, so a `100755` to `100644` flip on a covered path changed no row, and swapping a regular file for a symlink pointing at an identical in-repo file changed no row either -- the helper resolved the path and hashed the target. Rows now carry entry kind and the executable bit, and a symlink hashes its target string rather than what it points at. This is exactly the metadata Git itself tracks; full mode is deliberately excluded because it varies with umask and would make the identity depend on noise. This changes `worktree_overlay_sha256`, and so `subject_id` and `request_id`, for worktree runs -- not a compatibility break, since `engine_requirement_id` already carries the package version and those ids move every release. The pointer dropped `base_ref`, `base_commit_sha`, and `merge_base_sha`, so the reader checked only HEAD. Advancing a base until `base...HEAD` is empty -- a merge, or a fetch moving `origin/main` -- changes the evidence completely while leaving HEAD and the working tree untouched, and the completion-authorizing pointer stayed current. The base identity is now carried and compared, with the CLI supplying ref resolvers because the Git helpers live in that layer; an unresolvable ref compares unequal rather than raising, because a deleted base is drift the caller must see. Four regressions added, including both metadata-only mutations. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++- docs/agent-contract-current.md | 23 +++- docs/current-control-schema.v1.json | 38 ++++++ llms-full.txt | 23 +++- src/agents_shipgate/cli/agent_interface.py | 24 ++++ src/agents_shipgate/core/current_control.py | 108 +++++++++++++++--- .../core/verification_identity.py | 67 ++++++++--- .../schemas/current_control.py | 6 + tests/test_current_control.py | 108 ++++++++++++++++++ 9 files changed, 367 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc32ad7d..d89edf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,12 +32,17 @@ is not generation consistency — every bound artifact still hashes correctly one unrelated commit later — the read also compares the pointer's `workspace_identity` against the live repository: repository, HEAD commit, and - HEAD tree. Uncommitted work is checked against what the decision actually - covered — a worktree decision must still hash to the overlay it committed to - *and* see no live change outside the set it recorded, while a committed-tree - decision, which stops at HEAD, has completion blocked by any uncommitted - change that appeared afterwards. Completion authority is never returned - without that comparison. Two invariants are structural rather than + HEAD tree, plus the base revision when the decision named one — advancing a + base until `base...HEAD` is empty changes the evidence completely while + leaving HEAD and the working tree untouched. Uncommitted work is checked + against what the decision actually covered: a worktree decision must still + hash to the overlay it committed to *and* see no live change outside the set + it recorded, while a committed-tree decision, whose evidence stops at HEAD, is + invalidated by any uncommitted change that appeared afterwards. Overlay rows + bind entry kind and the executable bit alongside content, so a `100755` → + `100644` flip or a regular-file-to-symlink swap with identical bytes cannot + pass as unchanged. Completion authority is never returned without that + comparison. Two invariants are structural rather than advisory: only an `operation: "verify"` pointer can carry `control.state: "complete"`, and only when it also binds a `verification_receipt` whose request and decision are the ones the pointer diff --git a/docs/agent-contract-current.md b/docs/agent-contract-current.md index 7c6f5104..92fe7877 100644 --- a/docs/agent-contract-current.md +++ b/docs/agent-contract-current.md @@ -132,6 +132,13 @@ repository, HEAD commit, and HEAD tree — and refuses on any drift. Completion authority is never returned without that comparison: a reader that cannot resolve the workspace reports it as unverified rather than passing. +When the decision named a base, that base is compared too. A decision about +`base...HEAD` is a decision about that range, and advancing the base — a merge, +or a fetch moving `origin/main` — can empty the range without touching HEAD or +the working tree, leaving every HEAD-based check satisfied while the evidence +underneath has gone. The pointer therefore carries `base_ref`, +`base_commit_sha`, and `merge_base_sha`, and the reader resolves the ref live. + Uncommitted work is checked according to what the decision actually covered: - A **worktree** decision (`snapshot_kind: "worktree_overlay"`) is re-checked @@ -143,10 +150,18 @@ Uncommitted work is checked according to what the decision actually covered: `base...HEAD` and the worktree, not the uncommitted set, so requiring equality would refuse a clean workspace the moment the run that produced it finished. - A **committed-tree** decision (`snapshot_kind: "committed_tree"`) stops at - HEAD. Uncommitted changes appearing afterwards therefore block *completion* - but do not make the pointer unreadable — re-running the same committed - verification would reproduce it, so refusing the read outright would leave the - caller with no route at all. + HEAD, so any uncommitted change appearing afterwards invalidates it — in both + directions. A stale `complete` must not authorize work the decision never + covered, and a stale `human_review_required` must not keep enforcing a + pre-change stop. Re-running the same archived `--head` verification cannot + clear that, so the refusal routes to a worktree verification instead. + +An overlay row carries content *and* the two metadata axes Git itself tracks: +entry kind and the executable bit. Content alone is not the capability — +flipping a tool script from `100755` to `100644` changes no bytes, and swapping +a regular file for a symlink to an identical in-repo file changes no bytes +either. Full mode is deliberately not recorded: it varies with umask and would +make the identity depend on noise Git does not track. Given a current pointer, there are two correct "read first" paths; which one applies depends on who is reading. They are not two decisions — they are two diff --git a/docs/current-control-schema.v1.json b/docs/current-control-schema.v1.json index 4fb1577d..8c5e679e 100644 --- a/docs/current-control-schema.v1.json +++ b/docs/current-control-schema.v1.json @@ -128,6 +128,31 @@ "additionalProperties": false, "description": "What the pointer was published against.\n\nEvery field is nullable because the pointer is published by commands that\nresolve different amounts of identity \u2014 an in-progress marker knows only\nthe repository, a preview outside Git knows nothing. A consumer treats a\n``null`` as \"not bound\", never as \"unchanged\".", "properties": { + "base_commit_sha": { + "anyOf": [ + { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Commit Sha" + }, + "base_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Ref" + }, "head_commit_sha": { "anyOf": [ { @@ -166,6 +191,19 @@ "default": null, "title": "Head Tree Sha" }, + "merge_base_sha": { + "anyOf": [ + { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Merge Base Sha" + }, "policy_snapshot_sha256": { "anyOf": [ { diff --git a/llms-full.txt b/llms-full.txt index d135d708..268f9e04 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1180,6 +1180,13 @@ repository, HEAD commit, and HEAD tree — and refuses on any drift. Completion authority is never returned without that comparison: a reader that cannot resolve the workspace reports it as unverified rather than passing. +When the decision named a base, that base is compared too. A decision about +`base...HEAD` is a decision about that range, and advancing the base — a merge, +or a fetch moving `origin/main` — can empty the range without touching HEAD or +the working tree, leaving every HEAD-based check satisfied while the evidence +underneath has gone. The pointer therefore carries `base_ref`, +`base_commit_sha`, and `merge_base_sha`, and the reader resolves the ref live. + Uncommitted work is checked according to what the decision actually covered: - A **worktree** decision (`snapshot_kind: "worktree_overlay"`) is re-checked @@ -1191,10 +1198,18 @@ Uncommitted work is checked according to what the decision actually covered: `base...HEAD` and the worktree, not the uncommitted set, so requiring equality would refuse a clean workspace the moment the run that produced it finished. - A **committed-tree** decision (`snapshot_kind: "committed_tree"`) stops at - HEAD. Uncommitted changes appearing afterwards therefore block *completion* - but do not make the pointer unreadable — re-running the same committed - verification would reproduce it, so refusing the read outright would leave the - caller with no route at all. + HEAD, so any uncommitted change appearing afterwards invalidates it — in both + directions. A stale `complete` must not authorize work the decision never + covered, and a stale `human_review_required` must not keep enforcing a + pre-change stop. Re-running the same archived `--head` verification cannot + clear that, so the refusal routes to a worktree verification instead. + +An overlay row carries content *and* the two metadata axes Git itself tracks: +entry kind and the executable bit. Content alone is not the capability — +flipping a tool script from `100755` to `100644` changes no bytes, and swapping +a regular file for a symlink to an identical in-repo file changes no bytes +either. Full mode is deliberately not recorded: it varies with umask and would +make the identity depend on noise Git does not track. Given a current pointer, there are two correct "read first" paths; which one applies depends on who is reading. They are not two decisions — they are two diff --git a/src/agents_shipgate/cli/agent_interface.py b/src/agents_shipgate/cli/agent_interface.py index cc0570ae..415db59e 100644 --- a/src/agents_shipgate/cli/agent_interface.py +++ b/src/agents_shipgate/cli/agent_interface.py @@ -10,6 +10,7 @@ from agents_shipgate.cli.verify.git import ( commit_sha, ensure_git_workspace, + merge_base_sha, repository_identity, tree_sha, working_tree_context, @@ -208,11 +209,34 @@ def _live_workspace(workspace: Path, reports_dir: Path) -> LiveWorkspace | None: head_commit_sha=commit_sha(root, "HEAD"), head_tree_sha=tree_sha(root, "HEAD"), changed_paths=changed_paths, + resolve_commit=lambda ref: _safe_commit_sha(root, ref), + resolve_merge_base=lambda base, head: _safe_merge_base(root, base, head), ) except Exception: # noqa: BLE001 - an unresolvable workspace is "unverified". return None +def _safe_commit_sha(root: Path, ref: str) -> str | None: + """Resolve a ref recorded in a pointer; ``None`` when it no longer exists. + + A base ref that has been deleted is drift, not a crash — and it is drift the + caller must see, so a failure here resolves to ``None`` and compares unequal + rather than propagating. + """ + + try: + return commit_sha(root, ref) + except Exception: # noqa: BLE001 - an unresolvable ref is drift. + return None + + +def _safe_merge_base(root: Path, base: str, head: str) -> str | None: + try: + return merge_base_sha(root, base, head) + except Exception: # noqa: BLE001 - an unresolvable range is drift. + return None + + def _load_required_json(path: Path, label: str) -> dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) diff --git a/src/agents_shipgate/core/current_control.py b/src/agents_shipgate/core/current_control.py index 465bfdb7..bc769a41 100644 --- a/src/agents_shipgate/core/current_control.py +++ b/src/agents_shipgate/core/current_control.py @@ -388,6 +388,9 @@ def workspace_identity_from_plan(plan: VerificationPlan) -> CurrentControlWorksp head_ref=git.head_ref, head_commit_sha=git.head_commit_sha, head_tree_sha=git.head_tree_sha, + base_ref=git.base_ref, + base_commit_sha=git.base_commit_sha, + merge_base_sha=git.merge_base_sha, worktree_overlay_sha256=git.worktree_overlay_sha256, policy_snapshot_sha256=content_id( { @@ -415,6 +418,10 @@ class LiveWorkspace: # The paths that differ from HEAD right now. ``None`` means the caller could # not determine them, which is treated as unverified rather than unchanged. changed_paths: tuple[str, ...] | None = None + # Ref resolvers, supplied by the CLI because the Git helpers live there. The + # pointer names its own base, so it cannot be resolved before the read. + resolve_commit: Callable[[str], str | None] | None = None + resolve_merge_base: Callable[[str, str], str | None] | None = None @dataclass(frozen=True) @@ -590,10 +597,11 @@ def _validate_control_currency( ), path=out_dir, ) + _validate_base_currency(out_dir, identity, live, required=grants_authority) if identity.snapshot_kind == "worktree_overlay": _validate_worktree_currency(out_dir, pointer, live, required=grants_authority) - elif grants_authority and identity.snapshot_kind == "committed_tree": - _require_clean_worktree(out_dir, live) + elif identity.snapshot_kind == "committed_tree": + _require_clean_worktree(out_dir, live, required=grants_authority) def _validate_worktree_currency( @@ -682,37 +690,101 @@ def _validate_worktree_currency( ) -def _require_clean_worktree(out_dir: Path, live: LiveWorkspace) -> None: +def _require_clean_worktree(out_dir: Path, live: LiveWorkspace, *, required: bool) -> None: """A committed-tree decision says nothing about uncommitted work. ``verify --head `` evaluates an archived commit, so its evidence stops - at HEAD. Completion authority from such a run must not survive uncommitted - changes that appeared afterwards — a new tool file added next to a clean - ``complete`` is exactly the capability change the decision could not have - covered. Only completion is gated: a non-authorizing committed-tree pointer - stays readable, because re-running the same committed verification would - reproduce it and refusing would leave the caller with no route at all. + at HEAD. Uncommitted changes appearing afterwards invalidate the pointer in + *both* directions, so this is not scoped to completion: a stale + ``human_review_required`` kept enforcing a pre-change stop against a + workspace a human had since edited, and the archived run has no route that + can clear it — the clearing route is a local worktree verification, which is + what the refusal has to say. + + ``required`` narrows only the *undeterminable* case: a change set we could + not read is unknown rather than known-drifted, so it blocks authority but + does not deny a caller its route. """ if live.changed_paths is None: - raise CurrentControlUnavailable( - "workspace_unverifiable", - ( - "This pointer authorizes completion of a committed-tree " - "verification, but the current set of uncommitted changes could " - "not be determined." - ), - path=out_dir, - ) + if required: + raise CurrentControlUnavailable( + "workspace_unverifiable", + ( + "This pointer authorizes completion of a committed-tree " + "verification, but the current set of uncommitted changes " + "could not be determined." + ), + path=out_dir, + ) + return if live.changed_paths: raise CurrentControlUnavailable( "workspace_changed", _unseen_change_detail( sorted(live.changed_paths), "this committed-tree decision could not have covered", + ) + + " Re-run verification over the working tree (omit --head) to" + " decide on the workspace as it stands.", + path=out_dir, + ) + + +def _validate_base_currency( + out_dir: Path, + identity: CurrentControlWorkspaceIdentity, + live: LiveWorkspace, + *, + required: bool, +) -> None: + """Confirm the other end of the range still resolves where it did. + + A decision about ``base...HEAD`` is a decision about that range. Advancing + the base — a merge, or a fetch moving ``origin/main`` — can empty the range + entirely without touching HEAD or the working tree, which leaves every + HEAD-based check satisfied while the evidence underneath has gone. + """ + + if identity.base_ref is None or identity.base_commit_sha is None: + return + if live.resolve_commit is None: + if required: + raise CurrentControlUnavailable( + "workspace_unverifiable", + ( + "This pointer authorizes completion of a verification " + f"against {identity.base_ref!r}, but that ref could not be " + "resolved to compare against." + ), + path=out_dir, + ) + return + observed = live.resolve_commit(identity.base_ref) + if observed != identity.base_commit_sha: + raise CurrentControlUnavailable( + "workspace_changed", + ( + f"The base this decision was made against moved: {identity.base_ref!r} " + f"is now {observed!r}, but the pointer was published against " + f"{identity.base_commit_sha!r}. Re-run verification." ), path=out_dir, ) + if identity.merge_base_sha is not None and live.resolve_merge_base is not None: + observed_merge_base = live.resolve_merge_base( + identity.base_ref, identity.head_ref or "HEAD" + ) + if observed_merge_base != identity.merge_base_sha: + raise CurrentControlUnavailable( + "workspace_changed", + ( + "The merge base this decision was made against moved: it is " + f"now {observed_merge_base!r}, but the pointer was published " + f"against {identity.merge_base_sha!r}. Re-run verification." + ), + path=out_dir, + ) def _unseen_change_detail(paths: list[str], clause: str) -> str: diff --git a/src/agents_shipgate/core/verification_identity.py b/src/agents_shipgate/core/verification_identity.py index 1504344b..b1b99e99 100644 --- a/src/agents_shipgate/core/verification_identity.py +++ b/src/agents_shipgate/core/verification_identity.py @@ -980,11 +980,19 @@ def _existing_changed_blobs(paths: list[str], *, root: Path, source: str) -> lis def worktree_overlay(root: Path, paths: list[str]) -> list[dict[str, Any]]: - """Return the normalized present/deleted+hash rows for ``paths`` under ``root``. + """Return the normalized rows a worktree decision commits to. The same function builds the overlay a plan commits to and the overlay a later reader recomputes, so worktree drift cannot hide behind two slightly different normalizations. + + A row carries content *and* the two metadata axes Git itself tracks: entry + kind and the executable bit. Content alone is not the capability: flipping + a tool script from ``100755`` to ``100644`` changes no bytes, and swapping a + regular file for a symlink pointing at an identical in-repo file changes no + bytes either. Both are changes a decision must not survive. Full mode is + deliberately not recorded — it varies with umask and would make the identity + depend on noise Git does not track. """ return _worktree_overlay(root, paths) @@ -993,25 +1001,54 @@ def worktree_overlay(root: Path, paths: list[str]) -> list[dict[str, Any]]: def _worktree_overlay(root: Path, paths: list[str]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] snapshot = active_static_input_snapshot() + root_resolved = root.resolve() for relative in sorted(set(paths)): - candidate = (root / relative).resolve() - if root.resolve() not in candidate.parents: + lexical = root / relative + candidate = lexical.resolve() + if root_resolved not in candidate.parents: raise ValueError(f"worktree path escapes repository: {relative}") - present = ( - snapshot.has(candidate) - if snapshot is not None and snapshot.contains(candidate) - else candidate.is_file() - ) - rows.append( - { - "path": relative, - "status": "present" if present else "deleted", - "sha256": sha256_file(candidate) if present else None, - } - ) + rows.append({"path": relative, **_overlay_entry(lexical, candidate, snapshot)}) return rows +def _absent_overlay_entry() -> dict[str, Any]: + return {"status": "deleted", "kind": None, "executable": None, "sha256": None} + + +def _overlay_entry(lexical: Path, candidate: Path, snapshot: Any) -> dict[str, Any]: + try: + info = os.lstat(lexical) + except OSError: + return _absent_overlay_entry() + if stat.S_ISLNK(info.st_mode): + # Hash the link target, never what it points at. Following it here is + # what let a regular file and a symlink to an identical file produce the + # same row. + try: + target = os.readlink(lexical) + except OSError: + return _absent_overlay_entry() + return { + "status": "present", + "kind": "symlink", + "executable": False, + "sha256": sha256_bytes(target.encode("utf-8")), + } + present = ( + snapshot.has(candidate) + if snapshot is not None and snapshot.contains(candidate) + else candidate.is_file() + ) + if not present: + return _absent_overlay_entry() + return { + "status": "present", + "kind": "file", + "executable": bool(info.st_mode & 0o111), + "sha256": sha256_file(candidate), + } + + def _media_type(path: Path) -> str: if path.suffix == ".json": return "application/json" diff --git a/src/agents_shipgate/schemas/current_control.py b/src/agents_shipgate/schemas/current_control.py index 1971598f..7f6e3750 100644 --- a/src/agents_shipgate/schemas/current_control.py +++ b/src/agents_shipgate/schemas/current_control.py @@ -92,6 +92,12 @@ class CurrentControlWorkspaceIdentity(BaseModel): head_ref: str | None = None head_commit_sha: str | None = Field(default=None, pattern=GIT_OBJECT_PATTERN) head_tree_sha: str | None = Field(default=None, pattern=GIT_OBJECT_PATTERN) + # The other end of the range. A decision about `base...HEAD` is only about + # that range: advancing the base until the range is empty changes the + # evidence completely while leaving HEAD and the working tree untouched. + base_ref: str | None = None + base_commit_sha: str | None = Field(default=None, pattern=GIT_OBJECT_PATTERN) + merge_base_sha: str | None = Field(default=None, pattern=GIT_OBJECT_PATTERN) worktree_overlay_sha256: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) policy_snapshot_sha256: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) snapshot_kind: Literal["committed_tree", "worktree_overlay"] | None = None diff --git a/tests/test_current_control.py b/tests/test_current_control.py index 464283e6..b28908e4 100644 --- a/tests/test_current_control.py +++ b/tests/test_current_control.py @@ -26,6 +26,7 @@ from agents_shipgate.cli.verify import orchestrator as verify_orchestrator from agents_shipgate.cli.verify.git import ( commit_sha, + merge_base_sha, repository_identity, tree_sha, working_tree_context, @@ -107,12 +108,27 @@ def _live(repo: Path) -> LiveWorkspace: """The live workspace, resolved the way `agents-shipgate agent control` does.""" changed, _ = working_tree_context(repo, exclude=repo / "agents-shipgate-reports") + + def resolve_commit(ref: str) -> str | None: + try: + return commit_sha(repo, ref) + except Exception: # noqa: BLE001 - an unresolvable ref is drift. + return None + + def resolve_merge_base(base: str, head: str) -> str | None: + try: + return merge_base_sha(repo, base, head) + except Exception: # noqa: BLE001 - an unresolvable range is drift. + return None + return LiveWorkspace( root=repo, repository=repository_identity(repo), head_commit_sha=commit_sha(repo, "HEAD"), head_tree_sha=tree_sha(repo, "HEAD"), changed_paths=tuple(changed), + resolve_commit=resolve_commit, + resolve_merge_base=resolve_merge_base, ) @@ -213,6 +229,98 @@ def test_cached_completion_cannot_survive_an_uncommitted_edit(repo: Path) -> Non assert raised.value.reason == "workspace_changed" +def test_a_committed_tree_stop_does_not_survive_a_worktree_edit(repo: Path) -> None: + """Stale denial: a pre-change stop must not be enforced after the change. + + The archived run cannot clear itself -- re-running the same `--head` + verification reproduces the same decision -- so the refusal has to route the + caller to a worktree verification instead of leaving the old stop standing. + """ + + _git(repo, "checkout", "-b", "feature") + manifest = repo / "shipgate.yaml" + manifest.write_text(manifest.read_text(encoding="utf-8") + "\n# reviewed\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "a trust-root edit a human must review") + stopped, _, _ = _verify(repo, base="main") + assert stopped.control.state == "human_review_required" + reports = repo / "agents-shipgate-reports" + pointer = read_current_control(reports, live=_live(repo)).pointer + assert pointer.workspace_identity.snapshot_kind == "committed_tree" + assert pointer.control.must_stop is True + + (repo / "scratch.py").write_text("# a human edited the worktree\n", encoding="utf-8") + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports, live=_live(repo)) + assert raised.value.reason == "workspace_changed" + assert "omit --head" in str(raised.value) + + +@pytest.mark.parametrize("mutation", ["chmod", "symlink"]) +def test_metadata_only_changes_invalidate_a_worktree_decision( + repo: Path, mutation: str +) -> None: + """Content is not the whole capability. + + Flipping a tool file's executable bit changes no bytes, and swapping it for + a symlink to an identical in-repo file changes no bytes either. Both are + changes the decision must not survive, and both leave HEAD, the tree, and a + content-only overlay row identical. + """ + + tools = repo / "tools.json" + tools.write_text(tools.read_text(encoding="utf-8") + "\n", encoding="utf-8") + _verify(repo, archive_head=False) + reports = repo / "agents-shipgate-reports" + plan = json.loads((reports / "verification-plan.json").read_text(encoding="utf-8")) + assert "tools.json" in plan["inputs"]["changed_paths"] + assert read_current_control(reports, live=_live(repo)).pointer.lifecycle_state == "terminal" + + if mutation == "chmod": + tools.chmod(0o755) + else: + twin = repo / "twin.json" + twin.write_bytes(tools.read_bytes()) + tools.unlink() + tools.symlink_to("twin.json") + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports, live=_live(repo)) + assert raised.value.reason == "workspace_changed" + + +def test_advancing_the_base_invalidates_a_decision_about_the_range(repo: Path) -> None: + """A decision about `base...HEAD` is a decision about that range. + + Advancing the base can empty the range without touching HEAD or the working + tree, which leaves every HEAD-based check satisfied while the evidence the + decision rested on is gone. + """ + + _git(repo, "checkout", "-b", "feature") + tools = repo / "tools.json" + tools.write_text(tools.read_text(encoding="utf-8") + "\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "feature work") + + _verify(repo, base="main") + reports = repo / "agents-shipgate-reports" + pointer = read_current_control(reports, live=_live(repo)).pointer + assert pointer.workspace_identity.base_ref == "main" + assert pointer.workspace_identity.base_commit_sha + head_before = commit_sha(repo, "HEAD") + + # Advance the base only. HEAD and the working tree are untouched. + _git(repo, "branch", "-f", "main", "HEAD") + assert commit_sha(repo, "HEAD") == head_before + + with pytest.raises(CurrentControlUnavailable) as raised: + read_current_control(reports, live=_live(repo)) + assert raised.value.reason == "workspace_changed" + assert "base this decision was made against moved" in str(raised.value) + + def test_completion_is_refused_when_the_workspace_cannot_be_checked(repo: Path) -> None: """`live=None` means "not compared", which is never a pass for completion."""