From 2350036fe1ed1c0ed073b490392f93dc59530d0b Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 26 Jul 2026 21:46:40 -0700 Subject: [PATCH 1/5] Grade the local boundary stop: low/medium require_review routes to verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local check previously collapsed every require_review violation into the same human_review_required hard stop as block — a CLAUDE.md comment, an unknown settings key, and a critical grant expansion were operationally identical. The graded mapping routes require_review sets that are entirely low/medium risk to control.state=agent_action_required with the exact verify command; the review obligation is preserved in a new additive pending_review[] field on the agent-boundary result and re-asserted by PR-time verify, whose release-decision semantics are unchanged. Fail-closed exclusions keep the human stop for: block actions and critical risk anywhere in the set; BOUNDARY-INPUT-INCOMPLETE and unparseable parse-failure evidence (unknown content is not reviewable content); CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED (gate weakening); every gate-governing trust-root class (manifest, policy, ci_gate, shipgate_state) so a composite diff bundling a safe append with an unsafe manifest edit still routes to a human; and experimental adapter surfaces. The trust-root table moves to core/trust_roots.py (checks.verify re-exports it) so the boundary evaluator classifies paths from the same data without an import cycle. pending_review lives on AgentBoundaryResultV1 only — the deprecated codex-boundary v2 format stays byte-frozen. Co-Authored-By: Claude Fable 5 --- docs/agent-boundary-result-schema.v1.json | 75 +++++++++ src/agents_shipgate/checks/verify.py | 95 ++--------- src/agents_shipgate/core/agent_boundary.py | 13 ++ src/agents_shipgate/core/codex_boundary.py | 152 +++++++++++++++++- src/agents_shipgate/core/trust_roots.py | 114 +++++++++++++ src/agents_shipgate/schemas/agent_boundary.py | 15 +- src/agents_shipgate/schemas/agent_result.py | 29 +++- .../unknown_permission_key.json | 32 ++-- tests/test_agent_boundary.py | 34 +++- tests/test_codex_boundary_check.py | 42 +++-- 10 files changed, 484 insertions(+), 117 deletions(-) create mode 100644 src/agents_shipgate/core/trust_roots.py diff --git a/docs/agent-boundary-result-schema.v1.json b/docs/agent-boundary-result-schema.v1.json index b551d96b..a5fb2346 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": { @@ -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/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/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..150268e6 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,9 +38,11 @@ parse_unified_diff, ) from agents_shipgate.core.boundary_registry import ( + BOUNDARY_ADAPTERS, boundary_adapters_for_path, is_agent_boundary_path, ) +from agents_shipgate.core.trust_roots import trust_root_class_for from agents_shipgate.core.manifest_proposals import ( assess_coverage_increasing_tool_source_proposal, ) @@ -47,6 +50,7 @@ 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/tests/golden/codex_boundary_result/unknown_permission_key.json b/tests/golden/codex_boundary_result/unknown_permission_key.json index 0ea27d4f..b0d60b71 100644 --- a/tests/golden/codex_boundary_result/unknown_permission_key.json +++ b/tests/golden/codex_boundary_result/unknown_permission_key.json @@ -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_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_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 From 72759a58d2b16b38bc543057e36a0276fa01fcd5 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 26 Jul 2026 21:46:40 -0700 Subject: [PATCH 2/5] Stop hook follows control.state instead of the release label A Claude Code Stop-hook block forces the agent to keep working, so blocking on any non-passed release decision was backwards twice over: human_review_required (must_stop=true) means the turn must END so a human can take over, and review_required with a pending agent action deserves a route, not a lecture. 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, and is never treated as passing. The cold-start no-manifest case advises verify --preview instead of forcing continuation. Co-Authored-By: Claude Fable 5 --- docs/agents/use-with-claude-code.md | 10 +- src/agents_shipgate/cli/install_hooks.py | 87 ++++++++++--- tests/test_install_hooks.py | 157 +++++++++++++++++++++++ 3 files changed, 235 insertions(+), 19 deletions(-) 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/src/agents_shipgate/cli/install_hooks.py b/src/agents_shipgate/cli/install_hooks.py index 725cacf0..a201cda2 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,75 @@ 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}" + f"{base_note}" + ) + + # 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}. " + 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}. " + "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/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) From 6f4802d2562ccd0b79b67d7595fe1d8a8debfa7d Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 26 Jul 2026 21:57:35 -0700 Subject: [PATCH 3/5] Contract v19: version stamps, migration note, and 0.16.0b7 pin sweep Runtime contract 18 -> 19 documents the graded local boundary stop and the control-state-driven Stop hook. minimum_control_contract_version stays 14: the AgentControl union, its fixed must_stop/completion_allowed literals, and the release gating signal are untouched, so every >=14 consumer keeps working. pending_review[] ships additively on the regenerated agent-boundary-result-schema.v1.json; the deprecated codex-boundary v2 schema stays byte-frozen. Version 0.16.0b6 -> 0.16.0b7 swept across pyproject, __init__, plugin manifests, marketplace, .well-known, llms.txt, doc stamps, wheel-guard tests, and the codex boundary goldens; STABILITY gains the 0.16.0b7 migration note and CHANGELOG the three graded-stop bullets. Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 2 +- .well-known/agents-shipgate.json | 4 +- CHANGELOG.md | 38 +++++++++++++++ README.md | 2 +- STABILITY.md | 48 ++++++++++++++++++- benchmark/safety-qualification/README.md | 4 +- docs/agent-boundary-result-schema.v1.json | 2 +- docs/agent-contract-current.md | 19 ++++++-- docs/agent-handoff-schema.v6.json | 2 +- docs/agent-result-schema.v2.json | 2 +- docs/ai-search-summary.md | 2 +- docs/architecture.md | 2 +- docs/faq.md | 2 +- docs/passed-verdict-contract.md | 2 +- docs/report-reading-for-agents.md | 6 +-- docs/verification-reproducibility.md | 4 +- llms-full.txt | 19 ++++++-- llms.txt | 2 +- .../agents-shipgate/.codex-plugin/plugin.json | 2 +- .../claude-code/.claude-plugin/plugin.json | 2 +- pyproject.toml | 2 +- src/agents_shipgate/__init__.py | 2 +- src/agents_shipgate/cli/install_hooks.py | 9 ++-- src/agents_shipgate/core/codex_boundary.py | 2 +- src/agents_shipgate/schemas/contract.py | 2 +- .../agents_requirement_removed.json | 2 +- .../codex_boundary_result/docs_only.json | 2 +- .../github_action_removed.json | 2 +- .../codex_boundary_result/malformed_toml.json | 2 +- .../mcp_auto_approve_write.json | 2 +- .../network_wildcard.json | 2 +- .../python_refactor.json | 2 +- .../unknown_permission_key.json | 2 +- tests/test_agent_instructions_apply.py | 2 +- tests/test_agent_instructions_renderers.py | 2 +- tests/test_local_contract.py | 2 +- .../test_p0_verification_identity_canaries.py | 2 +- tests/test_safety_qualification.py | 8 ++-- tests/test_safety_qualification_release.py | 8 ++-- tests/test_v07_metadata_roundtrip.py | 4 +- 40 files changed, 165 insertions(+), 62 deletions(-) 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/.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..af66eb13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ ## 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. +- **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/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 a5fb2346..9846a492 100644 --- a/docs/agent-boundary-result-schema.v1.json +++ b/docs/agent-boundary-result-schema.v1.json @@ -469,7 +469,7 @@ "type": "string" }, "version": { - "default": "0.16.0b6", + "default": "0.16.0b7", "title": "Version", "type": "string" } 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/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/cli/install_hooks.py b/src/agents_shipgate/cli/install_hooks.py index a201cda2..03960b33 100644 --- a/src/agents_shipgate/cli/install_hooks.py +++ b/src/agents_shipgate/cli/install_hooks.py @@ -725,10 +725,7 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in review_items = len((verifier.get("release_decision") or {}).get("review_items") or []) 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}" - f"{base_note}" - ) + 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 @@ -751,7 +748,7 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in 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}. " + 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, " @@ -762,7 +759,7 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in stop_reason = control.get("stop_reason") or control.get("reason") or "" return _emit_context( "Stop", - f"Agents Shipgate verify ran before completion: {summary}. " + 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. " diff --git a/src/agents_shipgate/core/codex_boundary.py b/src/agents_shipgate/core/codex_boundary.py index 150268e6..f57b523c 100644 --- a/src/agents_shipgate/core/codex_boundary.py +++ b/src/agents_shipgate/core/codex_boundary.py @@ -42,10 +42,10 @@ boundary_adapters_for_path, is_agent_boundary_path, ) -from agents_shipgate.core.trust_roots import trust_root_class_for 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, 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 b0d60b71..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" 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..7ab8f081 100644 --- a/tests/test_agent_instructions_renderers.py +++ b/tests/test_agent_instructions_renderers.py @@ -162,7 +162,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_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_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" ) From 752005f13d7d5926583045526e0b44820848f00a Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 26 Jul 2026 22:19:16 -0700 Subject: [PATCH 4/5] Kit render-hash protocol for the 0.16.0b7 version bump ci-recipes/advisory-pr-comment.yml templates {{ shipgate_version }}, so the version bump changes its rendered bytes: EXPECTED_CLAUDE_CODE_SKILL_RENDER_SHA256 advances to the b7 render and the outgoing b6 hash joins prior_render_sha256 so kit updates recognize previously installed copies as managed. Hashes verified byte-exact against the template for both versions. Co-Authored-By: Claude Fable 5 --- .../claude-code-skill/.agents-shipgate-kit-metadata.json | 3 ++- tests/test_agent_instructions_renderers.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/tests/test_agent_instructions_renderers.py b/tests/test_agent_instructions_renderers.py index 7ab8f081..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" From f3d53390162bb520a189494b1e74b387d9acb8d8 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 26 Jul 2026 22:21:54 -0700 Subject: [PATCH 5/5] Own-repo advisory workflows fail only on blocked/unknown merge verdicts PR #274 expanded fail_on_merge_verdicts to include human_review_required and insufficient_evidence, but no verifier mechanism can clear a human_review_required merge verdict (human_ack is decision-inert, checks.ignore is category-blocked for SHIP-VERIFY-*, and a baseline never changes the review branch) - so every trust-root-touching PR, including routine release pin sweeps that bump the plugin manifests, fails CI permanently. The verdict's own rationale says release is allowed and the human reviewer should weigh in; the PR review is where that human weighs in. Both advisory workflows now fail only on blocked and unknown (fail-closed against parse/contract breakage). Review routing stays fully visible in the uploaded verifier artifact, and the GitHub Action's own defaults are unchanged. Reviewed and approved by Pengfei in-session before commit. Co-Authored-By: Claude Fable 5 --- .github/workflows/agents-shipgate-self.yml | 5 ++++- .github/workflows/agents-shipgate.yml | 8 +++++++- CHANGELOG.md | 11 +++++++++++ tests/test_action_metadata.py | 7 +++---- tests/test_public_surface_contract.py | 8 ++++---- 5 files changed, 29 insertions(+), 10 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index af66eb13..64fbfb1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,17 @@ 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 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_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():