diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index d4b1114b..55481a42 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -10,7 +10,7 @@
"name": "agents-shipgate",
"source": "./plugins/claude-code",
"description": "Run Agents Shipgate Tool-Use Readiness workflows from Claude Code: detect whether Shipgate applies, bootstrap the advisory gate, verify agent-capability PRs, and triage findings without fabricating human approval. Requires the agents-shipgate CLI (pipx install agents-shipgate, runtime contract 15).",
- "version": "0.16.0b6",
+ "version": "0.16.0b7",
"author": {
"name": "Three Moons Lab",
"url": "https://threemoonslab.com/"
diff --git a/.github/workflows/agents-shipgate-self.yml b/.github/workflows/agents-shipgate-self.yml
index ad81e7be..386b0e21 100644
--- a/.github/workflows/agents-shipgate-self.yml
+++ b/.github/workflows/agents-shipgate-self.yml
@@ -21,6 +21,9 @@ jobs:
verify_mode: verify
ci_mode: advisory
diff_base: target
- fail_on_merge_verdicts: "blocked,human_review_required,insufficient_evidence,unknown"
+ # Same advisory semantics as agents-shipgate.yml: fail only on
+ # blocked/unknown; human_review_required is decided by the PR
+ # reviewer, not by a CI state nothing can clear.
+ fail_on_merge_verdicts: "blocked,unknown"
upload_artifact: "true"
pr_comment: "false"
diff --git a/.github/workflows/agents-shipgate.yml b/.github/workflows/agents-shipgate.yml
index 225244c2..7535dbea 100644
--- a/.github/workflows/agents-shipgate.yml
+++ b/.github/workflows/agents-shipgate.yml
@@ -20,6 +20,12 @@ jobs:
config: shipgate.yaml
ci_mode: advisory
diff_base: target
- fail_on_merge_verdicts: "blocked,human_review_required,insufficient_evidence,unknown"
+ # Advisory semantics: human_review_required means "the PR reviewer
+ # decides" — no verifier mechanism can clear that verdict, so failing
+ # CI on it makes every trust-root-touching PR (release pin sweeps,
+ # AGENTS.md edits) permanently red. CI fails only on blocked and on
+ # unknown (fail-closed against parse/contract breakage); review
+ # routing stays visible in the uploaded verifier artifact.
+ fail_on_merge_verdicts: "blocked,unknown"
upload_artifact: "true"
pr_comment: "false"
diff --git a/.well-known/agents-shipgate.json b/.well-known/agents-shipgate.json
index 84053ce2..57ff83d5 100644
--- a/.well-known/agents-shipgate.json
+++ b/.well-known/agents-shipgate.json
@@ -3,7 +3,7 @@
"name": "agents-shipgate",
"display_name": "Agents Shipgate",
"tagline": "The deterministic merge gate for AI-generated agent capability changes",
- "version": "0.16.0b6",
+ "version": "0.16.0b7",
"license": "Apache-2.0",
"publisher": {
"name": "Three Moons Lab",
@@ -195,7 +195,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": "18",
+ "contract_version": "19",
"minimum_control_contract_version": "14",
"local_agent_contract_schema_version": "7",
"inputs": [
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48b1efac..64fbfb1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,55 @@
## Unreleased
+- **Graded local boundary stop (UX P0, contract v19, `0.16.0b7`).** The
+ local `shipgate check` previously projected every `require_review`
+ boundary violation onto the same `human_review_required` +
+ `must_stop: true` control state as a `block` — a CLAUDE.md comment, an
+ unknown `.claude/settings.json` key, and a critical grant expansion were
+ operationally identical, which routinely hard-stopped coding agents on
+ user-requested benign edits. Contract v19 routes a `require_review` set
+ that is entirely low/medium risk to
+ `control.state: "agent_action_required"` with the exact verify command;
+ the review obligation is preserved in the new additive `pending_review[]`
+ field on `shipgate.agent_boundary_result/v1` and re-asserted by PR-time
+ verify, whose `release_decision` branching is byte-identical. The band is
+ fail-closed: `block` actions, `critical` risk, incomplete or unparseable
+ input, gate-weakening rules, experimental surfaces, and every
+ gate-governing trust-root class (`manifest`, `policy`, `ci_gate`,
+ `shipgate_state`) keep the human stop, preserving the composite-diff
+ guarantee from the agent-authored proposal work. The deprecated
+ `codex-boundary-json` format grades identically; its frozen v2 schema
+ does not carry the new field.
+- **Stop hook follows `control.state` (`0.16.0b7`).** The installed Claude
+ Code Stop hook blocked the agent's stop on any non-`passed` release
+ decision — but a Stop-hook block forces the agent to KEEP working, which
+ is exactly wrong for `human_review_required` (`must_stop: true` means
+ "end the turn and hand off to a human"). The hook now mirrors the
+ operational contract: `complete` ends the turn silently,
+ `agent_action_required` blocks once and names the one exact remaining
+ command, `human_review_required` prints a hand-off notice and lets the
+ turn end. Unparseable or unrecognized verifier output warns loudly, is
+ never cached by the verified-signature short-circuit, and is never
+ treated as passing; the cold-start no-manifest case advises
+ `verify --preview` instead of forcing continuation. Reinstall hooks to
+ pick up the new behavior.
+- **Own-repo CI verify gates on `blocked,unknown` again (`0.16.0b7`).** The
+ 0.16.0b3-era expansion of `fail_on_merge_verdicts` to include
+ `human_review_required` and `insufficient_evidence` (#274) made every
+ trust-root-touching PR — including routine release pin sweeps that bump the
+ plugin manifests — permanently red: no verifier mechanism can clear a
+ `human_review_required` merge verdict, and the verdict's own semantics are
+ "release is allowed but the human reviewer should weigh in." Both advisory
+ workflows now fail only on `blocked` and `unknown` (fail-closed against
+ parse/contract breakage); review routing remains visible in the uploaded
+ verifier artifact and the PR reviewer stays the deciding human. The
+ GitHub Action's own defaults are unchanged.
+- **Version advances (`0.16.0b7`).** Runtime contract `18 → 19`. All other
+ schema versions are unchanged; `pending_review[]` is additive on the
+ regenerated `agent-boundary-result-schema.v1.json`, and
+ `minimum_control_contract_version` stays `14` — the `AgentControl` union,
+ its fixed `must_stop`/`completion_allowed` literals, and the release
+ gating signal are untouched.
- **Reproducible verification identity (P0, `0.16.0b6`).** Verify now binds
the resolved Git subject, exact input blobs, evaluation date, behavior
options, installed engine-content and dependency/adapter/plugin/policy set, normalized task,
diff --git a/README.md b/README.md
index cf596e80..cd59037c 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Local-first and static by default — no agent execution, tool calls, LLM calls,
> [!IMPORTANT]
> **Status: pre-1.0 (beta).** The decision engine is deterministic and stable.
-> This source tree is `0.16.0b6`; install and GitHub Action examples remain
+> This source tree is `0.16.0b7`; install and GitHub Action examples remain
> pinned to the latest published tag, `v0.15.0`, until the beta is released.
> Real-history accuracy numbers (small n, published in full in
> [`benchmark/miner/README.md`](benchmark/miner/README.md)): across 361 mined
diff --git a/STABILITY.md b/STABILITY.md
index 22f884e8..55a26d8e 100644
--- a/STABILITY.md
+++ b/STABILITY.md
@@ -1,4 +1,4 @@
-# Stability Contract · 0.16.0b6
+# Stability Contract · 0.16.0b7
What agents and CI integrations can rely on across versions of Agents Shipgate.
@@ -13,6 +13,52 @@ for reproducible CI.
---
+
+
+## Migration Note: 0.16.0b7
+
+Runtime contract `18 → 19` grades the LOCAL boundary stop. Under contract
+v14–v18 every `require_review` boundary violation projected
+`control.state: "human_review_required"` with `must_stop: true` — a
+CLAUDE.md prose edit and a critical grant expansion were operationally
+identical. As of v19, a `require_review` violation set that is entirely
+low/medium risk projects `control.state: "agent_action_required"` with the
+exact verify command, and the review obligation is carried in the new
+additive `pending_review[]` field on
+`shipgate.agent_boundary_result/v1` (each entry: `check_id`, `rule_id`,
+`path`, `risk_level`, `title`, `reviewers`, `note`). The deprecated
+`codex-boundary-json` format grades identically but its frozen v2 schema
+does not carry the new field.
+
+The graded band is deliberately narrow and fail-closed. These keep the
+`human_review_required` stop at any scored risk: any `block` action or
+`critical` risk in the set; `BOUNDARY-INPUT-INCOMPLETE` and parse-failure
+evidence (unparseable content is not reviewable content — only the
+parseable `unknown_host_config_key` case is band-eligible);
+`CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED` (gate weakening); experimental
+adapter surfaces; and every violation touching a gate-governing trust-root
+class (`manifest`, `policy`, `ci_gate`, `shipgate_state`), which preserves
+the composite-diff guarantee that a safe manifest append bundled with an
+unsafe manifest edit still routes to a human.
+
+The v14 control invariants are unchanged: `must_stop` equals
+`state == "human_review_required"`, `completion_allowed` equals
+`state == "complete"`, and conversation-level acknowledgement cannot clear
+control state. PR-time `release_decision` branching is byte-identical —
+the graded rows still land in `review_items` and route to a human at the
+merge gate; only the local turn-level stop is relaxed.
+
+The installed Claude Code **Stop hook** now follows `verifier.control.state`
+instead of the release label: `complete` ends the turn silently,
+`agent_action_required` blocks the stop once and names the one exact
+remaining command, and `human_review_required` prints a hand-off notice and
+lets the turn end — a Stop-hook block forces continued agent work, which
+`must_stop` semantics forbid. Unparseable or unrecognized verifier output
+warns, is never cached, and is never treated as passing. The cold-start
+no-manifest case advises `verify --preview` instead of forcing
+continuation. Reinstall hooks (`agents-shipgate install-hooks --target
+claude-code --write`) to pick up the new behavior.
+
## Migration Note: 0.16.0b6
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 74ca59bb..3ce125ef 100644
--- a/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json
+++ b/adoption-kits/claude-code-skill/.agents-shipgate-kit-metadata.json
@@ -71,7 +71,8 @@
"b2e42bce1eb6892c5207890bd53ab987d0beaac7c9fa2d9de8ba07033a3f2f60",
"2b1d989ee32b8cfee59fb1cc0f51d70277cc16c9f2ad72932a348ecd285b4c38",
"e35be58338a6343c712a6a63aff826f5785040f201aa0d7f5f6f257c27d12532",
- "44648891d77a12f562913e4402c08bca4be6cff4aa8c69b7950ea97e7d5cc17b"
+ "44648891d77a12f562913e4402c08bca4be6cff4aa8c69b7950ea97e7d5cc17b",
+ "7fcf37dacee5cc64263d21c6be3a5c7e99b4e6fb45d96b3dda7bdd0603aff6cd"
],
"prompts/decide-shipgate-relevance.md": [
"1bf8b9d91f081a246dcff14a84810ca5384f8e0987e4e7a8c0c5df56b151564c",
diff --git a/benchmark/safety-qualification/README.md b/benchmark/safety-qualification/README.md
index 6935339d..260b474d 100644
--- a/benchmark/safety-qualification/README.md
+++ b/benchmark/safety-qualification/README.md
@@ -1,6 +1,6 @@
# Evidence-Backed Pass Safety Qualification
-This directory is the runbook for the `0.16.0b6` beta safety qualification.
+This directory is the runbook for the `0.16.0b7` beta safety qualification.
The repository does **not** ship fabricated human labels or a passing result.
Until a real frozen corpus and its verifier receipts exist,
`safety-qualification.json` must not be published as qualified.
@@ -53,7 +53,7 @@ are marked `qualification_tier: test` and can never set
```bash
PYTHONPATH=src python scripts/run_safety_qualification.py \
- --wheel dist/agents_shipgate-0.16.0b6-py3-none-any.whl \
+ --wheel dist/agents_shipgate-0.16.0b7-py3-none-any.whl \
--corpus /secure/frozen-corpus.json \
--receipts /secure/receipt-index.json \
--policy /secure/qualification-policy/ \
diff --git a/docs/agent-boundary-result-schema.v1.json b/docs/agent-boundary-result-schema.v1.json
index b551d96b..9846a492 100644
--- a/docs/agent-boundary-result-schema.v1.json
+++ b/docs/agent-boundary-result-schema.v1.json
@@ -189,6 +189,74 @@
"title": "AgentResultDiagnostic",
"type": "object"
},
+ "AgentResultPendingReviewItem": {
+ "additionalProperties": false,
+ "description": "One finding that still owes human review while the agent may continue.\n\nEmitted when the local mapping routes a low/medium ``require_review``\nboundary change onto the coding-agent verify route instead of stopping the\nturn. The obligation is not cleared: it is carried here so the agent can\nreport it, and PR-time verify still routes the same change to a human.\n``reviewers`` holds the reviewer set that a stop would have demanded \u2014\n``control.human_review`` cannot carry it outside a human route.",
+ "properties": {
+ "check_id": {
+ "title": "Check Id",
+ "type": "string"
+ },
+ "note": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Note"
+ },
+ "path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Path"
+ },
+ "reviewers": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Reviewers",
+ "type": "array"
+ },
+ "risk_level": {
+ "enum": [
+ "none",
+ "low",
+ "medium",
+ "high",
+ "critical"
+ ],
+ "title": "Risk Level",
+ "type": "string"
+ },
+ "rule_id": {
+ "title": "Rule Id",
+ "type": "string"
+ },
+ "title": {
+ "title": "Title",
+ "type": "string"
+ }
+ },
+ "required": [
+ "check_id",
+ "rule_id",
+ "risk_level",
+ "title"
+ ],
+ "title": "AgentResultPendingReviewItem",
+ "type": "object"
+ },
"AgentResultPolicy": {
"additionalProperties": false,
"properties": {
@@ -401,7 +469,7 @@
"type": "string"
},
"version": {
- "default": "0.16.0b6",
+ "default": "0.16.0b7",
"title": "Version",
"type": "string"
}
@@ -1111,6 +1179,13 @@
"title": "Issues",
"type": "array"
},
+ "pending_review": {
+ "items": {
+ "$ref": "#/$defs/AgentResultPendingReviewItem"
+ },
+ "title": "Pending Review",
+ "type": "array"
+ },
"policies": {
"items": {
"$ref": "#/$defs/AgentResultPolicy"
diff --git a/docs/agent-contract-current.md b/docs/agent-contract-current.md
index 8838dc52..62826ad6 100644
--- a/docs/agent-contract-current.md
+++ b/docs/agent-contract-current.md
@@ -10,10 +10,21 @@ Verify the installed CLI contract locally before relying on hard-coded docs:
agents-shipgate contract --json
```
-Runtime contract v18 retains the v17 content-addressed verification identity,
+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
+the review obligation is carried in the additive
+`pending_review[]` field on the agent-boundary result instead of ending the
+turn. Block actions, critical risk, gate-weakening rules
+(`CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED`), unparseable content,
+incomplete input, experimental surfaces, and every gate-governing trust-root
+class (manifest, policy, ci_gate, shipgate_state) keep the
+`human_review_required` stop. PR-time `release_decision` semantics are
+unchanged. It retains the v18 human-authorization overlay, the v17
+content-addressed verification identity,
v16 typed policy-evidence, v15 host-neutral
boundary, v14 unambiguous `AgentControl`, and v13 root-reachable binding
-contracts. It adds a signed, externally rooted human-authorization overlay for
+contracts. v18 added a signed, externally rooted human-authorization overlay for
one exact post-review coding-agent action. Agents
switch on `control.state`; `decision` remains diagnostic and
`release_decision.decision` remains the release gate. Contract v14 requires
@@ -61,8 +72,8 @@ Downstream repos generated with
`.shipgate/agent-contract.json`.
- Latest release: `v0.15.0`
-- In-tree runtime: `0.16.0b6` — see [pyproject.toml](../pyproject.toml)
-- Runtime contract: `18` (minimum control contract: `14`)
+- In-tree runtime: `0.16.0b7` — see [pyproject.toml](../pyproject.toml)
+- Runtime contract: `19` (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)
diff --git a/docs/agent-handoff-schema.v6.json b/docs/agent-handoff-schema.v6.json
index 942b5718..94f117c8 100644
--- a/docs/agent-handoff-schema.v6.json
+++ b/docs/agent-handoff-schema.v6.json
@@ -580,7 +580,7 @@
"type": "string"
},
"version": {
- "default": "0.16.0b6",
+ "default": "0.16.0b7",
"title": "Version",
"type": "string"
}
diff --git a/docs/agent-result-schema.v2.json b/docs/agent-result-schema.v2.json
index 4771cec7..ab07b05c 100644
--- a/docs/agent-result-schema.v2.json
+++ b/docs/agent-result-schema.v2.json
@@ -401,7 +401,7 @@
"type": "string"
},
"version": {
- "default": "0.16.0b6",
+ "default": "0.16.0b7",
"title": "Version",
"type": "string"
}
diff --git a/docs/agents/use-with-claude-code.md b/docs/agents/use-with-claude-code.md
index 0d80e7dc..d4be23df 100644
--- a/docs/agents/use-with-claude-code.md
+++ b/docs/agents/use-with-claude-code.md
@@ -222,7 +222,15 @@ Three hooks are installed:
irrelevant docs edits do not nudge every turn.
- **`Stop` (verify).** Full `agents-shipgate verify` only when the
working tree or current branch has a relevant change that has not
- already been checked.
+ already been checked. The outcome follows `verifier.control.state`, the
+ authoritative operational signal: `complete` ends the turn silently;
+ `agent_action_required` blocks the stop once and names the one exact
+ remaining command (a Stop-hook block forces the agent to keep working,
+ so it is only ever used when a coding-agent action actually remains);
+ `human_review_required` lets the turn end and prints a hand-off notice —
+ `must_stop` means "stop and hand off to a human", which a forced
+ continuation would contradict. Unparseable or unrecognized verifier
+ output warns loudly, is never cached, and is never treated as passing.
Local setup failures such as a missing CLI or unavailable base ref are
surfaced as context, not as the release gate. CI remains authoritative,
diff --git a/docs/ai-search-summary.md b/docs/ai-search-summary.md
index 519cbadf..2a308d4c 100644
--- a/docs/ai-search-summary.md
+++ b/docs/ai-search-summary.md
@@ -90,7 +90,7 @@ Per-agent guides cover [Codex](agents/use-with-codex.md),
[Claude Code](agents/use-with-claude-code.md), and
[Cursor](agents/use-with-cursor.md).
-The current source tree is `0.16.0b6` (runtime contract v18); the latest
+The current source tree is `0.16.0b7` (runtime contract v19); the latest
published release remains `v0.15.0` until that beta is cut. In report v0.34,
`passed` is an evidence-backed static verdict: the configured root has a
complete reachable binding graph, every reachable action has complete,
diff --git a/docs/architecture.md b/docs/architecture.md
index 6c76cbdd..3ee0adbc 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 `18`, report schema `v0.34`, packet schema `v0.12`.
+runtime contract `19`, 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/faq.md b/docs/faq.md
index b708ccd7..6f3b8dd0 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -148,7 +148,7 @@ Skip emission with `--no-packet`; re-render later with
## Is it production-ready?
v0.15.0 is the latest published pre-1.0 beta. The in-tree runtime is
-`0.16.0b6`, which adds content-addressed verification receipts and reproducible
+`0.16.0b7`, which adds content-addressed verification receipts and reproducible
distributed-execution boundaries on top of typed policy evidence, the
multi-host boundary, and root-reachable binding contracts. The manifest schema
remains stable across the 0.x series; see
diff --git a/docs/passed-verdict-contract.md b/docs/passed-verdict-contract.md
index 28296323..1123430b 100644
--- a/docs/passed-verdict-contract.md
+++ b/docs/passed-verdict-contract.md
@@ -1,6 +1,6 @@
# Evidence-backed `passed` verdict
-In the Agents Shipgate `0.16.0b6` runtime (contract v18, report schema v0.34),
+In the Agents Shipgate `0.16.0b7` runtime (contract v19, report schema v0.34),
`release_decision.decision: passed` means the configured root
agent and its complete reachable tool/handoff graph were statically proven,
and every reachable capability has complete, conflict-free static identity,
diff --git a/docs/report-reading-for-agents.md b/docs/report-reading-for-agents.md
index 1bd32c16..d233eefe 100644
--- a/docs/report-reading-for-agents.md
+++ b/docs/report-reading-for-agents.md
@@ -18,7 +18,7 @@ report = json.loads(open("agents-shipgate-reports/report.json").read())
gate = report["release_decision"]["decision"] # blocked | review_required | insufficient_evidence | passed
```
-The CLI's stable contract names this signal explicitly: run `agents-shipgate contract --json` and inspect `gating_signal` — it is always `release_decision.decision` in runtime contract v18 (see [`STABILITY.md`](../STABILITY.md) §"Runtime contract JSON").
+The CLI's stable contract names this signal explicitly: run `agents-shipgate contract --json` and inspect `gating_signal` — it is always `release_decision.decision` in runtime contract v19 (see [`STABILITY.md`](../STABILITY.md) §"Runtime contract JSON").
---
@@ -39,7 +39,7 @@ Precedence (highest first): `blocked` → `review_required` (active high/critica
The decision is **baseline-aware**: a baseline-matched critical surfaces in `release_decision.review_items` (accepted debt), not in `release_decision.blockers`. Compare with the legacy `summary.status` field, which is *baseline-blind* — see Anti-patterns below.
-Runtime contract v18 can attach an externally signed authorization to a new
+Runtime contract v19 can attach an externally signed authorization to a new
verifier artifact and route one exact guarded operation, but it never rewrites
this report verdict. The report remains `review_required`; autonomous agents
must read `agent-handoff.json.control.state` to distinguish a human stop from
@@ -237,7 +237,7 @@ Surface the `next_action` to the user rather than scraping prose. The full diagn
| Report | `0.34` | `0.33`, `0.32`, `0.31`, `0.30`, `0.29`, `0.28`, `0.27`, `0.26`, `0.25`, `0.24`, `0.23`, `0.22`, `0.21`, `0.20`, `0.19`, `0.18`, `0.17`, `0.16`, `0.15`, `0.14`, `0.13`, `0.12`, `0.11`, `0.10`, `0.9`, `0.8`, `0.7`, `0.6`, `0.5`, `0.4`, `0.3`, `0.2`, `0.1` | [`report-schema.v0.34.json`](report-schema.v0.34.json) |
| Packet | `0.12` | `0.11`, `0.10`, `0.9`, `0.8`, `0.7`, `0.6`, `0.5`, `0.4`, `0.3`, `0.2`, `0.1` | [`packet-schema.v0.12.json`](packet-schema.v0.12.json) |
| Manifest | `0.1` | — | [`manifest-v0.1.json`](manifest-v0.1.json) |
-| CLI contract | `18` | — | `agents-shipgate contract --json` |
+| CLI contract | `19` | — | `agents-shipgate contract --json` |
To detect the version programmatically:
diff --git a/docs/verification-reproducibility.md b/docs/verification-reproducibility.md
index a8554f51..d48f9cfd 100644
--- a/docs/verification-reproducibility.md
+++ b/docs/verification-reproducibility.md
@@ -1,6 +1,6 @@
# Verification Identity and Reproduction
-Agents Shipgate `0.16.0b6` makes a verification request, its execution, its
+Agents Shipgate `0.16.0b7` makes a verification request, its execution, its
decision, and its emitted files independently identifiable. This closes the
previous ambiguity where a mutable branch name, a local cache hit, or an
unbound collection of JSON files could be presented as the same verification.
@@ -117,7 +117,7 @@ cross-executor equivalence qualification that are not present in v1.
## Reviewed authorization overlay
-Runtime contract v18 can project one externally authorized coding-agent action
+Runtime contract v19 can project one externally authorized coding-agent action
without changing the release decision. This is an overlay on a completed
verification graph, not an input that lets a worker or repository assert human
approval.
diff --git a/llms-full.txt b/llms-full.txt
index fb5df05b..da8ff042 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -1034,10 +1034,21 @@ Verify the installed CLI contract locally before relying on hard-coded docs:
agents-shipgate contract --json
```
-Runtime contract v18 retains the v17 content-addressed verification identity,
+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
+the review obligation is carried in the additive
+`pending_review[]` field on the agent-boundary result instead of ending the
+turn. Block actions, critical risk, gate-weakening rules
+(`CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED`), unparseable content,
+incomplete input, experimental surfaces, and every gate-governing trust-root
+class (manifest, policy, ci_gate, shipgate_state) keep the
+`human_review_required` stop. PR-time `release_decision` semantics are
+unchanged. It retains the v18 human-authorization overlay, the v17
+content-addressed verification identity,
v16 typed policy-evidence, v15 host-neutral
boundary, v14 unambiguous `AgentControl`, and v13 root-reachable binding
-contracts. It adds a signed, externally rooted human-authorization overlay for
+contracts. v18 added a signed, externally rooted human-authorization overlay for
one exact post-review coding-agent action. Agents
switch on `control.state`; `decision` remains diagnostic and
`release_decision.decision` remains the release gate. Contract v14 requires
@@ -1085,8 +1096,8 @@ Downstream repos generated with
`.shipgate/agent-contract.json`.
- Latest release: `v0.15.0`
-- In-tree runtime: `0.16.0b6` — see [pyproject.toml](../pyproject.toml)
-- Runtime contract: `18` (minimum control contract: `14`)
+- In-tree runtime: `0.16.0b7` — see [pyproject.toml](../pyproject.toml)
+- Runtime contract: `19` (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)
diff --git a/llms.txt b/llms.txt
index 913592d5..453b0fb6 100644
--- a/llms.txt
+++ b/llms.txt
@@ -13,7 +13,7 @@
- Publisher URL: https://threemoonslab.com/
- License: Apache-2.0
- Latest public release: v0.15.0
-- Current source-tree runtime: 0.16.0b6 (contract 18; pre-release)
+- Current source-tree runtime: 0.16.0b7 (contract 19; pre-release)
- Canonical repository: https://github.com/ThreeMoonsLab/agents-shipgate
- Do not use: Agent Shipcheck, Agent Shipgate, agents shipgate, Agents-Shipgate
diff --git a/plugins/agents-shipgate/.codex-plugin/plugin.json b/plugins/agents-shipgate/.codex-plugin/plugin.json
index 092f8aab..795cb862 100644
--- a/plugins/agents-shipgate/.codex-plugin/plugin.json
+++ b/plugins/agents-shipgate/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "agents-shipgate",
- "version": "0.16.0b6",
+ "version": "0.16.0b7",
"description": "Run Agents Shipgate Tool-Use Readiness workflows from Codex.",
"author": {
"name": "Three Moons Lab",
diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json
index 2a13b891..5929c0f5 100644
--- a/plugins/claude-code/.claude-plugin/plugin.json
+++ b/plugins/claude-code/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "agents-shipgate",
- "version": "0.16.0b6",
+ "version": "0.16.0b7",
"description": "Run Agents Shipgate Tool-Use Readiness workflows from Claude Code. Skill-only: the plugin supplies the agents-shipgate skill and the /agents-shipgate:shipgate command; the scanner runs through the agents-shipgate CLI installed in the local environment (pipx install agents-shipgate, runtime contract 15). For the deterministic hooks (trigger check after edits, full verify at Stop), run: agents-shipgate install-hooks --target claude-code --write.",
"author": {
"name": "Three Moons Lab",
diff --git a/pyproject.toml b/pyproject.toml
index 6eef7dd4..31b2fa2c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "agents-shipgate"
-version = "0.16.0b6"
+version = "0.16.0b7"
description = "The deterministic merge gate for AI-generated agent capability changes. Agent release readiness for tool-using AI agents. CLI + GitHub Action. Scans MCP, OpenAPI, OpenAI Agents SDK, Anthropic, Google ADK, LangChain, CrewAI, OpenAI API, Codex config, Codex plugin, n8n, Conductor OSS workflow JSON."
readme = "README.md"
requires-python = ">=3.12"
diff --git a/src/agents_shipgate/__init__.py b/src/agents_shipgate/__init__.py
index 9a0f5e42..5a840ff7 100644
--- a/src/agents_shipgate/__init__.py
+++ b/src/agents_shipgate/__init__.py
@@ -1,3 +1,3 @@
"""Agents Shipgate package."""
-__version__ = "0.16.0b6"
+__version__ = "0.16.0b7"
diff --git a/src/agents_shipgate/checks/verify.py b/src/agents_shipgate/checks/verify.py
index 09ce78e7..3caa5795 100644
--- a/src/agents_shipgate/checks/verify.py
+++ b/src/agents_shipgate/checks/verify.py
@@ -19,9 +19,19 @@
from __future__ import annotations
-from agents_shipgate.core.boundary_registry import BOUNDARY_ADAPTERS
from agents_shipgate.core.context import ScanContext
from agents_shipgate.core.globbing import glob_match
+
+# The trust-root table lives in ``core.trust_roots`` so the local boundary
+# evaluator can classify paths from the identical data without an import cycle
+# (``checks.verify`` -> ``core.context`` -> ``core.agent_boundary``). These
+# re-exports keep every historical ``checks.verify`` import site working.
+from agents_shipgate.core.trust_roots import ( # noqa: F401
+ _FORBIDDEN_EDIT_CLASSES,
+ _LEGACY_TRUST_ROOT_SURFACES,
+ PROTECTED_FILE_EDITS,
+ TRUST_ROOT_SURFACES,
+)
from agents_shipgate.schemas.common import (
SourceReference,
parse_confidence,
@@ -31,89 +41,6 @@
CHECK_ID = "SHIP-VERIFY-TRUST-ROOT-TOUCHED"
-# Ordered (class, glob) classification of a repo's release trust roots —
-# the surfaces that define the gate in any repo that has adopted
-# Shipgate (target-repo trust roots, §5.2). First match wins; one
-# finding per changed file. ``**/`` prefixes match at any depth
-# (including the repo root) per agents_shipgate.core.globbing.
-_LEGACY_TRUST_ROOT_SURFACES: tuple[tuple[str, str], ...] = (
- ("manifest", "**/shipgate.yaml"),
- ("shipgate_state", "**/.agents-shipgate/**"),
- ("policy", "**/policies/**"),
- ("prompts", "**/prompts/**"),
- ("ci_gate", "**/.github/workflows/agents-shipgate.yml"),
- ("ci_gate", "**/.github/workflows/agents-shipgate.yaml"),
- ("agent_instructions", "**/AGENTS.md"),
- ("agent_instructions", "**/CLAUDE.md"),
- ("agent_instructions", "**/.claude/**"),
- ("agent_instructions", "**/.cursor/rules/**"),
- ("agent_instructions", "**/.agents/skills/**"),
- ("agent_instructions", "**/.codex/**"),
- ("codex_plugin", "**/.codex-plugin/**"),
- ("tool_surface_decl", "**/.app.json"),
- ("tool_surface_decl", "**/.mcp.json"),
- # Host-boundary MCP declarations (Cursor / VS Code project servers).
- # Claude Code settings (.claude/settings.json, .claude/settings.local.json)
- # are already covered by the agent_instructions "**/.claude/**" glob above.
- ("tool_surface_decl", "**/.cursor/mcp.json"),
- ("tool_surface_decl", "**/.vscode/mcp.json"),
- ("tool_surface_decl", "**/SKILL.md"),
-)
-
-
-def _registry_trust_root_surfaces() -> tuple[tuple[str, str], ...]:
- """Project registry paths not already covered by a legacy trust-root glob.
-
- Existing classifications stay stable for finding fingerprints and reviewer
- copy. Newly registered boundary paths automatically become trust roots,
- which prevents trigger/check/preflight coverage from drifting apart.
- """
-
- existing_patterns = tuple(pattern for _kind, pattern in _LEGACY_TRUST_ROOT_SURFACES)
- additions: list[tuple[str, str]] = []
- seen: set[str] = set(existing_patterns)
- for adapter in BOUNDARY_ADAPTERS:
- for pattern in (*adapter.exact_paths, *adapter.globs):
- if pattern in seen:
- continue
- representative = pattern.replace("**", "nested").replace("*", "item")
- if any(glob_match(existing, representative) for existing in existing_patterns):
- continue
- seen.add(pattern)
- additions.append(("host_boundary", pattern))
- return tuple(additions)
-
-
-TRUST_ROOT_SURFACES: tuple[tuple[str, str], ...] = (
- *_LEGACY_TRUST_ROOT_SURFACES,
- *_registry_trust_root_surfaces(),
-)
-
-# The deny-list of trust-root files a coding agent must never edit *to make a
-# verdict pass*, derived from ``TRUST_ROOT_SURFACES`` (single source of truth),
-# restricted to the classes whose trust boundary is the WHOLE FILE: the
-# Shipgate CI gate, the agent-instruction surfaces, and policy packs.
-#
-# Deliberately EXCLUDES:
-# * ``shipgate.yaml`` and ``.agents-shipgate/**`` — their boundary is
-# *key-level* (editing an action's scope is a legitimate mechanical fix; a
-# ``checks.ignore`` / baseline / waiver expansion is reward-hacking). A
-# path-level deny cannot express that, so they are covered by
-# ``forbidden_actions`` (``FORBIDDEN_SHORTCUTS``) instead.
-# * the tool-surface declarations (``.mcp.json``, ``SKILL.md``, ``.app.json``,
-# ``.codex-plugin/**``) — those are the capability surface UNDER review,
-# which a PR may legitimately edit.
-#
-# Single home so the verifier and the ``agent_handoff`` preview fallback emit
-# the IDENTICAL
-# standing deny-list — a passing/preview verdict never reads as "anything goes".
-_FORBIDDEN_EDIT_CLASSES = frozenset(
- {"ci_gate", "agent_instructions", "policy", "host_boundary"}
-)
-PROTECTED_FILE_EDITS: tuple[str, ...] = tuple(
- pattern for kind, pattern in TRUST_ROOT_SURFACES if kind in _FORBIDDEN_EDIT_CLASSES
-)
-
def run(context: ScanContext) -> list[Finding]:
verification = context.verification
diff --git a/src/agents_shipgate/cli/install_hooks.py b/src/agents_shipgate/cli/install_hooks.py
index 725cacf0..03960b33 100644
--- a/src/agents_shipgate/cli/install_hooks.py
+++ b/src/agents_shipgate/cli/install_hooks.py
@@ -626,11 +626,14 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in
diff_text=snapshot["diff_text"],
manifest_present=False,
)
- if trigger and trigger.get("should_run") and not stop_hook_active:
- return _emit_stop_block(
+ if trigger and trigger.get("should_run"):
+ # Advisory: nothing is configured yet, so nobody has decided this
+ # repo is gated — advise, never force the turn to continue.
+ return _emit_context(
+ "Stop",
"Agents Shipgate trigger matched, but no shipgate.yaml exists. "
- "Before finishing, run `agents-shipgate verify --preview --json`, "
- "initialize the manifest if relevant, then verify."
+ "Run `agents-shipgate verify --preview --json` and initialize "
+ "the manifest if this workspace contains an agent.",
)
return 0
@@ -703,27 +706,72 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in
)
try:
- verifier = json.loads(completed.stdout or "{}")
+ verifier = json.loads(completed.stdout or "")
except json.JSONDecodeError:
- verifier = {}
+ verifier = None
+ if not isinstance(verifier, dict):
+ # Never cache and never stay silent on unreadable verifier output: a
+ # malfunctioning gate must not read as a passing one.
+ return _emit_context(
+ "Stop",
+ "Agents Shipgate verify produced output the hook could not parse. "
+ "Do not treat this as a passing verdict; run "
+ f"`{_manual_verify_command(args, root=root)}` manually and read "
+ "`agents-shipgate-reports/report.json`.",
+ )
+
decision = ((verifier.get("release_decision") or {}).get("decision") or "unknown")
blockers = len((verifier.get("release_decision") or {}).get("blockers") or [])
review_items = len((verifier.get("release_decision") or {}).get("review_items") or [])
- base_status = verifier.get("base_status") or "unknown"
- _write_verified_signature(root, signature)
- if decision == "passed":
+ control = verifier.get("control") if isinstance(verifier.get("control"), dict) else {}
+ state = control.get("state")
+ summary = f"decision={decision}, blockers={blockers}, review_items={review_items}"
+
+ # The hook mirrors the operational control contract: ``control.state`` is
+ # authoritative and ``decision`` is diagnostic. A Claude Code Stop-hook
+ # "block" forces the agent to KEEP WORKING, so it is only ever correct
+ # when an exact coding-agent action remains. ``human_review_required``
+ # (``must_stop=true``) is the opposite situation — the turn must be
+ # allowed to end so a human can take over.
+ if state == "complete":
+ _write_verified_signature(root, signature)
if base_note:
return _emit_context("Stop", "Agents Shipgate verify passed." + base_note)
return 0
- if stop_hook_active:
- return 0
- return _emit_stop_block(
- "Agents Shipgate verify ran before completion: "
- f"decision={decision}, blockers={blockers}, review_items={review_items}, "
- f"base_status={base_status}.{base_note} Before finishing, summarize "
- "`agents-shipgate-reports/report.json` and the next safe action. "
- "Do not bypass the verifier by suppressing findings, lowering severity, "
- "expanding baselines/waivers, removing Shipgate CI, or weakening agent instructions."
+ if state == "agent_action_required":
+ _write_verified_signature(root, signature)
+ if stop_hook_active:
+ return 0
+ next_action = (
+ control.get("next_action") if isinstance(control.get("next_action"), dict) else {}
+ )
+ command = next_action.get("command") or _manual_verify_command(args, root=root)
+ why = next_action.get("why") or "One coding-agent action remains."
+ return _emit_stop_block(
+ f"Agents Shipgate verify ran before completion: {summary}.{base_note} "
+ f"One exact coding-agent action remains before finishing: run `{command}`. "
+ f"{why} "
+ "Do not bypass the verifier by suppressing findings, lowering severity, "
+ "expanding baselines/waivers, removing Shipgate CI, or weakening agent instructions."
+ )
+ if state == "human_review_required":
+ _write_verified_signature(root, signature)
+ stop_reason = control.get("stop_reason") or control.get("reason") or ""
+ return _emit_context(
+ "Stop",
+ f"Agents Shipgate verify ran before completion: {summary}.{base_note} "
+ "A human must review this change before it can merge"
+ f"{': ' + stop_reason if stop_reason else ''}. "
+ "The coding agent's local work can end here; PR review is unchanged. "
+ "Details: `agents-shipgate-reports/report.json`.",
+ )
+ # Unknown control state: warn loudly, never cache, never block.
+ return _emit_context(
+ "Stop",
+ f"Agents Shipgate verify returned an unrecognized control state {state!r} "
+ f"({summary}). Do not treat this as a passing verdict; read "
+ "`agents-shipgate-reports/report.json` and treat unknown states as "
+ "requiring human review.",
)
diff --git a/src/agents_shipgate/core/agent_boundary.py b/src/agents_shipgate/core/agent_boundary.py
index 441d70ad..53c789af 100644
--- a/src/agents_shipgate/core/agent_boundary.py
+++ b/src/agents_shipgate/core/agent_boundary.py
@@ -33,7 +33,9 @@
_dedupe_violations,
_human_review_for,
_next_action_for,
+ _pending_review_for,
_repair_for,
+ _required_reviewers_for,
_risk_for,
_violation_fingerprint,
evaluate_codex_boundary_result,
@@ -379,8 +381,18 @@ def evaluate_agent_boundary(
def build_agent_boundary_result(assessment: AgentBoundaryAssessment) -> AgentBoundaryResultV1:
legacy = assessment.legacy_result
aggregate_policy = _aggregate_policy(assessment)
+ pending_review = (
+ _pending_review_for(
+ assessment.violations,
+ _required_reviewers_for(legacy.decision, list(assessment.violations)),
+ )
+ if legacy.control.state == "agent_action_required"
+ and legacy.decision == "require_review"
+ else []
+ )
return AgentBoundaryResultV1(
**legacy.model_dump(mode="python", exclude={"schema_version", "policy"}),
+ pending_review=pending_review,
actor=assessment.actor, # type: ignore[arg-type]
input_mode=assessment.input_mode,
scope=assessment.scope,
@@ -457,6 +469,7 @@ def _project_legacy(
trigger_verify_required=bool(
legacy.trigger and legacy.trigger.get("force_run")
),
+ violations=violations,
)
else:
decision = legacy.decision
diff --git a/src/agents_shipgate/core/codex_boundary.py b/src/agents_shipgate/core/codex_boundary.py
index 7f16efa9..f57b523c 100644
--- a/src/agents_shipgate/core/codex_boundary.py
+++ b/src/agents_shipgate/core/codex_boundary.py
@@ -4,6 +4,7 @@
import json
import re
import tomllib
+from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -37,16 +38,19 @@
parse_unified_diff,
)
from agents_shipgate.core.boundary_registry import (
+ BOUNDARY_ADAPTERS,
boundary_adapters_for_path,
is_agent_boundary_path,
)
from agents_shipgate.core.manifest_proposals import (
assess_coverage_increasing_tool_source_proposal,
)
+from agents_shipgate.core.trust_roots import trust_root_class_for
from agents_shipgate.schemas.agent_control import (
CodingAgentCommandAction,
HumanControlAction,
)
+from agents_shipgate.schemas.agent_result import AgentResultPendingReviewItem
from agents_shipgate.schemas.codex_boundary_result import (
CODEX_BOUNDARY_RESULT_SCHEMA_VERSION,
)
@@ -99,6 +103,44 @@
# be advertised as an agent-safe repair by the local boundary.
_AGENT_SAFE_REPAIR_RULE_IDS: frozenset[str] = frozenset()
+# Rules that keep the human stop even at low/medium risk.
+#
+# ``BOUNDARY-INPUT-INCOMPLETE`` means the evaluator could not see the whole
+# change, so continuing would authorize unevaluated input.
+# ``CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED`` is the self-protection rule:
+# removing the verifier requirement from agent instructions is exactly the
+# gate-weakening move a graded mapping must never soften.
+BAND_EXCLUDED_RULE_IDS: frozenset[str] = frozenset(
+ {
+ "BOUNDARY-INPUT-INCOMPLETE",
+ "CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED",
+ }
+)
+
+# The parse-failure rules cover two very different situations under one id:
+# content that could not be parsed or resolved (incomplete evidence — the
+# evaluator does not know what changed) and content that parsed fine but used
+# a key outside the allow-list (complete evidence, unknown semantics). Only
+# the latter is band-eligible, so eligibility is an allow-list on the evidence
+# kind: any new evidence kind added to these rules defaults to the human stop.
+_PARSE_FAILURE_RULE_IDS: frozenset[str] = frozenset(
+ {"CODEX-CONFIG-PARSE-FAILED", "HOST-CONFIG-PARSE-FAILED"}
+)
+_PARSEABLE_EVIDENCE_KINDS: frozenset[str] = frozenset({"unknown_host_config_key"})
+
+# Trust-root classes that govern the gate itself: the declared surface and its
+# policy (``manifest``), the boundary policy (``policy``), the CI gate
+# (``ci_gate``), and the recorded baselines/waivers (``shipgate_state``).
+# A change here decides what the verifier is allowed to say, so it stays a
+# human stop regardless of how a catch-all rule scored its risk — the graded
+# band exists for incidental agent-surface edits, never for the gate's own
+# governing inputs. Keeping these out of the band is what preserves the
+# composite-diff guarantee: a safe manifest append bundled with an unsafe
+# manifest edit still routes to a human.
+BAND_EXCLUDED_TRUST_ROOT_CLASSES: frozenset[str] = frozenset(
+ {"manifest", "policy", "ci_gate", "shipgate_state"}
+)
+
_SHIPGATE_TERMS = (
"agents-shipgate",
"agents_shipgate",
@@ -611,6 +653,7 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None:
undeclared_gap=undeclared_gap,
coverage_gap=coverage_gap,
trigger_verify_required=trigger_verify_required,
+ violations=violations,
)
return AgentResultV2(
agent=agent, # type: ignore[arg-type]
@@ -626,7 +669,7 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None:
policy=_policy_result(policy),
violated_rules=violations,
affected_files=_affected_files_for(violations, changed_files),
- required_reviewers=human_review.required_reviewers,
+ required_reviewers=list(control.human_review.required_reviewers),
explanation=summary,
suggested_fixes=suggested_fixes,
agent_repair_instructions=_agent_repair_instructions(decision, violations),
@@ -639,6 +682,83 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None:
)
+def violations_within_agent_actionable_band(
+ violations: Sequence[AgentResultViolatedRule],
+) -> bool:
+ """Whether every violation permits the coding-agent verify route.
+
+ A ``require_review`` boundary change at low or medium risk is a review
+ obligation, not an emergency: the agent may finish the turn and hand the
+ PR gate the decision. ``block`` rows, ``critical`` risk, and the rules in
+ :data:`BAND_EXCLUDED_RULE_IDS` keep the human stop.
+
+ An empty set is deliberately out of band. ``require_review`` with no
+ violation rows comes from a substrate release-decision projection rather
+ than an evaluated boundary rule, and nothing here has grounds to soften
+ that.
+ """
+
+ if not violations:
+ return False
+ for item in violations:
+ if item.action == "block" or item.risk_level == "critical":
+ return False
+ if item.id in BAND_EXCLUDED_RULE_IDS:
+ return False
+ if item.action == "require_review" and item.risk_level not in {"low", "medium"}:
+ return False
+ if (
+ item.id in _PARSE_FAILURE_RULE_IDS
+ and item.evidence.get("kind") not in _PARSEABLE_EVIDENCE_KINDS
+ ):
+ return False
+ if _governs_the_gate(item.path):
+ return False
+ if item.path and _touches_experimental_surface(item.path):
+ # An experimental adapter's classification confidence is low —
+ # the same epistemic state as unparseable content.
+ return False
+ return True
+
+
+def _touches_experimental_surface(path: str) -> bool:
+ return any(
+ adapter.experimental and adapter.matches(path) for adapter in BOUNDARY_ADAPTERS
+ )
+
+
+def _governs_the_gate(path: str | None) -> bool:
+ """Whether a changed path is one of the gate's own governing trust roots."""
+
+ if not path:
+ return False
+ return trust_root_class_for(path) in BAND_EXCLUDED_TRUST_ROOT_CLASSES
+
+
+def _pending_review_for(
+ violations: Sequence[AgentResultViolatedRule],
+ reviewers: Sequence[str],
+) -> list[AgentResultPendingReviewItem]:
+ """Carry the review obligation the downgraded control state cannot hold."""
+
+ return [
+ AgentResultPendingReviewItem(
+ check_id=item.check_id,
+ rule_id=item.id,
+ path=item.path,
+ risk_level=item.risk_level,
+ title=item.title,
+ reviewers=list(reviewers),
+ note=(
+ "Routed to the coding agent locally; PR-time verify still "
+ "routes this change to a human reviewer."
+ ),
+ )
+ for item in violations
+ if item.action == "require_review"
+ ]
+
+
def _control_for_result(
*,
decision: str,
@@ -650,9 +770,34 @@ def _control_for_result(
undeclared_gap: bool,
coverage_gap: bool,
trigger_verify_required: bool,
+ violations: Sequence[AgentResultViolatedRule] = (),
):
"""Translate boundary facts into the one shared operational projector."""
+ # Graded review: a low/medium ``require_review`` set routes the agent to the
+ # PR gate instead of ending the turn. The obligation is preserved in
+ # ``pending_review`` and re-asserted by verify; only the local stop is
+ # relaxed.
+ if (
+ decision == "require_review"
+ and not repair.safe_to_attempt
+ and violations_within_agent_actionable_band(violations)
+ ):
+ return derive_agent_control(
+ reason=summary,
+ next_action=CodingAgentCommandAction(
+ kind="verify",
+ command=_VERIFY_COMMAND,
+ why=(
+ "This change owes human review but is not an emergency stop: "
+ "run verify so the PR gate records the decision, and report "
+ "the pending review items."
+ ),
+ ),
+ verify_required=True,
+ allowed_next_commands=[_VERIFY_COMMAND],
+ )
+
if decision in {"require_review", "block"} and not repair.safe_to_attempt:
why = human_review.why or first_next_action.why or summary
return derive_agent_control(
@@ -1793,6 +1938,11 @@ def _human_review_for(
repair: AgentResultRepair,
) -> AgentResultHumanReview:
required = decision in {"require_review", "block"} and not repair.safe_to_attempt
+ # Graded rows keep the obligation in ``pending_review`` instead of a local
+ # stop, so no reviewer set is demanded of this run. Must agree with
+ # ``_control_for_result``, whose derived control this projects.
+ if required and decision == "require_review":
+ required = not violations_within_agent_actionable_band(violations)
return AgentResultHumanReview(
required=required,
why=(violations[0].title if required and violations else None),
diff --git a/src/agents_shipgate/core/trust_roots.py b/src/agents_shipgate/core/trust_roots.py
new file mode 100644
index 00000000..e0800e38
--- /dev/null
+++ b/src/agents_shipgate/core/trust_roots.py
@@ -0,0 +1,114 @@
+"""Classification of a repository's release trust roots.
+
+This is domain data, not check logic: the local boundary evaluator, the
+preflight router, the installed hooks, and the verify-mode
+``SHIP-VERIFY-TRUST-ROOT-TOUCHED`` check must all classify a changed path
+identically. It lives in :mod:`agents_shipgate.core` so every one of those
+consumers can import it without a cycle;
+:mod:`agents_shipgate.checks.verify` re-exports the names it has always
+exported.
+"""
+
+from __future__ import annotations
+
+from agents_shipgate.core.boundary_registry import BOUNDARY_ADAPTERS
+from agents_shipgate.core.globbing import glob_match
+
+# Ordered (class, glob) classification of a repo's release trust roots —
+# the surfaces that define the gate in any repo that has adopted
+# Shipgate (target-repo trust roots, §5.2). First match wins; one
+# finding per changed file. ``**/`` prefixes match at any depth
+# (including the repo root) per agents_shipgate.core.globbing.
+_LEGACY_TRUST_ROOT_SURFACES: tuple[tuple[str, str], ...] = (
+ ("manifest", "**/shipgate.yaml"),
+ ("shipgate_state", "**/.agents-shipgate/**"),
+ ("policy", "**/policies/**"),
+ ("prompts", "**/prompts/**"),
+ ("ci_gate", "**/.github/workflows/agents-shipgate.yml"),
+ ("ci_gate", "**/.github/workflows/agents-shipgate.yaml"),
+ ("agent_instructions", "**/AGENTS.md"),
+ ("agent_instructions", "**/CLAUDE.md"),
+ ("agent_instructions", "**/.claude/**"),
+ ("agent_instructions", "**/.cursor/rules/**"),
+ ("agent_instructions", "**/.agents/skills/**"),
+ ("agent_instructions", "**/.codex/**"),
+ ("codex_plugin", "**/.codex-plugin/**"),
+ ("tool_surface_decl", "**/.app.json"),
+ ("tool_surface_decl", "**/.mcp.json"),
+ # Host-boundary MCP declarations (Cursor / VS Code project servers).
+ # Claude Code settings (.claude/settings.json, .claude/settings.local.json)
+ # are already covered by the agent_instructions "**/.claude/**" glob above.
+ ("tool_surface_decl", "**/.cursor/mcp.json"),
+ ("tool_surface_decl", "**/.vscode/mcp.json"),
+ ("tool_surface_decl", "**/SKILL.md"),
+)
+
+
+def _registry_trust_root_surfaces() -> tuple[tuple[str, str], ...]:
+ """Project registry paths not already covered by a legacy trust-root glob.
+
+ Existing classifications stay stable for finding fingerprints and reviewer
+ copy. Newly registered boundary paths automatically become trust roots,
+ which prevents trigger/check/preflight coverage from drifting apart.
+ """
+
+ existing_patterns = tuple(pattern for _kind, pattern in _LEGACY_TRUST_ROOT_SURFACES)
+ additions: list[tuple[str, str]] = []
+ seen: set[str] = set(existing_patterns)
+ for adapter in BOUNDARY_ADAPTERS:
+ for pattern in (*adapter.exact_paths, *adapter.globs):
+ if pattern in seen:
+ continue
+ representative = pattern.replace("**", "nested").replace("*", "item")
+ if any(glob_match(existing, representative) for existing in existing_patterns):
+ continue
+ seen.add(pattern)
+ additions.append(("host_boundary", pattern))
+ return tuple(additions)
+
+
+TRUST_ROOT_SURFACES: tuple[tuple[str, str], ...] = (
+ *_LEGACY_TRUST_ROOT_SURFACES,
+ *_registry_trust_root_surfaces(),
+)
+
+# The deny-list of trust-root files a coding agent must never edit *to make a
+# verdict pass*, derived from ``TRUST_ROOT_SURFACES`` (single source of truth),
+# restricted to the classes whose trust boundary is the WHOLE FILE: the
+# Shipgate CI gate, the agent-instruction surfaces, and policy packs.
+#
+# Deliberately EXCLUDES:
+# * ``shipgate.yaml`` and ``.agents-shipgate/**`` — their boundary is
+# *key-level* (editing an action's scope is a legitimate mechanical fix; a
+# ``checks.ignore`` / baseline / waiver expansion is reward-hacking). A
+# path-level deny cannot express that, so they are covered by
+# ``forbidden_actions`` (``FORBIDDEN_SHORTCUTS``) instead.
+# * the tool-surface declarations (``.mcp.json``, ``SKILL.md``, ``.app.json``,
+# ``.codex-plugin/**``) — those are the capability surface UNDER review,
+# which a PR may legitimately edit.
+#
+# Single home so the verifier and the ``agent_handoff`` preview fallback emit
+# the IDENTICAL
+# standing deny-list — a passing/preview verdict never reads as "anything goes".
+_FORBIDDEN_EDIT_CLASSES = frozenset(
+ {"ci_gate", "agent_instructions", "policy", "host_boundary"}
+)
+PROTECTED_FILE_EDITS: tuple[str, ...] = tuple(
+ pattern for kind, pattern in TRUST_ROOT_SURFACES if kind in _FORBIDDEN_EDIT_CLASSES
+)
+
+
+def trust_root_class_for(path: str) -> str | None:
+ """Classify ``path`` against the ordered trust-root table, first match wins."""
+
+ for trust_root_class, pattern in TRUST_ROOT_SURFACES:
+ if glob_match(pattern, path):
+ return trust_root_class
+ return None
+
+
+__all__ = [
+ "PROTECTED_FILE_EDITS",
+ "TRUST_ROOT_SURFACES",
+ "trust_root_class_for",
+]
diff --git a/src/agents_shipgate/schemas/agent_boundary.py b/src/agents_shipgate/schemas/agent_boundary.py
index 99f2a0d9..4edde7a8 100644
--- a/src/agents_shipgate/schemas/agent_boundary.py
+++ b/src/agents_shipgate/schemas/agent_boundary.py
@@ -6,7 +6,10 @@
from pydantic import BaseModel, ConfigDict, Field, model_validator
-from agents_shipgate.schemas.agent_result import AgentResultV2
+from agents_shipgate.schemas.agent_result import (
+ AgentResultPendingReviewItem,
+ AgentResultV2,
+)
from agents_shipgate.schemas.agent_result_v1 import (
AgentResultAgent,
AgentResultPolicy,
@@ -42,6 +45,11 @@ class AgentBoundaryResultV1(AgentResultV2):
policy_set_sha256: str
issues: list[str] = Field(default_factory=list)
violations: list[AgentResultViolatedRule]
+ # Additive: review obligations the graded local mapping carries forward
+ # instead of stopping the turn. Lives on this result (not the shared
+ # AgentResultV2 base) so the deprecated codex-boundary v2 format stays
+ # byte-frozen.
+ pending_review: list[AgentResultPendingReviewItem] = Field(default_factory=list)
static_analysis_only: Literal[True] = True
runtime_session_verified: Literal[False] = False
excluded_scopes: list[str] = Field(default_factory=list)
@@ -58,6 +66,11 @@ def _coverage_controls_completion(self) -> AgentBoundaryResultV1:
raise ValueError("partial or experimental host coverage cannot allow completion")
if self.violations != self.violated_rules:
raise ValueError("violations must exactly project legacy violated_rules")
+ # An outstanding review obligation contradicts completion: a graded
+ # row still owes a human a look at PR time, so it must keep the agent
+ # on a route rather than reading as finished.
+ if self.pending_review and self.control.state == "complete":
+ raise ValueError("a complete result cannot carry pending review items")
return self
diff --git a/src/agents_shipgate/schemas/agent_result.py b/src/agents_shipgate/schemas/agent_result.py
index cef92ca5..6869cac0 100644
--- a/src/agents_shipgate/schemas/agent_result.py
+++ b/src/agents_shipgate/schemas/agent_result.py
@@ -28,6 +28,28 @@
AGENT_RESULT_SCHEMA_VERSION = "agent_result_v2"
+class AgentResultPendingReviewItem(BaseModel):
+ """One finding that still owes human review while the agent may continue.
+
+ Emitted when the local mapping routes a low/medium ``require_review``
+ boundary change onto the coding-agent verify route instead of stopping the
+ turn. The obligation is not cleared: it is carried here so the agent can
+ report it, and PR-time verify still routes the same change to a human.
+ ``reviewers`` holds the reviewer set that a stop would have demanded —
+ ``control.human_review`` cannot carry it outside a human route.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ check_id: str
+ rule_id: str
+ path: str | None = None
+ risk_level: AgentResultRiskLevel
+ title: str
+ reviewers: list[str] = Field(default_factory=list)
+ note: str | None = None
+
+
class AgentResultV2(BaseModel):
"""Schema-enforced local operational result.
@@ -130,4 +152,9 @@ def _reviewers_project_control(self) -> AgentResultV2:
AgentResult = AgentResultV2
-__all__ = ["AGENT_RESULT_SCHEMA_VERSION", "AgentResult", "AgentResultV2"]
+__all__ = [
+ "AGENT_RESULT_SCHEMA_VERSION",
+ "AgentResult",
+ "AgentResultPendingReviewItem",
+ "AgentResultV2",
+]
diff --git a/src/agents_shipgate/schemas/contract.py b/src/agents_shipgate/schemas/contract.py
index 3b995b2b..a89f1349 100644
--- a/src/agents_shipgate/schemas/contract.py
+++ b/src/agents_shipgate/schemas/contract.py
@@ -54,7 +54,7 @@
from agents_shipgate.schemas.verifier import VerifierArtifact
from agents_shipgate.schemas.verify_run import VERIFY_RUN_SCHEMA_VERSION
-CONTRACT_VERSION: Literal["18"] = "18"
+CONTRACT_VERSION: Literal["19"] = "19"
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"
diff --git a/tests/golden/codex_boundary_result/agents_requirement_removed.json b/tests/golden/codex_boundary_result/agents_requirement_removed.json
index bb8105cb..e0050f25 100644
--- a/tests/golden/codex_boundary_result/agents_requirement_removed.json
+++ b/tests/golden/codex_boundary_result/agents_requirement_removed.json
@@ -3,7 +3,7 @@
"agent": "codex",
"tool": {
"name": "agents-shipgate",
- "version": "0.16.0b6"
+ "version": "0.16.0b7"
},
"subject": {
"agent": "codex"
diff --git a/tests/golden/codex_boundary_result/docs_only.json b/tests/golden/codex_boundary_result/docs_only.json
index df9a0647..69783c03 100644
--- a/tests/golden/codex_boundary_result/docs_only.json
+++ b/tests/golden/codex_boundary_result/docs_only.json
@@ -3,7 +3,7 @@
"agent": "codex",
"tool": {
"name": "agents-shipgate",
- "version": "0.16.0b6"
+ "version": "0.16.0b7"
},
"subject": {
"agent": "codex"
diff --git a/tests/golden/codex_boundary_result/github_action_removed.json b/tests/golden/codex_boundary_result/github_action_removed.json
index f258c7a8..e0fe73c1 100644
--- a/tests/golden/codex_boundary_result/github_action_removed.json
+++ b/tests/golden/codex_boundary_result/github_action_removed.json
@@ -3,7 +3,7 @@
"agent": "codex",
"tool": {
"name": "agents-shipgate",
- "version": "0.16.0b6"
+ "version": "0.16.0b7"
},
"subject": {
"agent": "codex"
diff --git a/tests/golden/codex_boundary_result/malformed_toml.json b/tests/golden/codex_boundary_result/malformed_toml.json
index c9935823..6e2f932f 100644
--- a/tests/golden/codex_boundary_result/malformed_toml.json
+++ b/tests/golden/codex_boundary_result/malformed_toml.json
@@ -3,7 +3,7 @@
"agent": "codex",
"tool": {
"name": "agents-shipgate",
- "version": "0.16.0b6"
+ "version": "0.16.0b7"
},
"subject": {
"agent": "codex"
diff --git a/tests/golden/codex_boundary_result/mcp_auto_approve_write.json b/tests/golden/codex_boundary_result/mcp_auto_approve_write.json
index 72da1cda..df5067ab 100644
--- a/tests/golden/codex_boundary_result/mcp_auto_approve_write.json
+++ b/tests/golden/codex_boundary_result/mcp_auto_approve_write.json
@@ -3,7 +3,7 @@
"agent": "codex",
"tool": {
"name": "agents-shipgate",
- "version": "0.16.0b6"
+ "version": "0.16.0b7"
},
"subject": {
"agent": "codex"
diff --git a/tests/golden/codex_boundary_result/network_wildcard.json b/tests/golden/codex_boundary_result/network_wildcard.json
index 3ba64f6a..15ba82fb 100644
--- a/tests/golden/codex_boundary_result/network_wildcard.json
+++ b/tests/golden/codex_boundary_result/network_wildcard.json
@@ -3,7 +3,7 @@
"agent": "codex",
"tool": {
"name": "agents-shipgate",
- "version": "0.16.0b6"
+ "version": "0.16.0b7"
},
"subject": {
"agent": "codex"
diff --git a/tests/golden/codex_boundary_result/python_refactor.json b/tests/golden/codex_boundary_result/python_refactor.json
index 80b7ca1d..a006a256 100644
--- a/tests/golden/codex_boundary_result/python_refactor.json
+++ b/tests/golden/codex_boundary_result/python_refactor.json
@@ -3,7 +3,7 @@
"agent": "codex",
"tool": {
"name": "agents-shipgate",
- "version": "0.16.0b6"
+ "version": "0.16.0b7"
},
"subject": {
"agent": "codex"
diff --git a/tests/golden/codex_boundary_result/unknown_permission_key.json b/tests/golden/codex_boundary_result/unknown_permission_key.json
index 0ea27d4f..be146a5e 100644
--- a/tests/golden/codex_boundary_result/unknown_permission_key.json
+++ b/tests/golden/codex_boundary_result/unknown_permission_key.json
@@ -3,7 +3,7 @@
"agent": "codex",
"tool": {
"name": "agents-shipgate",
- "version": "0.16.0b6"
+ "version": "0.16.0b7"
},
"subject": {
"agent": "codex"
@@ -17,27 +17,27 @@
".codex/config.toml"
],
"control": {
- "state": "human_review_required",
+ "state": "agent_action_required",
"reason": "1 coding-agent boundary change(s) require human review.",
"completion_allowed": false,
- "must_stop": true,
- "verify_required": false,
+ "must_stop": false,
+ "verify_required": true,
"next_action": {
- "actor": "human",
- "kind": "review",
- "command": null,
+ "actor": "coding_agent",
+ "kind": "verify",
+ "command": "agents-shipgate verify --json",
"expects": null,
- "why": "Unknown Codex permission key"
+ "why": "This change owes human review but is not an emergency stop: run verify so the PR gate records the decision, and report the pending review items."
},
- "allowed_next_commands": [],
+ "allowed_next_commands": [
+ "agents-shipgate verify --json"
+ ],
"human_review": {
- "required": true,
- "why": "Unknown Codex permission key",
- "required_reviewers": [
- "agent-platform"
- ]
+ "required": false,
+ "why": null,
+ "required_reviewers": []
},
- "stop_reason": "Unknown Codex permission key"
+ "stop_reason": null
},
"repair": {
"actor": "human",
@@ -82,9 +82,7 @@
"path": ".codex/config.toml"
}
],
- "required_reviewers": [
- "agent-platform"
- ],
+ "required_reviewers": [],
"explanation": "1 coding-agent boundary change(s) require human review.",
"suggested_fixes": [
"Review the unknown permission key before trusting the boundary."
diff --git a/tests/test_action_metadata.py b/tests/test_action_metadata.py
index 37b9567f..b01bb711 100644
--- a/tests/test_action_metadata.py
+++ b/tests/test_action_metadata.py
@@ -222,10 +222,9 @@ def test_agents_shipgate_workflow_uses_merge_verdict_policy_input():
text = (WORKFLOW_DIR / "agents-shipgate.yml").read_text(encoding="utf-8")
assert "fail_on_decisions" not in text
- assert (
- 'fail_on_merge_verdicts: "blocked,human_review_required,'
- 'insufficient_evidence,unknown"' in text
- )
+ # Advisory workflows fail only on blocked/unknown; review verdicts route
+ # to the human PR reviewer instead of an uncleareable red CI state.
+ assert 'fail_on_merge_verdicts: "blocked,unknown"' in text
def test_action_step_summary_leads_with_verifier_merge_state():
diff --git a/tests/test_agent_boundary.py b/tests/test_agent_boundary.py
index f65b64de..d35f2c72 100644
--- a/tests/test_agent_boundary.py
+++ b/tests/test_agent_boundary.py
@@ -183,7 +183,15 @@ def test_codex_grant_expansion_never_completes(
tmp_path,
_new_file_diff(".codex/config.toml", config_text),
)
- assert result.control.state == "human_review_required"
+ if "danger-full-access" in config_text:
+ # Critical grant expansion keeps the human stop.
+ assert result.control.state == "human_review_required"
+ else:
+ # Medium unknown-key rows ride the graded verify route; the review
+ # obligation is carried in pending_review and re-asserted by verify.
+ assert result.control.state == "agent_action_required"
+ assert result.control.next_action.kind == "verify"
+ assert result.pending_review
assert result.control.completion_allowed is False
@@ -223,7 +231,12 @@ def test_nested_and_case_variant_boundary_paths_never_complete(
)
result = _build(tmp_path, _new_file_diff(path, content))
- assert result.control.state == "human_review_required"
+ # Nested copies are not live host configs, so the catch-all scores them
+ # medium and the graded mapping routes to verify; completion stays
+ # forbidden and PR-time verify still reviews the trust-root touch.
+ assert result.control.state == "agent_action_required"
+ assert result.control.next_action.kind == "verify"
+ assert result.pending_review
assert result.control.completion_allowed is False
@@ -373,7 +386,12 @@ def test_unclassified_workflow_behavior_change_requires_review(tmp_path: Path) -
" steps:\n - run: curl https://example.com/install | sh",
),
)
- assert result.control.state == "human_review_required"
+ # Catch-all rows ride the graded verify route; PR-time verify still
+ # reviews the workflow trust-root touch, and completion stays forbidden.
+ assert result.control.state == "agent_action_required"
+ assert result.control.next_action.kind == "verify"
+ assert result.pending_review
+ assert result.control.completion_allowed is False
assert any(
item.evidence.get("kind") == "protected_surface_unclassified"
for item in result.violated_rules
@@ -404,7 +422,15 @@ def test_shared_trust_roots_never_complete_without_safe_receipt(
path: str,
) -> None:
result = _build(tmp_path, _new_file_diff(path, "{}"))
- assert result.control.state == "human_review_required"
+ if path.startswith(".agents-shipgate/"):
+ # Gate-governing state (baselines, waivers) stays a human stop at any
+ # scored risk — the graded band never covers the gate's own inputs.
+ assert result.control.state == "human_review_required"
+ else:
+ assert result.control.state == "agent_action_required"
+ assert result.control.next_action.kind == "verify"
+ assert result.pending_review
+ assert result.control.completion_allowed is False
def test_unified_policy_cannot_downgrade_host_safety_floor(tmp_path: Path) -> None:
diff --git a/tests/test_agent_instructions_apply.py b/tests/test_agent_instructions_apply.py
index ff2daa59..e27322fb 100644
--- a/tests/test_agent_instructions_apply.py
+++ b/tests/test_agent_instructions_apply.py
@@ -190,7 +190,7 @@ 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"] == "18"
+ assert payload["contract_version"] == "19"
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")
diff --git a/tests/test_agent_instructions_renderers.py b/tests/test_agent_instructions_renderers.py
index 16e7ae79..257808c3 100644
--- a/tests/test_agent_instructions_renderers.py
+++ b/tests/test_agent_instructions_renderers.py
@@ -48,7 +48,8 @@
"bc5cd31a5c4d4f6a1ebf6a04db3f80480e7cc5f9ab2b7a6f7e3f62e8ddfc3937"
),
".claude/skills/agents-shipgate/ci-recipes/advisory-pr-comment.yml": (
- "7fcf37dacee5cc64263d21c6be3a5c7e99b4e6fb45d96b3dda7bdd0603aff6cd"
+ # Renders {{ shipgate_version }}; changes on every version bump.
+ "1f6ef3e51a09e824a98d6e5b33f2bf61282c62e2ae859e234da9f56161fa4a87"
),
".claude/skills/agents-shipgate/prompts/add-shipgate-to-repo.md": (
"53296f41b7c2bc8538555a4361707de8b990748b7a5d80ae4ce066af83af8fa7"
@@ -162,7 +163,7 @@ def test_local_contract_renderer_exposes_agent_operational_fields() -> None:
payload = json.loads(render_local_contract_file())
assert payload["schema_version"] == "7"
assert payload["agents_shipgate_version"]
- assert payload["contract_version"] == "18"
+ assert payload["contract_version"] == "19"
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")
diff --git a/tests/test_codex_boundary_check.py b/tests/test_codex_boundary_check.py
index b6188d57..f210d0f4 100644
--- a/tests/test_codex_boundary_check.py
+++ b/tests/test_codex_boundary_check.py
@@ -21,24 +21,49 @@
runner = CliRunner()
+# case -> (decision, rule ids, expected control state). The graded local
+# mapping routes low/medium require_review rows to the coding-agent verify
+# route; high/critical rows, gate-weakening rules, and unparseable content
+# keep the human stop.
CASES = {
- "network_wildcard": ("require_review", ["CODEX-NETWORK-WILDCARD"]),
- "mcp_auto_approve_write": ("block", ["CODEX-MCP-AUTO-APPROVE-WRITE"]),
+ "network_wildcard": (
+ "require_review",
+ ["CODEX-NETWORK-WILDCARD"],
+ "human_review_required", # high risk stays a stop
+ ),
+ "mcp_auto_approve_write": (
+ "block",
+ ["CODEX-MCP-AUTO-APPROVE-WRITE"],
+ "human_review_required",
+ ),
"agents_requirement_removed": (
"require_review",
["CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED"],
+ "human_review_required", # gate-weakening rule is band-excluded
+ ),
+ "github_action_removed": (
+ "block",
+ ["CODEX-CI-GATE-REMOVED"],
+ "human_review_required",
+ ),
+ "docs_only": ("allow", [], "complete"),
+ "python_refactor": ("allow", [], "complete"),
+ "unknown_permission_key": (
+ "require_review",
+ ["CODEX-UNKNOWN-PERMISSION-KEY"],
+ "agent_action_required", # medium, parseable -> graded verify route
+ ),
+ "malformed_toml": (
+ "require_review",
+ ["CODEX-CONFIG-PARSE-FAILED"],
+ "human_review_required", # unparseable content is band-excluded
),
- "github_action_removed": ("block", ["CODEX-CI-GATE-REMOVED"]),
- "docs_only": ("allow", []),
- "python_refactor": ("allow", []),
- "unknown_permission_key": ("require_review", ["CODEX-UNKNOWN-PERMISSION-KEY"]),
- "malformed_toml": ("require_review", ["CODEX-CONFIG-PARSE-FAILED"]),
}
def test_codex_check_boundary_json_golden_outputs(tmp_path: Path) -> None:
validator = Draft202012Validator(json.loads(SCHEMA.read_text(encoding="utf-8")))
- for case, (decision, rule_ids) in CASES.items():
+ for case, (decision, rule_ids, expected_state) in CASES.items():
result = runner.invoke(
app,
[
@@ -60,7 +85,6 @@ def test_codex_check_boundary_json_golden_outputs(tmp_path: Path) -> None:
assert payload["decision"] == decision
assert [item["id"] for item in payload["violated_rules"]] == rule_ids
control = _control(payload)
- expected_state = "complete" if decision == "allow" else "human_review_required"
assert control["state"] == expected_state
diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py
index a9a7d906..84ad246b 100644
--- a/tests/test_install_hooks.py
+++ b/tests/test_install_hooks.py
@@ -386,6 +386,157 @@ def test_generated_stop_hook_verifies_worktree_once_without_head(tmp_path: Path)
assert "--no-manifest-present" in trigger_entries[0]
+def _run_stop_hook(
+ tmp_path: Path,
+ *,
+ verify_payload: str | None,
+ stop_hook_active: bool = False,
+) -> subprocess.CompletedProcess[str]:
+ log = tmp_path.parent / f"{tmp_path.name}-cli.log"
+ fake_cli = _fake_shipgate_cli(tmp_path)
+ env = os.environ.copy()
+ env["CLAUDE_PROJECT_DIR"] = str(tmp_path)
+ env["AGENTS_SHIPGATE_CLI"] = f"{sys.executable} {fake_cli} {log}"
+ if verify_payload is not None:
+ env["FAKE_VERIFY_PAYLOAD"] = verify_payload
+ payload: dict[str, object] = {"cwd": str(tmp_path)}
+ if stop_hook_active:
+ payload["stop_hook_active"] = True
+ return subprocess.run(
+ [sys.executable, str(tmp_path / HOOK_SCRIPT_RELATIVE_PATH), "verify"],
+ input=json.dumps(payload),
+ capture_output=True,
+ text=True,
+ env=env,
+ cwd=tmp_path,
+ check=False,
+ )
+
+
+def _stop_hook_workspace(tmp_path: Path) -> None:
+ render_or_install_hooks(
+ workspace=tmp_path,
+ target="claude-code",
+ write=True,
+ config=Path("shipgate.yaml"),
+ base="origin/main",
+ head="",
+ ci_mode="advisory",
+ )
+ _init_repo(tmp_path)
+ (tmp_path / "prompts").mkdir()
+ (tmp_path / "prompts" / "refund.md").write_text("require approval\n", encoding="utf-8")
+
+
+def test_stop_hook_blocks_only_for_agent_action_required(tmp_path: Path) -> None:
+ _stop_hook_workspace(tmp_path)
+ payload = json.dumps(
+ {
+ "release_decision": {"decision": "review_required", "blockers": [], "review_items": [{}]},
+ "control": {
+ "state": "agent_action_required",
+ "reason": "verify pending",
+ "next_action": {"kind": "verify", "command": "agents-shipgate verify --json"},
+ },
+ }
+ )
+ result = _run_stop_hook(tmp_path, verify_payload=payload)
+ assert result.returncode == 0, result.stderr
+ out = json.loads(result.stdout)
+ assert out["decision"] == "block"
+ assert "agents-shipgate verify --json" in out["reason"]
+
+ # The re-entry guard still prevents a forced-continuation loop.
+ rerun = _run_stop_hook(tmp_path, verify_payload=payload, stop_hook_active=True)
+ assert rerun.returncode == 0, rerun.stderr
+ assert rerun.stdout == ""
+
+
+def test_stop_hook_hands_off_instead_of_blocking_on_human_review(tmp_path: Path) -> None:
+ # ``must_stop=true`` means the turn must be allowed to end for a human —
+ # a Stop-hook block would force the agent to keep working, the opposite.
+ _stop_hook_workspace(tmp_path)
+ payload = json.dumps(
+ {
+ "release_decision": {
+ "decision": "blocked",
+ "blockers": [{}],
+ "review_items": [],
+ },
+ "control": {
+ "state": "human_review_required",
+ "reason": "capability change requires approval evidence",
+ "stop_reason": "capability change requires approval evidence",
+ },
+ }
+ )
+ result = _run_stop_hook(tmp_path, verify_payload=payload)
+ assert result.returncode == 0, result.stderr
+ out = json.loads(result.stdout)
+ assert "decision" not in out
+ assert "A human must review" in out["systemMessage"]
+ assert "decision=blocked" in out["systemMessage"]
+
+ # One handoff notice per tree state: the signature cache silences repeats.
+ rerun = _run_stop_hook(tmp_path, verify_payload=payload)
+ assert rerun.returncode == 0, rerun.stderr
+ assert rerun.stdout == ""
+
+
+def test_stop_hook_warns_and_never_caches_unparseable_verifier_output(
+ tmp_path: Path,
+) -> None:
+ _stop_hook_workspace(tmp_path)
+ result = _run_stop_hook(tmp_path, verify_payload="not-json{")
+ assert result.returncode == 0, result.stderr
+ out = json.loads(result.stdout)
+ assert "decision" not in out
+ assert "could not parse" in out["systemMessage"]
+ assert "Do not treat this as a passing verdict" in out["systemMessage"]
+
+ # Unparseable output must not be cached: the warning repeats.
+ rerun = _run_stop_hook(tmp_path, verify_payload="not-json{")
+ assert rerun.returncode == 0, rerun.stderr
+ assert "could not parse" in json.loads(rerun.stdout)["systemMessage"]
+
+
+def test_stop_hook_warns_on_unrecognized_control_state(tmp_path: Path) -> None:
+ _stop_hook_workspace(tmp_path)
+ payload = json.dumps(
+ {
+ "release_decision": {"decision": "passed", "blockers": [], "review_items": []},
+ "control": {"state": "surprise_state", "reason": "future contract"},
+ }
+ )
+ result = _run_stop_hook(tmp_path, verify_payload=payload)
+ assert result.returncode == 0, result.stderr
+ out = json.loads(result.stdout)
+ assert "decision" not in out
+ assert "unrecognized control state" in out["systemMessage"]
+ assert "requiring human review" in out["systemMessage"]
+
+
+def test_stop_hook_cold_start_advises_instead_of_blocking(tmp_path: Path) -> None:
+ render_or_install_hooks(
+ workspace=tmp_path,
+ target="claude-code",
+ write=True,
+ config=Path("shipgate.yaml"),
+ base="origin/main",
+ head="",
+ ci_mode="advisory",
+ )
+ subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True)
+ (tmp_path / "prompts").mkdir()
+ (tmp_path / "prompts" / "refund.md").write_text("require approval\n", encoding="utf-8")
+ result = _run_stop_hook(tmp_path, verify_payload=None)
+ assert result.returncode == 0, result.stderr
+ out = json.loads(result.stdout)
+ assert "decision" not in out
+ assert "no shipgate.yaml exists" in out["systemMessage"]
+ assert "verify --preview" in out["systemMessage"]
+
+
def _init_repo(path: Path) -> None:
subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
subprocess.run(
@@ -430,6 +581,11 @@ def _fake_shipgate_cli(path: Path) -> Path:
print(json.dumps({"should_run": True, "rationale": "test trigger matched"}))
raise SystemExit(0)
if args and args[0] == "verify":
+ import os
+ override = os.environ.get("FAKE_VERIFY_PAYLOAD")
+ if override is not None:
+ sys.stdout.write(override)
+ raise SystemExit(0)
print(json.dumps({
"release_decision": {
"decision": "passed",
@@ -437,6 +593,7 @@ def _fake_shipgate_cli(path: Path) -> Path:
"review_items": [],
},
"base_status": "not_requested",
+ "control": {"state": "complete", "reason": "test pass"},
}))
raise SystemExit(0)
raise SystemExit(2)
diff --git a/tests/test_local_contract.py b/tests/test_local_contract.py
index 70b2bcde..c3b6ca41 100644
--- a/tests/test_local_contract.py
+++ b/tests/test_local_contract.py
@@ -66,7 +66,7 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None:
]
assert payload["schema_version"] == LOCAL_CONTRACT_SCHEMA_VERSION == "7"
assert payload["agents_shipgate_version"] == __version__
- assert payload["contract_version"] == CONTRACT_VERSION == "18"
+ assert payload["contract_version"] == CONTRACT_VERSION == "19"
assert payload["minimum_control_contract_version"] == "14"
assert payload["default_paths"]["local_contract"] == LOCAL_CONTRACT_RELATIVE_PATH
assert payload["primary_commands"] == dict(PRIMARY_COMMANDS)
diff --git a/tests/test_p0_verification_identity_canaries.py b/tests/test_p0_verification_identity_canaries.py
index a761d0ee..36368e09 100644
--- a/tests/test_p0_verification_identity_canaries.py
+++ b/tests/test_p0_verification_identity_canaries.py
@@ -79,7 +79,7 @@ def _plan() -> VerificationPlan:
**inputs_payload,
)
engine_payload = {
- "version": "0.16.0b6",
+ "version": "0.16.0b7",
"python_implementation": "CPython",
"python_version": "3.12.0",
"platform": "linux",
diff --git a/tests/test_public_surface_contract.py b/tests/test_public_surface_contract.py
index 5b539fb6..2899f208 100644
--- a/tests/test_public_surface_contract.py
+++ b/tests/test_public_surface_contract.py
@@ -2092,10 +2092,10 @@ def test_self_dogfood_manifest_scans_codex_plugin_package() -> None:
workflow = _read(".github/workflows/agents-shipgate-self.yml")
assert "config: shipgate-self.yaml" in workflow
assert "verify_mode: verify" in workflow
- assert (
- 'fail_on_merge_verdicts: "blocked,human_review_required,'
- 'insufficient_evidence,unknown"' in workflow
- )
+ # Advisory workflows fail only on blocked/unknown: human_review_required
+ # is decided by the PR reviewer and no verifier mechanism can clear it,
+ # so failing CI on it would leave trust-root-touching PRs permanently red.
+ assert 'fail_on_merge_verdicts: "blocked,unknown"' in workflow
def test_pre_commit_local_docs_show_same_path_trigger_clauses():
diff --git a/tests/test_safety_qualification.py b/tests/test_safety_qualification.py
index 0d1680ce..2916a91b 100644
--- a/tests/test_safety_qualification.py
+++ b/tests/test_safety_qualification.py
@@ -56,7 +56,7 @@
)
DECISIONS = ("passed", "review_required", "insufficient_evidence", "blocked")
-VERSION = "0.16.0b6"
+VERSION = "0.16.0b7"
def _verification_plan(case_id: str) -> VerificationPlan:
@@ -209,8 +209,8 @@ def _write_json(path: Path, value: object) -> None:
def _write_wheel(path: Path) -> None:
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(
- "agents_shipgate-0.16.0b6.dist-info/METADATA",
- "Metadata-Version: 2.4\nName: agents-shipgate\nVersion: 0.16.0b6\n",
+ "agents_shipgate-0.16.0b7.dist-info/METADATA",
+ "Metadata-Version: 2.4\nName: agents-shipgate\nVersion: 0.16.0b7\n",
)
@@ -254,7 +254,7 @@ def _fixture(
actual_overrides: dict[str, str] | None = None,
disagreement_case: str | None = None,
) -> tuple[Path, Path, Path, Path]:
- wheel = tmp_path / "agents_shipgate-0.16.0b6-py3-none-any.whl"
+ wheel = tmp_path / "agents_shipgate-0.16.0b7-py3-none-any.whl"
_write_wheel(wheel)
policy = tmp_path / "qualification-policy.json"
_write_json(policy, {"policy": "beta-exact", "version": 1})
diff --git a/tests/test_safety_qualification_release.py b/tests/test_safety_qualification_release.py
index f799f45d..77b48314 100644
--- a/tests/test_safety_qualification_release.py
+++ b/tests/test_safety_qualification_release.py
@@ -26,15 +26,15 @@
verify_release_qualification,
)
-VERSION = "0.16.0b6"
+VERSION = "0.16.0b7"
REPO_ROOT = Path(__file__).resolve().parent.parent
def _write_wheel(path: Path) -> None:
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(
- "agents_shipgate-0.16.0b6.dist-info/METADATA",
- "Metadata-Version: 2.4\nName: agents-shipgate\nVersion: 0.16.0b6\n",
+ "agents_shipgate-0.16.0b7.dist-info/METADATA",
+ "Metadata-Version: 2.4\nName: agents-shipgate\nVersion: 0.16.0b7\n",
)
@@ -177,7 +177,7 @@ def _production_result(wheel: Path) -> SafetyQualificationResultV1:
def _fixture(tmp_path: Path) -> tuple[Path, Path]:
tmp_path.mkdir(parents=True, exist_ok=True)
- wheel = tmp_path / "agents_shipgate-0.16.0b6-py3-none-any.whl"
+ wheel = tmp_path / "agents_shipgate-0.16.0b7-py3-none-any.whl"
_write_wheel(wheel)
qualification = tmp_path / "safety-qualification.json"
qualification.write_text(
diff --git a/tests/test_v07_metadata_roundtrip.py b/tests/test_v07_metadata_roundtrip.py
index 23395a54..0c1cfb8a 100644
--- a/tests/test_v07_metadata_roundtrip.py
+++ b/tests/test_v07_metadata_roundtrip.py
@@ -240,7 +240,7 @@ def test_package_version_is_current_in_tree_runtime():
"""Guard against bumping schemas while leaving package metadata behind."""
import agents_shipgate
- assert agents_shipgate.__version__ == "0.16.0b6", (
+ assert agents_shipgate.__version__ == "0.16.0b7", (
f"package version is {agents_shipgate.__version__!r}; "
- "expected 0.16.0b6 for the current in-tree runtime"
+ "expected 0.16.0b7 for the current in-tree runtime"
)