diff --git a/.agents/skills/agents-shipgate/SKILL.md b/.agents/skills/agents-shipgate/SKILL.md index a34b43c0..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. +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 87c649eb..42ea39a8 100644 --- a/.agents/skills/agents-shipgate/references/report-reading.md +++ b/.agents/skills/agents-shipgate/references/report-reading.md @@ -1,12 +1,17 @@ # 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 --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 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..8c8b5c59 100644 --- a/.cursor/rules/agents-shipgate.mdc +++ b/.cursor/rules/agents-shipgate.mdc @@ -68,6 +68,19 @@ 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 --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 +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..de4773fc 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,28 @@ 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 --workspace . --reports-dir agents-shipgate-reports +``` + +A zero exit means the printed pointer was validated against every artifact it +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 +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..d89edf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,66 @@ ## 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. 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, and + 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 + 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`, + 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..61f264d6 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,11 @@ 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 --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`, `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..d389beba 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,9 @@ "98ba22d7518ae4635ed109fd187323da0541281061dd4f259ac7fdb950c7b185", "02e780f5a1506d948e4c1d77f6ee4c6b4193227a4fd2ced081847d1fb2e5fbd0", "bc5cd31a5c4d4f6a1ebf6a04db3f80480e7cc5f9ab2b7a6f7e3f62e8ddfc3937", - "58ea3b6bba89078ec54d6b5493ffebf9250d9619fbacef5090285b009e58cdcd" + "58ea3b6bba89078ec54d6b5493ffebf9250d9619fbacef5090285b009e58cdcd", + "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 93c8815e..fb5965cf 100644 --- a/adoption-kits/claude-code-skill/SKILL.md +++ b/adoption-kits/claude-code-skill/SKILL.md @@ -52,7 +52,16 @@ 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 --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. 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 +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` 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 955ec5b5..6c15dc35 100644 --- a/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json +++ b/adoption-kits/codex-skill/.agents-shipgate-kit-metadata.json @@ -19,7 +19,9 @@ "37795c6ffc3dcdda624b609dd17da8656c245d00ea9cebb9fae25c50842a4a9b", "a8dd8e22d9a9dd3358f9d7d328f6f5da5d80ac142681dfdb28b96b44bc68004b", "ad6ca3c53872f1d7e2d8a42794a766f51f0c50a8b4599bb3b76ddd2e31260af1", - "4cbd6a9b978bb142908b8601f52fa1b8fe6a0aa89ecd05eec28f3b00f470ff04" + "4cbd6a9b978bb142908b8601f52fa1b8fe6a0aa89ecd05eec28f3b00f470ff04", + "10fcba81a9d07d7f1736fd3177c162dfdc92d5a106469c3def907d233a270dd4", + "4bde76605784f71eaf6fe57f0c3f4e44e204f10f4eb023edb15424710928894f" ], "references/recipes.md": [ "df5110bfa05eeabd9b918d8902b5c054fa547d1155be61ef6e7d7d63378bf210", @@ -43,7 +45,9 @@ "3e7bd6a3a882f5e52c0fc4f215c5589149f8eb24eeef0ea054854f03f0f050de", "6d2848f3436f6e246bf553e6cf061c990888d6ff39eb82fec9a41f291b2e94fe", "7baeb0715e59daf92cf78fb9fee6e4f659174f5fcb6c9a1e3eca9f73fc697503", - "b652d59a846ccf131df71cf284e10f2e5a72c6c1da6d5454067f1a6a457452d5" + "b652d59a846ccf131df71cf284e10f2e5a72c6c1da6d5454067f1a6a457452d5", + "a5dd0eec215e973c403a22bdbbf0eca76a1796e20be5ae9623829c9d11d2a8dc", + "7b70e3ab34ed2f74175e2fd14df0ef98bdddd41b7c0460d10684f17f8cf3c00c" ], "agents/openai.yaml": [ "4d94a724336e5d36a2769630495f341007580e4dee306bc42a1aeca1af9e867b" diff --git a/adoption-kits/codex-skill/SKILL.md b/adoption-kits/codex-skill/SKILL.md index a34b43c0..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. +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 87c649eb..42ea39a8 100644 --- a/adoption-kits/codex-skill/references/report-reading.md +++ b/adoption-kits/codex-skill/references/report-reading.md @@ -1,12 +1,17 @@ # 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 --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 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..92fe7877 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,53 @@ 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 --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, 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 + 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, 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 +entry points into the same one decision engine. - **PR / controller flow** — an autonomous coding agent deciding *continue, repair, or stop*. Prefer @@ -141,6 +200,23 @@ 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` 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 +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..8c5e679e --- /dev/null +++ b/docs/current-control-schema.v1.json @@ -0,0 +1,433 @@ +{ + "$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": { + "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": [ + { + "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" + }, + "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": [ + { + "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..3ce0e408 100644 --- a/docs/target-repo-agent-snippets.md +++ b/docs/target-repo-agent-snippets.md @@ -85,6 +85,19 @@ 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 --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 +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 +206,19 @@ 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 --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 +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 +305,19 @@ 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 --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 +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..268f9e04 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,28 @@ 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 --workspace . --reports-dir agents-shipgate-reports +``` + +A zero exit means the printed pointer was validated against every artifact it +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 +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 +1058,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 +1135,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 +1167,53 @@ 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 --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, 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 + 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, 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 +entry points into the same one decision engine. - **PR / controller flow** — an autonomous coding agent deciding *continue, repair, or stop*. Prefer @@ -1165,6 +1248,23 @@ 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` 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 +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..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`. -- 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 --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 a34b43c0..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. +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 87c649eb..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,12 +1,17 @@ # 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 --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 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..fb5965cf 100644 --- a/plugins/claude-code/skills/agents-shipgate/SKILL.md +++ b/plugins/claude-code/skills/agents-shipgate/SKILL.md @@ -52,7 +52,16 @@ 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 --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. 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 +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` 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/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..fb5965cf 100644 --- a/skills/agents-shipgate/SKILL.md +++ b/skills/agents-shipgate/SKILL.md @@ -52,7 +52,16 @@ 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 --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. 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 +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` 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/_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..415db59e 100644 --- a/src/agents_shipgate/cli/agent_interface.py +++ b/src/agents_shipgate/cli/agent_interface.py @@ -6,14 +6,48 @@ 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, + merge_base_sha, + 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 +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.", 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( @@ -81,6 +115,128 @@ def handoff( typer.echo(f"Wrote agent handoff to {out}") +@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", + 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, 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, live=_live_workspace(workspace, 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." + ) + # 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( + kind, + message=str(exc), + exit_code=exit_code, + next_action=guidance, + next_actions=[ + NextAction( + kind="command", + command=COMMANDS["verify_pr"], + why=guidance, + expects=( + "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(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, + 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")) @@ -105,4 +261,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..91d0db69 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,22 @@ "`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 --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 +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..e73d6a4e 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,19 @@ SUGGESTED_INVENTORY_FILENAME, ) 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, +) 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 +52,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 +109,45 @@ 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. + # + # 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", + 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), + ), + artifact_keys={ + SCAN_FORMAT_ARTIFACT_KEYS[name] + for name in plan.generated_paths + if name in SCAN_FORMAT_ARTIFACT_KEYS + }, + ) + + +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..147d9b4e 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, @@ -55,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 ( @@ -99,6 +106,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 +671,28 @@ 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. + # + # `--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=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/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..bc769a41 --- /dev/null +++ b/src/agents_shipgate/core/current_control.py @@ -0,0 +1,950 @@ +"""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, Mapping +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, + worktree_overlay, +) +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, + VerificationReceipt, + content_id, + validate_portable_path, +) + +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", + "report_markdown": "report.md", + "report_sarif": "report.sarif", + "packet": "packet.json", + "pr_comment": "pr-comment.md", +} + +# 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 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 +) + + +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, + artifact_paths: Mapping[str, 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. + + ``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, 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 + # 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", + 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 _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, + *, + 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 = ( + filenames + if artifact_keys is None + else {key: filename for key, filename in 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, + 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( + { + "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 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 + # 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) +class CurrentControlRead: + """A pointer that was validated against the artifacts it binds.""" + + pointer: CurrentControlPointer + path: Path + + +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, + 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. + + 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) + 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 + _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) + 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_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, + ) + _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 identity.snapshot_kind == "committed_tree": + _require_clean_worktree(out_dir, live, required=grants_authority) + + +def _validate_worktree_currency( + out_dir: Path, + pointer: CurrentControlPointer, + live: LiveWorkspace, + *, + required: bool, +) -> None: + """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") + 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, + ) + 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 + 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, *, required: bool) -> None: + """A committed-tree decision says nothing about uncommitted work. + + ``verify --head `` evaluates an archived commit, so its evidence stops + 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: + 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: + 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: + 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", + "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", + "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..b1b99e99 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")) @@ -960,28 +979,76 @@ 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 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) + + 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" @@ -1198,6 +1265,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 23d9b917..34e29388 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,10 @@ "agents-shipgate-reports/verification-receipt.json --artifacts-root " "agents-shipgate-reports" ), + "agent_control": ( + "agents-shipgate agent control --workspace . " + "--reports-dir agents-shipgate-reports" + ), "agent_handoff": ( "agents-shipgate agent handoff --from agents-shipgate-reports/verifier.json --json" ), @@ -276,6 +312,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 +334,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 +417,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 +490,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 +567,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..7f6e3750 --- /dev/null +++ b/src/agents_shipgate/schemas/current_control.py @@ -0,0 +1,255 @@ +"""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) + # 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 + + +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 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 + # 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..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": ( - "f6771ad16589854d51604cf35af0d78022b6a28e5ffa97a1d0c429ee6b6107da" + "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": ( - "10fcba81a9d07d7f1736fd3177c162dfdc92d5a106469c3def907d233a270dd4" + "caba08258bdb5d89b0b2370b02a58f79e2ef26ea2ed36a1c1bd42357322dbbd9" ), ".agents/skills/agents-shipgate/agents/openai.yaml": ( "aa511e933ff663dcd1e0d2af3da2a7101206ce2bb1bb98c4dae801bb3f4e42ef" @@ -93,7 +93,7 @@ "97a8eb98fc560405c690581ac5542b2f94783480f2266af36f566ae12600e2cb" ), ".agents/skills/agents-shipgate/references/report-reading.md": ( - "a5dd0eec215e973c403a22bdbbf0eca76a1796e20be5ae9623829c9d11d2a8dc" + "d35c6d6a332ea53ea37e50e9ad7a46b5f124d982922e90f09379107e850830cf" ), } @@ -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..b28908e4 --- /dev/null +++ b/tests/test_current_control.py @@ -0,0 +1,1023 @@ +"""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.git import ( + commit_sha, + merge_base_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, + 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", "-q", "-b", "main") + _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 _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, + ) + + +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", 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 + 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_committed_change(repo: Path) -> None: + """Reverse regression: a remembered `complete` is not authority later. + + 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" + reports = repo / "agents-shipgate-reports" + authorized = read_current_control(reports, live=_live(repo)).pointer + assert authorized.control.completion_allowed is True + + _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, 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_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.""" + + 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_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. 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"] == ["tools.json"] + 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: + """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_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_binds_the_receipt_it_emitted(repo: Path) -> None: + """`assemble --out` accepts any name, so the canonical path may be stale. + + 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) + 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 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", + 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["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: + _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, live=_live(repo)).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, live=_live(repo), 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", + "--workspace", + str(repo), + "--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_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", "-q", "-b", "main") + _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() + result = runner.invoke(app, ["agent", "control", "--reports-dir", str(empty)]) + + assert result.exit_code == 3 + 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: + """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" ),