From 215db6b6adbc0dea8776682db910c4fa27011385 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Tue, 28 Jul 2026 16:20:34 -0700 Subject: [PATCH 01/12] Stop calling a first adoption a policy weakening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the manifest to a repository that had none is the first verdict every new adopter sees, and every surface got it wrong: the headline said the PR weakens the policy that evaluates it, the fail-safe finding was titled "Policy change cannot be proven safe", and a missing-manifest base counted as a safe recovery, which nulled the fix_task — so the run said stop and named nothing to do. Adoption is now proved from git rather than inferred from the diff, and deliberately proved the hard way: the comparison base must carry no manifest under any name. A PR that *moves* the manifest while loosening it also finds nothing at the configured path, and would otherwise get to call itself a first adoption. What did not change: the check id, the medium severity, the human_review_required state, and can_merge_without_human. Adopting a release policy is still a human decision. Only the claim about what happened changed — plus a fix_task that now names the one act that clears it. `check` gets the same correction locally, keyed on the diff carrying exactly one manifest record and that record being a plain addition. A composite diff (new manifest plus an edit to a live one) keeps the generic wording, per PR #282: a block-level "safe" signal must never soften a path-wide fail-closed guard. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 + src/agents_shipgate/checks/verify_policy.py | 32 +- src/agents_shipgate/cli/verify/fix_task.py | 104 ++++-- src/agents_shipgate/cli/verify/git.py | 18 + .../cli/verify/orchestrator.py | 102 +++++- src/agents_shipgate/core/agent_boundary.py | 58 +++- src/agents_shipgate/schemas/verification.py | 7 + tests/test_adapter_static_only.py | 2 +- tests/test_agent_boundary.py | 60 ++++ tests/test_first_adoption.py | 311 ++++++++++++++++++ tests/test_fix_task_contract.py | 66 ++++ 11 files changed, 743 insertions(+), 33 deletions(-) create mode 100644 tests/test_first_adoption.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f1f32c..2ec3c773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +- **A first adoption no longer reads as a policy weakening.** Adding the + manifest to a repository that had none is the first verdict every new adopter + sees, and it said "This PR weakens the release policy that evaluates it", + carried a finding titled "Policy change cannot be proven safe (no base + snapshot)", and — because a missing-manifest base was classified as a safe + recovery — shipped no `fix_task` at all, so nothing named the act that would + clear it. `verify` now proves adoption from git (the comparison base carries + no manifest under any name, so a *moved* manifest cannot pass itself off as a + first adoption) and says so: same check id, same `medium` severity, same + `human_review_required` state, new evidence kind `manifest_introduced`, and a + `fix_task` whose leading instruction is "review the generated shipgate.yaml + and merge the adoption through a human-reviewed PR". `check` gets the same + correction locally, keyed on the diff carrying exactly one manifest record + and that record being a plain addition. Adoption remains a human decision — + only the claim about what happened changed. + - **A way out of `insufficient_evidence` (#292).** An abstention was unactionable in practice: the decision engine generated the exact manifest snippet each evidence gap wants, but those snippets were only reachable by diff --git a/src/agents_shipgate/checks/verify_policy.py b/src/agents_shipgate/checks/verify_policy.py index c93ac6eb..3f6170e0 100644 --- a/src/agents_shipgate/checks/verify_policy.py +++ b/src/agents_shipgate/checks/verify_policy.py @@ -14,7 +14,10 @@ base) AND the PR touched a policy/manifest trust root, it emits a single ``base_snapshot_unavailable`` review-required finding — a reward-hacker must not be able to dodge review by breaking the base scan (§5.3: ambiguous -direction -> review_required, never silent pass). +direction -> review_required, never silent pass). When the base is proven +to carry no manifest at all, the same fail-safe emits under evidence kind +``manifest_introduced`` instead: adoption is still a human decision, but +nothing existed to weaken and the finding says so. Weakening is defined as movement toward less review / less blocking. A strengthening change (stricter mode, more fail-on severities, raised @@ -181,6 +184,33 @@ def _fail_safe(context: ScanContext) -> list[Finding]: hit = touched(_POLICY_SURFACES, changed_files(context)) if not hit: return [] + verification = context.verification + if verification is not None and verification.manifest_introduced: + # A base with no manifest at all cannot have been weakened. This still + # emits — at the same check id and severity, so the verdict and every + # fail-closed consumer are unchanged — because the human decision + # (adopt this policy) is real. Only the claim about what happened + # changes. The orchestrator proves the base carries no manifest under + # any name, so a moved-and-loosened manifest does not reach here. + return [ + verify_finding( + context, + check_id=CHECK_ID, + title="Initial Shipgate adoption: the base carries no policy", + severity="medium", + evidence={ + "kind": "manifest_introduced", + "changed_policy_files": hit, + }, + recommendation=( + "This PR introduces the Shipgate manifest rather than " + "changing an existing one, so no prior gate was weakened. " + "Adopting a release policy is a human decision: review the " + "generated shipgate.yaml and merge it through a " + "human-reviewed PR." + ), + ) + ] return [ verify_finding( context, diff --git a/src/agents_shipgate/cli/verify/fix_task.py b/src/agents_shipgate/cli/verify/fix_task.py index 6ac98022..8b3d1dcf 100644 --- a/src/agents_shipgate/cli/verify/fix_task.py +++ b/src/agents_shipgate/cli/verify/fix_task.py @@ -72,11 +72,18 @@ def build_fix_task( capability_review: VerifierCapabilityReview | None, base_ref: str | None, head_ref: str, + manifest_introduced: bool = False, ) -> VerifierFixTask | None: """Project the head scan onto a single repair task. Returns ``None`` when there is nothing to fix (mergeable, or no head release decision to reason about). + + ``manifest_introduced`` changes only the wording of the trust-root + instruction and repair: a PR that adds the manifest to a base that had none + is adopting a gate, not weakening one, and the human act it needs is + "review and merge this", not "approve a downgrade". Routing is untouched — + adoption is still an authority escalation, so it still routes to a human. """ if merge_verdict == "mergeable": return None @@ -166,12 +173,14 @@ def build_fix_task( gating, merge_verdict=merge_verdict, evidence_degraded=evidence_degraded, + manifest_introduced=manifest_introduced, ), allowed_repairs=_human_repairs( report, capability_review, gating, verification_command=verification_command, + manifest_introduced=manifest_introduced, ), forbidden_repairs=_forbidden_repairs(gating), forbidden_shortcuts=list(FORBIDDEN_SHORTCUTS), @@ -204,32 +213,66 @@ def _human_instructions( *, merge_verdict: MergeVerdict = "human_review_required", evidence_degraded: bool = False, + manifest_introduced: bool = False, ) -> list[str]: decision = report.release_decision assert decision is not None out: list[str] = [decision.reason] + # The adoption note frames everything under it — a reader who does not know + # the manifest is new will misread the evidence remedies as complaints + # about their change. It also has to survive ``_MAX_INSTRUCTIONS``: a + # cold-start repo generates enough evidence remedies to push a + # later-appended instruction off the end. + adoption_note = _adoption_instruction(capability_review, manifest_introduced) + if adoption_note is not None: + out.append(adoption_note) # Surface the concrete "make the hidden authority enumerable" remedies # whenever evidence is degraded — not only on the bare IE verdict. A # high-finding case elevated to review_required carries the same # evidence gap and the human needs the same remedy. if merge_verdict == "insufficient_evidence" or evidence_degraded: out.extend(_insufficient_evidence_remedies(report)) - if capability_review.policy_weakened: - out.append( - "A human must approve the release-policy change in this PR; the " - "coding agent that made the change cannot self-approve it." - ) - if capability_review.trust_root_touched: - out.append( - "A human must review the touched release trust root (manifest, CI " - "gate, agent instructions, or trigger catalog) before merge." - ) + if adoption_note is None: + if capability_review.policy_weakened: + out.append( + "A human must approve the release-policy change in this PR; the " + "coding agent that made the change cannot self-approve it." + ) + if capability_review.trust_root_touched: + out.append( + "A human must review the touched release trust root (manifest, CI " + "gate, agent instructions, or trigger catalog) before merge." + ) # List every gating finding's recommendation — a human-routed task owns the # whole decision, including findings whose routing fields were ambiguous. out.extend(finding.recommendation for finding in gating if finding.recommendation) return _dedupe_cap(out) +def _adoption_instruction( + capability_review: VerifierCapabilityReview, + manifest_introduced: bool, +) -> str | None: + """The one human act a first adoption needs, or ``None``. + + Returns a string in exactly the cases where the generic policy/trust-root + instructions would have fired, so it replaces them rather than adding to + them; the routing decision that produced them is untouched. + """ + + if not manifest_introduced: + return None + if not (capability_review.policy_weakened or capability_review.trust_root_touched): + return None + return ( + "This PR adopts Agents Shipgate: the base carries no manifest, so " + "nothing existing was weakened. Review the generated shipgate.yaml " + "(and the agent-instruction and CI files it adds) and merge them " + "through a human-reviewed PR — a coding agent cannot adopt a release " + "policy on the repository's behalf." + ) + + def _insufficient_evidence_remedies(report: ReadinessReport) -> list[str]: """Concrete remedies for the ``insufficient_evidence`` dead-end. @@ -353,30 +396,45 @@ def _human_repairs( gating: list[Finding], *, verification_command: str, + manifest_introduced: bool = False, ) -> list[VerifierRepair]: decision = report.release_decision assert decision is not None repairs: list[VerifierRepair] = [] - if capability_review.policy_weakened: + if _adoption_instruction(capability_review, manifest_introduced) is not None: repairs.append( VerifierRepair( - id="review_policy_weakening", + id="adopt_shipgate_manifest", actor="human", - kind="review_policy_change", + kind="review_trust_root_change", target="shipgate.yaml", - reason="A human must approve release-policy weakening before merge.", + reason=( + "This PR introduces the Shipgate manifest; a human must " + "review it and merge the adoption." + ), ) ) - if capability_review.trust_root_touched: - repairs.append( - VerifierRepair( - id="review_trust_root", - actor="human", - kind="review_trust_root_change", - target="manifest, CI gate, agent instructions, or trigger catalog", - reason="A human must review the touched release trust root before merge.", + else: + if capability_review.policy_weakened: + repairs.append( + VerifierRepair( + id="review_policy_weakening", + actor="human", + kind="review_policy_change", + target="shipgate.yaml", + reason="A human must approve release-policy weakening before merge.", + ) + ) + if capability_review.trust_root_touched: + repairs.append( + VerifierRepair( + id="review_trust_root", + actor="human", + kind="review_trust_root_change", + target="manifest, CI gate, agent instructions, or trigger catalog", + reason="A human must review the touched release trust root before merge.", + ) ) - ) for gap in decision.evidence_coverage.evidence_gaps: if gap.kind in {"low_confidence_tool", "source_warning"}: continue diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 3ddab58d..072e11b3 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -368,6 +368,24 @@ def read_file_at_ref(workspace: Path, ref: str, path: Path) -> str | None: return result.stdout +def paths_named_at_ref(workspace: Path, ref: str, names: frozenset[str]) -> list[str]: + """Tracked paths at ``ref`` whose file name is one of ``names``. + + Used to prove that a ref carries no Shipgate manifest *anywhere*, not just + at the configured path — otherwise moving the manifest to a new path would + make a modified gate look like a first adoption. + """ + + result = _run_git(workspace, ["ls-tree", "-r", "--name-only", ref], check=False) + if result.returncode != 0: + return [] + return [ + line + for line in result.stdout.splitlines() + if line.strip() and line.rsplit("/", maxsplit=1)[-1] in names + ] + + def working_tree_context(workspace: Path) -> tuple[list[str], str]: """Return uncommitted changed paths and tracked-file diff text. diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index 28c1c092..ece86686 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -94,6 +94,7 @@ ensure_git_workspace, git_path, merge_base_sha, + paths_named_at_ref, read_file_at_ref, ref_exists, repository_identity, @@ -432,6 +433,14 @@ def run_verify( ) base_notes.extend(cache_notes) + manifest_introduced = _manifest_introduced( + git_root=git_root, + config_relative=config_relative, + base_status=base_status, + base=base, + head=head, + ) + report: ReadinessReport | None = None head_status = "failed" head_exit_code = 4 @@ -493,6 +502,7 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: diff_text=diff_text, diff_text_available=bool(diff_text), trigger_result=trigger, + manifest_introduced=manifest_introduced, ), capability_lock_callback=capture_capability_lock, ) @@ -547,6 +557,7 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: head_exit_code=head_exit_code, out_dir=out_dir, ci_mode=ci_mode, + manifest_introduced=manifest_introduced, ) try: try: @@ -906,6 +917,51 @@ def _map_optional_tree_path( return tree_dir / relative +# File names that count as a Shipgate manifest when deciding whether a ref +# already carries a gate. ``shipgate.yaml`` is the published default; the +# configured name is added at call time so a repository that renamed its +# manifest is still recognized as adopted. +_MANIFEST_FILE_NAMES = frozenset({"shipgate.yaml"}) + + +def _manifest_introduced( + *, + git_root: Path, + config_relative: Path, + base_status: VerifierBaseStatus, + base: str | None, + head: str, +) -> bool: + """True when the comparison base carries no Shipgate manifest at all. + + Adoption and modification are different events, and the fail-safe path + could not tell them apart: a first adoption compares against a base with no + policy, which is not a base whose policy was weakened. This proves the + distinction from git rather than inferring it from the diff. + + The proof is deliberately stronger than "the configured path is absent on + the base". A PR that *moves* the manifest — say to ``config/shipgate.yaml`` + while loosening it — also finds nothing at the configured path on the base, + and would otherwise get to call itself a first adoption. Requiring that the + base carry no manifest under any name closes that. + + Unknown bases (``ref_missing``, ``archive_failed``) are never treated as + adoptions: absence of evidence is not evidence of absence. + """ + + if base_status == "missing_manifest" and base is not None: + ref: str = base + elif base_status in {"not_requested", "skipped"}: + # No base was compared against, so the manifest's own history is the + # base: present in the workspace but absent from the head commit means + # this working tree is introducing it. + ref = head + else: + return False + names = _MANIFEST_FILE_NAMES | {config_relative.name} + return not paths_named_at_ref(git_root, ref, frozenset(names)) + + def _can_merge_without_human( *, merge_verdict: MergeVerdict, @@ -939,7 +995,11 @@ def _can_merge_without_human( return True -def _self_approval_note(capability_review: VerifierCapabilityReview | None) -> str | None: +def _self_approval_note( + capability_review: VerifierCapabilityReview | None, + *, + manifest_introduced: bool = False, +) -> str | None: """The explicit self-approval prohibition when this PR edits the rules that evaluate it. @@ -947,9 +1007,27 @@ def _self_approval_note(capability_review: VerifierCapabilityReview | None) -> s gate (reward hacking). When the head scan flags a weakened policy or a touched trust root, that prohibition is surfaced as the verifier headline and the human-review reason — not left implicit in a fix_task instruction. + + A first adoption gets its own wording. The prohibition still holds (a + coding agent cannot adopt a release policy on the repository's behalf), but + a PR that adds the manifest to a base that had none weakens nothing, and + saying it does is both wrong and the first thing every new adopter reads. + ``manifest_introduced`` only changes the sentence: this returns a string in + exactly the same cases as before, so every caller that reads it as "a trust + root is in play" keeps its meaning. """ if capability_review is None: return None + if manifest_introduced and ( + capability_review.policy_weakened or capability_review.trust_root_touched + ): + return ( + "This PR introduces Agents Shipgate to this repository: the base " + "carries no manifest, so there is no prior gate this change could " + "weaken. Adopting a release policy is a human decision — review the " + "generated shipgate.yaml (and the agent-instruction and CI files it " + "adds), then merge it through a human-reviewed PR." + ) if capability_review.policy_weakened: return ( "This PR weakens the release policy that evaluates it; a coding " @@ -1068,10 +1146,13 @@ def _verifier_headline( merge_verdict: MergeVerdict, head_status: str, capability_review: VerifierCapabilityReview | None = None, + manifest_introduced: bool = False, ) -> str | None: # An agent editing the rules that evaluate its own change must see the # self-approval prohibition first, ahead of the generic scan headline. - note = _self_approval_note(capability_review) + note = _self_approval_note( + capability_review, manifest_introduced=manifest_introduced + ) if note is not None: return note if report is not None and report.agent_summary is not None: @@ -1110,6 +1191,7 @@ def _derive_verifier_control( first_next_action_override: AgentControlAction | None, base_status: str, base_ref: str | None, + manifest_introduced: bool = False, ) -> AgentControl: """Project verifier facts through the shared operational control engine.""" @@ -1167,7 +1249,10 @@ def _derive_verifier_control( verify_required=True, ) - review_reason = _self_approval_note(capability_review) or reason + review_reason = ( + _self_approval_note(capability_review, manifest_introduced=manifest_introduced) + or reason + ) unsafe_block = bool(release_decision is not None and release_decision.decision == "blocked") return derive_agent_control( reason=reason, @@ -1205,6 +1290,7 @@ def _build_verifier( preview: bool = False, headline_override: str | None = None, first_next_action_override: AgentControlAction | None = None, + manifest_introduced: bool = False, ) -> VerifierArtifact: release_decision_model = report.release_decision if report is not None else None release_decision = ( @@ -1222,10 +1308,15 @@ def _build_verifier( applicability = applicability_for(decision=decision, execution=head_status) agent_summary_model = report.agent_summary if report is not None else None capability_review = build_capability_review(report) if report is not None else None + # ``ref_missing``/``archive_failed`` are unknown-base states: the run + # already carries its own recovery action, and a repair task derived from a + # comparison that never happened would be guesswork. ``missing_manifest`` + # is not in that class — the base was read successfully and simply has no + # gate yet — so a first adoption gets a real fix_task instead of nothing to + # act on. safe_recovery = first_next_action_override is not None or base_status in { "ref_missing", "archive_failed", - "missing_manifest", } fix_task = ( None @@ -1236,6 +1327,7 @@ def _build_verifier( capability_review=capability_review, base_ref=base, head_ref=head, + manifest_introduced=manifest_introduced, ) ) can_merge = _can_merge_without_human( @@ -1248,6 +1340,7 @@ def _build_verifier( merge_verdict=merge_verdict, head_status=head_status, capability_review=capability_review, + manifest_introduced=manifest_introduced, ) if headline_override is None: provenance = _gap_provenance_note(report=report, base_report=base_report) @@ -1263,6 +1356,7 @@ def _build_verifier( first_next_action_override=first_next_action_override, base_status=base_status, base_ref=base, + manifest_introduced=manifest_introduced, ) return VerifierArtifact( workspace=str(git_root), diff --git a/src/agents_shipgate/core/agent_boundary.py b/src/agents_shipgate/core/agent_boundary.py index 2d8bd290..90d1d60d 100644 --- a/src/agents_shipgate/core/agent_boundary.py +++ b/src/agents_shipgate/core/agent_boundary.py @@ -15,7 +15,11 @@ from typing import Any, Literal from urllib.parse import urlsplit -from agents_shipgate.core.boundary_diff import BoundaryInputIssue, parse_unified_diff +from agents_shipgate.core.boundary_diff import ( + BoundaryInputIssue, + DiffFile, + parse_unified_diff, +) from agents_shipgate.core.boundary_registry import ( BOUNDARY_ADAPTERS, boundary_hosts_for_path, @@ -54,6 +58,7 @@ HostBoundarySnapshot, build_host_boundary_snapshot, ) +from agents_shipgate.core.trust_roots import trust_root_class_for from agents_shipgate.schemas.agent_boundary import ( AGENT_BOUNDARY_RESULT_SCHEMA_VERSION, AgentBoundaryResultV1, @@ -227,6 +232,7 @@ def evaluate_agent_boundary( combined = _with_unclassified_protected_changes( changed_files=changed_files, violations=combined, + adoption_paths=_manifest_adoption_paths(diff_files), evaluated_paths={ item.path for item in diagnostics @@ -735,11 +741,42 @@ def _boundary_summary(decision: str, violations: list[AgentResultViolatedRule]) return f"{len(violations)} coding-agent boundary change(s) block local continuation." +def _manifest_adoption_paths(diff_files: list[DiffFile]) -> frozenset[str]: + """Paths where this diff *introduces* a Shipgate manifest. + + "Adopting the gate" and "changing the gate" deserve different words, and a + pure addition is the only shape that is unambiguously the first. The + qualification is deliberately narrow: exactly one manifest record in the + whole diff, and that record a plain addition. A diff that also modifies, + deletes, or renames a manifest is touching an existing gate — PR #282's + lesson is that a block-level "this part is safe" signal must never soften a + path-wide fail-closed guard, and the composite shapes are exactly where + that goes wrong. + + Only the wording moves: the rule id, action, and risk level are unchanged, + so the local decision and the control state are identical either way. + """ + + records = [ + item + for item in diff_files + for candidate in (item.new_path, item.old_path) + if candidate and trust_root_class_for(candidate.replace("\\", "/")) == "manifest" + ] + if len(records) != 1: + return frozenset() + only = records[0] + if not only.is_new or only.is_deleted or only.is_rename or only.old_path: + return frozenset() + return frozenset({only.path.replace("\\", "/")}) + + def _with_unclassified_protected_changes( *, changed_files: list[str], violations: list[AgentResultViolatedRule], evaluated_paths: set[str], + adoption_paths: frozenset[str] = frozenset(), ) -> list[AgentResultViolatedRule]: covered = {item.path for item in violations if item.path} additions: list[AgentResultViolatedRule] = [] @@ -757,22 +794,35 @@ def _with_unclassified_protected_changes( else "PROTECTED-SURFACE-UNCLASSIFIED" ) rule = _GENERIC_RULES[kind] + adopting = normalized in adoption_paths additions.append( AgentResultViolatedRule( id=rule.id, check_id=rule.check_id, action=rule.action, # type: ignore[arg-type] risk_level=rule.risk_level, # type: ignore[arg-type] - title=rule.title, + title=( + "Adopting Agents Shipgate: this change introduces the manifest" + if adopting + else rule.title + ), path=path, evidence={ "kind": ( - "static_requirements_changed" + "manifest_introduced" + if adopting + else "static_requirements_changed" if kind == "STATIC-REQUIREMENTS-CHANGED" else "protected_surface_unclassified" ) }, - recommendation=rule.recommendation, + recommendation=( + "Review the generated shipgate.yaml and merge the adoption " + "through a human-reviewed PR; a coding agent cannot adopt a " + "release policy on the repository's behalf." + if adopting + else rule.recommendation + ), ) ) return _dedupe_violations([*violations, *additions]) diff --git a/src/agents_shipgate/schemas/verification.py b/src/agents_shipgate/schemas/verification.py index 19244e90..dc019d00 100644 --- a/src/agents_shipgate/schemas/verification.py +++ b/src/agents_shipgate/schemas/verification.py @@ -31,3 +31,10 @@ class VerificationContext(BaseModel): diff_text: str | None = None diff_text_available: bool = False trigger_result: dict[str, Any] = Field(default_factory=dict) + # True only when the comparison base carries no Shipgate manifest at all — + # this diff *introduces* the gate rather than modifying one. Checks that + # fail safe on a missing base use it to say so honestly; it never relaxes a + # verdict. The orchestrator proves it from git (see + # ``cli/verify/orchestrator._manifest_introduced``), so a renamed manifest + # cannot pass itself off as a first adoption. + manifest_introduced: bool = False diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index c092d16a..9f57ec60 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -303,7 +303,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=616, + line=634, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " diff --git a/tests/test_agent_boundary.py b/tests/test_agent_boundary.py index d35f2c72..63819898 100644 --- a/tests/test_agent_boundary.py +++ b/tests/test_agent_boundary.py @@ -725,3 +725,63 @@ def _init_repo(path: Path) -> None: (path / "README.md").write_text("test\n", encoding="utf-8") subprocess.run(["git", "add", "README.md"], cwd=path, check=True) subprocess.run(["git", "commit", "-qm", "init"], cwd=path, check=True) + + +# --- first adoption: honest wording, identical routing ---------------------- + +_MANIFEST = ( + "version: '1'\n" + "project:\n name: demo\n" + "agent:\n name: support\n declared_purpose: Help customers.\n" +) + + +def test_new_manifest_reads_as_adoption_not_an_unclassified_surface( + tmp_path: Path, +) -> None: + result = _build(tmp_path, _new_file_diff("shipgate.yaml", _MANIFEST)) + + # Routing is untouched: adopting a gate is still a human decision. + assert result.decision == "require_review" + assert result.control.state == "human_review_required" + assert result.control.must_stop is True + + rows = [item for item in result.violated_rules if item.path == "shipgate.yaml"] + assert rows and rows[0].id == "BOUNDARY-PROTECTED-SURFACE-UNCLASSIFIED" + assert rows[0].evidence["kind"] == "manifest_introduced" + assert "Adopting Agents Shipgate" in rows[0].title + assert "human-reviewed PR" in rows[0].recommendation + + +def test_editing_an_existing_manifest_is_not_an_adoption(tmp_path: Path) -> None: + target = tmp_path / "shipgate.yaml" + target.write_text(_MANIFEST, encoding="utf-8") + result = _build( + tmp_path, + _change_diff("shipgate.yaml", _MANIFEST, _MANIFEST + "ci:\n mode: advisory\n"), + ) + + rows = [item for item in result.violated_rules if item.path == "shipgate.yaml"] + assert rows + assert all(row.evidence.get("kind") != "manifest_introduced" for row in rows) + + +def test_composite_manifest_diff_never_reads_as_an_adoption(tmp_path: Path) -> None: + """A new manifest plus an edit to an existing one is not a first adoption. + + Without the "exactly one manifest record" rule, the added file would win + the friendlier wording while the diff was in fact changing a live gate. + """ + + existing = tmp_path / "shipgate.yaml" + existing.write_text(_MANIFEST, encoding="utf-8") + diff = _new_file_diff("service/shipgate.yaml", _MANIFEST) + _change_diff( + "shipgate.yaml", _MANIFEST, _MANIFEST + "ci:\n mode: advisory\n" + ) + result = _build(tmp_path, diff) + + assert result.control.state == "human_review_required" + assert all( + row.evidence.get("kind") != "manifest_introduced" + for row in result.violated_rules + ) diff --git a/tests/test_first_adoption.py b/tests/test_first_adoption.py new file mode 100644 index 00000000..33b9390b --- /dev/null +++ b/tests/test_first_adoption.py @@ -0,0 +1,311 @@ +"""First adoption is not a policy weakening. + +Adding the manifest to a repository that had none is the first verdict every +new adopter sees. It used to read "This PR weakens the release policy that +evaluates it", carried a fail-safe finding titled "Policy change cannot be +proven safe", and — because the missing-manifest base was classified as a safe +recovery — shipped no ``fix_task`` at all, so nothing named the one act that +would clear it. + +These tests pin the corrected behavior *and* the invariants it must not move: +the verdict, the severity, the check id, and the fact that a coding agent still +cannot self-approve the adoption. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +from agents_shipgate.checks import verify_policy +from agents_shipgate.cli.verify.orchestrator import ( + _manifest_introduced, + _self_approval_note, + run_verify, +) +from agents_shipgate.config.loader import load_manifest +from agents_shipgate.core.context import ScanContext +from agents_shipgate.core.domain import Agent +from agents_shipgate.schemas.verification import VerificationContext +from agents_shipgate.schemas.verifier import VerifierCapabilityReview + +REPO_ROOT = Path(__file__).resolve().parent.parent +SAMPLE = REPO_ROOT / "samples" / "support_refund_agent" +SAMPLE_CONFIG = Path("samples/support_refund_agent/shipgate.yaml") + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def _repo_adopting_shipgate(tmp_path: Path) -> Path: + """A repo whose first commit has no manifest and whose second adds one.""" + + repo = tmp_path / "repo" + sample_dst = repo / "samples" / "support_refund_agent" + sample_dst.parent.mkdir(parents=True) + shutil.copytree(SAMPLE, sample_dst) + manifest = sample_dst / "shipgate.yaml" + held_back = manifest.read_text(encoding="utf-8") + manifest.unlink() + + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.test") + _git(repo, "config", "user.name", "Test User") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "before shipgate") + + manifest.write_text(held_back, encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "adopt shipgate") + return repo + + +# --- the git-proved introduction signal ------------------------------------- + + +def test_missing_manifest_base_with_no_manifest_anywhere_is_an_adoption(tmp_path): + repo = _repo_adopting_shipgate(tmp_path) + assert ( + _manifest_introduced( + git_root=repo, + config_relative=SAMPLE_CONFIG, + base_status="missing_manifest", + base="HEAD~1", + head="HEAD", + ) + is True + ) + + +def test_moved_manifest_cannot_pass_itself_off_as_an_adoption(tmp_path): + """The dodge this signal has to survive. + + A PR that moves the manifest to a new path *and* loosens it also finds + nothing at the configured path on the base. Only "the base carries no + manifest under any name" separates adoption from relocation. + """ + + repo = _repo_adopting_shipgate(tmp_path) + (repo / "samples" / "support_refund_agent" / "config").mkdir() + _git( + repo, + "mv", + "samples/support_refund_agent/shipgate.yaml", + "samples/support_refund_agent/config/shipgate.yaml", + ) + _git(repo, "commit", "-m", "move the manifest") + + assert ( + _manifest_introduced( + git_root=repo, + config_relative=Path("samples/support_refund_agent/config/shipgate.yaml"), + base_status="missing_manifest", + base="HEAD~1", + head="HEAD", + ) + is False + ) + + +def test_uncommitted_new_manifest_is_an_adoption(tmp_path): + """The local case: `init --write` then `verify`, nothing committed yet.""" + + repo = _repo_adopting_shipgate(tmp_path) + _git(repo, "reset", "--soft", "HEAD~1") + + assert ( + _manifest_introduced( + git_root=repo, + config_relative=SAMPLE_CONFIG, + base_status="not_requested", + base=None, + head="HEAD", + ) + is True + ) + + +def test_adopted_repo_is_never_an_adoption(tmp_path): + repo = _repo_adopting_shipgate(tmp_path) + assert ( + _manifest_introduced( + git_root=repo, + config_relative=SAMPLE_CONFIG, + base_status="not_requested", + base=None, + head="HEAD", + ) + is False + ) + + +def test_unknown_base_is_never_an_adoption(tmp_path): + """Absence of evidence is not evidence of absence.""" + + repo = _repo_adopting_shipgate(tmp_path) + for status in ("ref_missing", "archive_failed", "succeeded", "diff_from_provided"): + assert ( + _manifest_introduced( + git_root=repo, + config_relative=SAMPLE_CONFIG, + base_status=status, # type: ignore[arg-type] + base="HEAD~1", + head="HEAD", + ) + is False + ), status + + +# --- the fail-safe finding --------------------------------------------------- + + +def _scan_context(*, manifest_introduced: bool, changed=("shipgate.yaml",)): + return ScanContext( + manifest=load_manifest(SAMPLE / "shipgate.yaml"), + agent=Agent(id="agent:test/test", name="test"), + tools=[], + config_path=Path("shipgate.yaml"), + verification=VerificationContext( + changed_files=list(changed), + manifest_introduced=manifest_introduced, + ), + ) + + +def test_adoption_fail_safe_keeps_the_check_id_and_severity(): + """Honesty changes; the gate does not.""" + + adopting = verify_policy.run(_scan_context(manifest_introduced=True)) + modifying = verify_policy.run(_scan_context(manifest_introduced=False)) + + assert len(adopting) == len(modifying) == 1 + assert adopting[0].check_id == modifying[0].check_id == verify_policy.CHECK_ID + assert adopting[0].severity == modifying[0].severity == "medium" + + assert adopting[0].evidence["kind"] == "manifest_introduced" + assert modifying[0].evidence["kind"] == "base_snapshot_unavailable" + assert "weaken" not in adopting[0].title.lower() + assert "cannot be proven safe" not in adopting[0].title + + +def test_adoption_fail_safe_still_needs_a_touched_policy_surface(): + context = _scan_context(manifest_introduced=True, changed=("README.md",)) + assert verify_policy.run(context) == [] + + +# --- headline and fix_task copy --------------------------------------------- + + +def _review(*, policy_weakened=False, trust_root_touched=False): + return VerifierCapabilityReview( + policy_weakened=policy_weakened, + trust_root_touched=trust_root_touched, + ) + + +def test_adoption_headline_replaces_the_weakening_claim(): + review = _review(policy_weakened=True, trust_root_touched=True) + adopting = _self_approval_note(review, manifest_introduced=True) + modifying = _self_approval_note(review, manifest_introduced=False) + + assert adopting is not None and modifying is not None + assert "introduces Agents Shipgate" in adopting + assert "human-reviewed PR" in adopting + assert "weakens the release policy" in modifying + + +def test_adoption_note_fires_in_exactly_the_same_cases_as_before(): + """``_self_approval_note`` doubles as the "a trust root is in play" probe. + + ``_can_merge_without_human`` raises on a passed decision that carries one, + so the introduction wording must not change *whether* a note exists — only + what it says. + """ + + for review in ( + None, + _review(), + _review(policy_weakened=True), + _review(trust_root_touched=True), + _review(policy_weakened=True, trust_root_touched=True), + ): + introduced = _self_approval_note(review, manifest_introduced=True) + plain = _self_approval_note(review, manifest_introduced=False) + assert (introduced is None) == (plain is None) + + +# --- end to end -------------------------------------------------------------- + + +def _run_verify(repo: Path, *, base: str | None, head: str): + verifier, _report, _exit = run_verify( + workspace=repo, + config=SAMPLE_CONFIG, + base=base, + head=head, + archive_head=True, + out=repo / "agents-shipgate-reports", + ci_mode="advisory", + fail_on=None, + baseline=None, + baseline_mode="new-findings", + diff_from=None, + policy_packs=None, + plugins_enabled=False, + strict_plugins=False, + suggest_patches=False, + no_heuristics=False, + verbose=False, + ) + return verifier + + +def test_first_adoption_pr_is_honest_and_actionable(tmp_path): + repo = _repo_adopting_shipgate(tmp_path) + verifier = _run_verify(repo, base="HEAD~1", head="HEAD") + + assert verifier.base_status == "missing_manifest" + assert verifier.headline is not None + assert "introduces Agents Shipgate" in verifier.headline + + # Fail-closed, unchanged: adoption is still a human decision. + assert verifier.can_merge_without_human is False + assert verifier.control.state == "human_review_required" + + # A missing-manifest base used to be classified as a safe recovery, which + # nulled the fix_task: the run said "stop" and named nothing to do. + fix_task = verifier.fix_task + assert fix_task is not None + assert fix_task.actor == "human" + assert [i for i in fix_task.instructions if "adopts Agents Shipgate" in i] + assert [r for r in fix_task.allowed_repairs if r.id == "adopt_shipgate_manifest"] + + payload = json.loads( + (repo / "agents-shipgate-reports" / "report.json").read_text("utf-8") + ) + weakening = [ + finding + for finding in payload["findings"] + if finding["check_id"] == verify_policy.CHECK_ID + ] + assert weakening, "the adoption still records a policy finding" + assert weakening[0]["evidence"]["kind"] == "manifest_introduced" + + +def test_adopted_repo_gets_the_ordinary_wording_back(tmp_path): + repo = _repo_adopting_shipgate(tmp_path) + manifest = repo / "samples" / "support_refund_agent" / "shipgate.yaml" + manifest.write_text( + manifest.read_text("utf-8") + "\n# edited after adoption\n", + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "edit the manifest") + + verifier = _run_verify(repo, base="HEAD~1", head="HEAD") + assert verifier.base_status == "succeeded" + assert verifier.headline is not None + assert "introduces Agents Shipgate" not in verifier.headline diff --git a/tests/test_fix_task_contract.py b/tests/test_fix_task_contract.py index 16e5c4a5..d2f9aadd 100644 --- a/tests/test_fix_task_contract.py +++ b/tests/test_fix_task_contract.py @@ -715,3 +715,69 @@ def test_insufficient_evidence_without_inventory_gives_generic_remedy() -> None: joined = " ".join(task.instructions) assert "explicit local tool inventory" in joined assert "re-run verify" in joined + + +# --- first adoption: the same routing, honest wording ----------------------- + + +def _trust_root_report(): + f = _finding("F1", requires_human_review=True, autofix_safe=False) + return _report(decision="review_required", findings=[f], review_items=[f]) + + +def test_manifest_modification_keeps_the_weakening_wording() -> None: + task = build_fix_task( + _trust_root_report(), + merge_verdict="human_review_required", + capability_review=_review(policy_weakened=True, trust_root_touched=True), + base_ref="origin/main", + head_ref="HEAD", + manifest_introduced=False, + ) + + assert task is not None and task.actor == "human" + joined = " ".join(task.instructions) + assert "cannot self-approve" in joined + assert "adopts Agents Shipgate" not in joined + assert {"review_policy_weakening", "review_trust_root"} <= { + r.id for r in task.allowed_repairs + } + + +def test_first_adoption_replaces_the_weakening_wording() -> None: + """Adoption is not weakening: one honest instruction, not two wrong ones.""" + + task = build_fix_task( + _trust_root_report(), + merge_verdict="human_review_required", + capability_review=_review(policy_weakened=True, trust_root_touched=True), + base_ref="origin/main", + head_ref="HEAD", + manifest_introduced=True, + ) + + assert task is not None + # Routing is untouched: adoption is still an authority escalation. + assert task.actor == "human" and task.safe_to_attempt is False + joined = " ".join(task.instructions) + assert "adopts Agents Shipgate" in joined + assert "cannot self-approve" not in joined + repair_ids = {r.id for r in task.allowed_repairs} + assert "adopt_shipgate_manifest" in repair_ids + assert not {"review_policy_weakening", "review_trust_root"} & repair_ids + + +def test_adoption_wording_needs_a_trust_root_signal() -> None: + """`manifest_introduced` alone must not invent an adoption instruction.""" + + task = build_fix_task( + _trust_root_report(), + merge_verdict="human_review_required", + capability_review=_review(), + base_ref="origin/main", + head_ref="HEAD", + manifest_introduced=True, + ) + + assert task is not None + assert "adopts Agents Shipgate" not in " ".join(task.instructions) From 7bc0979035a8df87276cd1ed59e2f66239e19a18 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Tue, 28 Jul 2026 16:32:34 -0700 Subject: [PATCH 02/12] Detect the calling agent, and keep the agent-mode error promise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check --agent` defaulted to codex and never read the harness variables Shipgate already consults to switch agent mode on, so every Claude Code and Cursor run labelled its result and audit id `codex`. One table now defines both the hints and the actor they identify, so the two cannot drift apart. An explicit --agent still wins; a plain shell still gets codex. The skills and slash command promise that with AGENTS_SHIPGATE_AGENT_MODE=1 a failing command emits a structured next_action line on stderr. check, audit, and preflight printed prose only — an agent that mis-invoked them had to parse English or guess. Each error path now emits the line with its exit_code, matching what scan and verify already do, and preflight no longer lets an unexpected failure escape as a bare traceback. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 +++ src/agents_shipgate/cli/agent_mode.py | 37 ++++++- src/agents_shipgate/cli/check.py | 45 +++++++-- src/agents_shipgate/cli/host_audit.py | 60 +++++++++--- src/agents_shipgate/cli/main.py | 2 +- src/agents_shipgate/cli/preflight.py | 70 ++++++++++++- tests/test_agent_mode.py | 135 +++++++++++++++++++++++++- 7 files changed, 327 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec3c773..19256e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ correction locally, keyed on the diff carrying exactly one manifest record and that record being a plain addition. Adoption remains a human decision — only the claim about what happened changed. +- **`check` detects which agent is running it.** `--agent` defaulted to `codex` + and never consulted the harness variables Shipgate already reads to switch on + agent mode, so every Claude Code and Cursor run recorded the wrong actor in + its result and audit id. Detection now comes from one table that also defines + those hints, so the two cannot drift; an explicit `--agent` still wins, and a + plain shell still gets `codex`. +- **`check`, `audit`, and `preflight` honor the agent-mode error contract.** + The skills and slash command tell agents that with + `AGENTS_SHIPGATE_AGENT_MODE=1` a failing command emits a structured + `next_action` line on stderr; these three printed prose only, so an agent that + mis-invoked them had to parse English. Each error path now emits the line with + its `exit_code`, and `preflight` no longer lets an unexpected failure escape + as a bare traceback. - **A way out of `insufficient_evidence` (#292).** An abstention was unactionable in practice: the decision engine generated the exact manifest diff --git a/src/agents_shipgate/cli/agent_mode.py b/src/agents_shipgate/cli/agent_mode.py index 08da5668..e2671ed7 100644 --- a/src/agents_shipgate/cli/agent_mode.py +++ b/src/agents_shipgate/cli/agent_mode.py @@ -8,11 +8,21 @@ AGENT_MODE_ENV_VAR = "AGENTS_SHIPGATE_AGENT_MODE" -# Environment variables that coding-agent harnesses export in every shell -# they spawn. Their presence auto-enables agent mode so agents get -# structured output without remembering to set AGENTS_SHIPGATE_AGENT_MODE. -# Claude Code sets CLAUDECODE=1; Cursor sets CURSOR_TRACE_ID. -AGENT_ENV_HINTS = ("CLAUDECODE", "CURSOR_TRACE_ID") +# Environment variables that coding-agent harnesses export in every shell they +# spawn, and which agent each one identifies. Their presence auto-enables agent +# mode so agents get structured output without remembering to set +# AGENTS_SHIPGATE_AGENT_MODE, and it names the actor for commands that record +# one. Claude Code sets CLAUDECODE=1; Cursor sets CURSOR_TRACE_ID. +_ACTOR_BY_ENV_HINT: tuple[tuple[str, str], ...] = ( + ("CLAUDECODE", "claude-code"), + ("CURSOR_TRACE_ID", "cursor"), +) + +AGENT_ENV_HINTS = tuple(hint for hint, _actor in _ACTOR_BY_ENV_HINT) + +# The actor assumed when the environment says nothing. Kept as the historical +# default so a plain shell behaves exactly as before. +DEFAULT_ACTOR = "codex" _TRUTHY = {"1", "true", "yes", "on"} _FALSY = {"0", "false", "no", "off"} @@ -35,6 +45,23 @@ def is_agent_mode(env: Mapping[str, str] | None = None) -> bool: return any(source.get(hint) for hint in AGENT_ENV_HINTS) +def detect_actor(env: Mapping[str, str] | None = None) -> str: + """Which coding agent is running this command. + + ``check`` records the actor in its result and audit id, so a Claude Code + run that defaults to ``codex`` mislabels every row it writes. The same + harness variables that switch on agent mode also identify the harness, so + detection costs nothing extra. Falls back to :data:`DEFAULT_ACTOR` when the + environment says nothing; an explicit ``--agent`` always wins. + """ + + source = os.environ if env is None else env + for hint, actor in _ACTOR_BY_ENV_HINT: + if source.get(hint): + return actor + return DEFAULT_ACTOR + + def emit_agent_mode_error( error_kind: str, *, diff --git a/src/agents_shipgate/cli/check.py b/src/agents_shipgate/cli/check.py index b214de61..8366a99e 100644 --- a/src/agents_shipgate/cli/check.py +++ b/src/agents_shipgate/cli/check.py @@ -5,6 +5,7 @@ import typer +from agents_shipgate.cli.agent_mode import detect_actor, emit_agent_mode_error from agents_shipgate.cli.agent_result import ( agent_result_json, build_agent_boundary_result, @@ -18,10 +19,13 @@ def check( - agent: str = typer.Option( - "codex", + agent: str | None = typer.Option( + None, "--agent", - help="Agent runtime to check: codex, claude-code, or cursor.", + help=( + "Agent runtime to check: codex, claude-code, or cursor. Detected " + "from the environment when omitted; codex when undetectable." + ), ), diff: str | None = typer.Option( None, @@ -66,20 +70,41 @@ def check( ) -> None: """Run the agent-native local boundary check.""" + # The actor lands in the result and in the audit id, so an undetected + # harness mislabels every row it writes. An explicit flag always wins. + agent = agent or detect_actor() + if agent not in {"codex", "claude-code", "cursor"}: - typer.echo("--agent must be one of: codex, claude-code, cursor.", err=True) + message = "--agent must be one of: codex, claude-code, cursor." + typer.echo(message, err=True) + emit_agent_mode_error( + "config_error", + message=message, + exit_code=2, + next_action="Re-run shipgate check with --agent codex, claude-code, or cursor.", + ) raise typer.Exit(2) if format_ == "agent-json": - typer.echo( + message = ( "--format agent-json was removed in the 0.14.0 contract cleanup. " - "Use --format agent-boundary-json.", - err=True, + "Use --format agent-boundary-json." + ) + typer.echo(message, err=True) + emit_agent_mode_error( + "config_error", + message=message, + exit_code=2, + next_action="Re-run shipgate check with --format agent-boundary-json.", ) raise typer.Exit(2) if format_ not in {"agent-boundary-json", "codex-boundary-json"}: - typer.echo( - "--format must be 'agent-boundary-json' or 'codex-boundary-json'.", - err=True, + message = "--format must be 'agent-boundary-json' or 'codex-boundary-json'." + typer.echo(message, err=True) + emit_agent_mode_error( + "config_error", + message=message, + exit_code=2, + next_action="Re-run shipgate check with --format agent-boundary-json.", ) raise typer.Exit(2) try: diff --git a/src/agents_shipgate/cli/host_audit.py b/src/agents_shipgate/cli/host_audit.py index a41f8b2f..d282454b 100644 --- a/src/agents_shipgate/cli/host_audit.py +++ b/src/agents_shipgate/cli/host_audit.py @@ -7,6 +7,7 @@ import typer +from agents_shipgate.cli.agent_mode import emit_agent_mode_error from agents_shipgate.core.host_grants import ( DEFAULT_BASELINE_FILE, HOST_GRANTS_INVENTORY_SCHEMA_VERSION, @@ -26,6 +27,25 @@ ) +def _config_error(message: str, *, next_action: str) -> typer.Exit: + """Report flag misuse on both channels and return the exit to raise. + + Agent-facing docs promise that with ``AGENTS_SHIPGATE_AGENT_MODE=1`` a + failing command emits a structured ``next_action`` line on stderr. ``audit`` + printed prose only, so an agent that mis-invoked it had to parse English or + guess. + """ + + typer.echo(message, err=True) + emit_agent_mode_error( + "config_error", + message=message, + exit_code=2, + next_action=next_action, + ) + return typer.Exit(2) + + def audit( workspace: Path = typer.Option( Path("."), @@ -82,24 +102,26 @@ def audit( """Zero-config, read-only audits. Currently supports --host.""" if not host: - typer.echo( + raise _config_error( "Nothing to audit: pass --host for the host-capability inventory.", - err=True, + next_action="Re-run as `agents-shipgate audit --host`.", ) - raise typer.Exit(2) if scope not in {"repository", "local-static"}: - typer.echo("--scope must be 'repository' or 'local-static'.", err=True) - raise typer.Exit(2) + raise _config_error( + "--scope must be 'repository' or 'local-static'.", + next_action="Re-run audit with --scope repository or --scope local-static.", + ) if save_baseline and drift: - typer.echo( + raise _config_error( "--save-baseline and --drift are mutually exclusive: record the " "acknowledged state or compare against it, not both.", - err=True, + next_action="Re-run audit with either --save-baseline or --drift, not both.", ) - raise typer.Exit(2) if fail_on_drift and not drift: - typer.echo("--fail-on-drift requires --drift.", err=True) - raise typer.Exit(2) + raise _config_error( + "--fail-on-drift requires --drift.", + next_action="Re-run audit with --drift, or drop --fail-on-drift.", + ) inventory_scope = "local_static" if scope == "local-static" else "repository" inventory = host_audit_inventory(workspace, scope=inventory_scope) @@ -113,8 +135,13 @@ def audit( try: payload = build_host_grants_baseline(inventory) except ValueError as exc: - typer.echo(str(exc), err=True) - raise typer.Exit(2) from exc + raise _config_error( + str(exc), + next_action=( + "Resolve the incomplete host inventory, then re-run " + "`agents-shipgate audit --host --save-baseline`." + ), + ) from exc text = json.dumps(payload, indent=2, sort_keys=True) + "\n" if resolved_baseline.is_file() and resolved_baseline.read_text( encoding="utf-8" @@ -145,8 +172,13 @@ def audit( try: baseline = load_host_grants_baseline(resolved_baseline) except ValueError as exc: - typer.echo(str(exc), err=True) - raise typer.Exit(2) from exc + raise _config_error( + str(exc), + next_action=( + "Record a baseline with `agents-shipgate audit --host " + "--save-baseline`, or point --baseline-file at a valid one." + ), + ) from exc payload = build_host_drift_payload( baseline=baseline, inventory=inventory, diff --git a/src/agents_shipgate/cli/main.py b/src/agents_shipgate/cli/main.py index c4fbf498..1d58f907 100644 --- a/src/agents_shipgate/cli/main.py +++ b/src/agents_shipgate/cli/main.py @@ -62,7 +62,7 @@ )(_detect_command) app.command( "check", - help="Run the fast local agent loop and emit codex boundary JSON.", + help="Run the fast local agent-boundary check and emit its JSON result.", )(_check_command) app.command( "preflight", diff --git a/src/agents_shipgate/cli/preflight.py b/src/agents_shipgate/cli/preflight.py index aeb732f1..d5ed9f1c 100644 --- a/src/agents_shipgate/cli/preflight.py +++ b/src/agents_shipgate/cli/preflight.py @@ -8,6 +8,7 @@ import typer from pydantic import ValidationError +from agents_shipgate.cli.agent_mode import emit_agent_mode_error from agents_shipgate.core.codex_boundary import parse_unified_diff from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError from agents_shipgate.core.logging import configure_logging @@ -21,6 +22,24 @@ ) +def _agent_mode_exit( + error_kind: str, + exc: BaseException, + *, + exit_code: int, + next_action: str, +) -> typer.Exit: + """Emit the structured agent-mode error line and return the exit to raise.""" + + emit_agent_mode_error( + error_kind, + message=str(exc), + exit_code=exit_code, + next_action=next_action, + ) + return typer.Exit(exit_code) + + def preflight( workspace: Path = typer.Option( Path("."), @@ -110,16 +129,59 @@ def preflight( ) except ConfigError as exc: typer.echo(f"Config error: {exc}", err=True) - raise typer.Exit(2) from exc + raise _agent_mode_exit( + "config_error", + exc, + exit_code=2, + next_action=( + "Fix the manifest or flag value named in the error, then re-run " + "`agents-shipgate preflight`." + ), + ) from exc except InputParseError as exc: typer.echo(f"Input parsing error: {exc}", err=True) - raise typer.Exit(3) from exc + raise _agent_mode_exit( + "input_parse_error", + exc, + exit_code=3, + next_action=( + "Correct the diff, changed-files list, or request JSON named in " + "the error, then re-run `agents-shipgate preflight`." + ), + ) from exc except AgentsShipgateError as exc: typer.echo(f"Agents Shipgate error: {exc}", err=True) - raise typer.Exit(4) from exc + raise _agent_mode_exit( + "shipgate_error", + exc, + exit_code=4, + next_action="Resolve the error above, then re-run `agents-shipgate preflight`.", + ) from exc except OSError as exc: typer.echo(f"Input error: {exc}", err=True) - raise typer.Exit(3) from exc + raise _agent_mode_exit( + "input_parse_error", + exc, + exit_code=3, + next_action=( + "Make the input file readable at the path given, then re-run " + "`agents-shipgate preflight`." + ), + ) from exc + except Exception as exc: # noqa: BLE001 - agents must never see a bare traceback. + # Every other command reports unexpected failures on the agent channel; + # preflight let them escape as a traceback, which an agent cannot route + # on. Prose still goes to stderr and the exit code is unchanged. + typer.echo(f"Agents Shipgate internal error: {exc}", err=True) + raise _agent_mode_exit( + "internal_error", + exc, + exit_code=4, + next_action=( + "Report this failure with the command line above; preflight " + "could not complete." + ), + ) from exc payload = result.model_dump(mode="json") if json_output: diff --git a/tests/test_agent_mode.py b/tests/test_agent_mode.py index 918df7c8..978c0c8d 100644 --- a/tests/test_agent_mode.py +++ b/tests/test_agent_mode.py @@ -19,7 +19,13 @@ import pytest from typer.testing import CliRunner -from agents_shipgate.cli.agent_mode import emit_agent_mode_error, is_agent_mode +from agents_shipgate.cli.agent_mode import ( + AGENT_ENV_HINTS, + DEFAULT_ACTOR, + detect_actor, + emit_agent_mode_error, + is_agent_mode, +) from agents_shipgate.cli.main import app from agents_shipgate.cli.verify.command import _resolve_verify_format from agents_shipgate.core.errors import ConfigError @@ -299,3 +305,130 @@ def test_verify_without_agent_environment_defaults_to_text( assert result.exit_code == 0, result.output assert result.output.startswith("Agents Shipgate verify:") + + +# --- detect_actor ----------------------------------------------------------- + + +def test_detect_actor_defaults_to_codex() -> None: + assert detect_actor({}) == "codex" + + +def test_detect_actor_recognizes_claude_code() -> None: + assert detect_actor({"CLAUDECODE": "1"}) == "claude-code" + + +def test_detect_actor_recognizes_cursor() -> None: + assert detect_actor({"CURSOR_TRACE_ID": "abc123"}) == "cursor" + + +def test_detect_actor_ignores_empty_hint_values() -> None: + assert detect_actor({"CLAUDECODE": ""}) == "codex" + + +def test_agent_env_hints_stay_in_sync_with_actor_detection() -> None: + """One table drives both, so agent mode and the actor cannot disagree.""" + + assert AGENT_ENV_HINTS == ("CLAUDECODE", "CURSOR_TRACE_ID") + for hint in AGENT_ENV_HINTS: + assert is_agent_mode({hint: "x"}) is True + assert detect_actor({hint: "x"}) != DEFAULT_ACTOR + + +def test_check_labels_its_result_with_the_detected_agent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A Claude Code run used to record `codex` in the result and audit id.""" + + monkeypatch.setenv("CLAUDECODE", "1") + result = runner.invoke(app, ["check", "--workspace", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["agent"] == "claude-code" + + +def test_check_explicit_agent_flag_beats_detection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CLAUDECODE", "1") + result = runner.invoke( + app, ["check", "--workspace", str(tmp_path), "--agent", "codex"] + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["agent"] == "codex" + + +# --- structured errors on the commands that had none ------------------------ + + +def _agent_mode_error(result) -> dict: + """The last stderr line of an agent-mode run, parsed.""" + + lines = [line for line in result.output.splitlines() if line.startswith("{")] + assert lines, result.output + return json.loads(lines[-1]) + + +def test_check_rejects_an_unknown_format_on_the_agent_channel( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + result = runner.invoke( + app, ["check", "--workspace", str(tmp_path), "--format", "nope"] + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + assert payload["error"] == "config_error" + assert payload["exit_code"] == 2 + assert "agent-boundary-json" in payload["next_action"] + + +def test_check_rejects_an_unknown_agent_on_the_agent_channel( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + result = runner.invoke( + app, ["check", "--workspace", str(tmp_path), "--agent", "nope"] + ) + + assert result.exit_code == 2 + assert _agent_mode_error(result)["error"] == "config_error" + + +def test_audit_without_host_reports_a_next_action( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + result = runner.invoke(app, ["audit"]) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + assert payload["error"] == "config_error" + assert "--host" in payload["next_action"] + + +def test_preflight_config_error_reports_a_next_action( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + (tmp_path / "shipgate.yaml").write_text( + 'version: "1"\nproject:\n name: [broken\n', encoding="utf-8" + ) + result = runner.invoke(app, ["preflight", "--workspace", str(tmp_path)]) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + assert payload["error"] == "config_error" + assert payload["exit_code"] == 2 + + +def test_preflight_silent_without_agent_mode(tmp_path: Path) -> None: + (tmp_path / "shipgate.yaml").write_text( + 'version: "1"\nproject:\n name: [broken\n', encoding="utf-8" + ) + result = runner.invoke(app, ["preflight", "--workspace", str(tmp_path)]) + + assert result.exit_code == 2 + assert not [line for line in result.output.splitlines() if line.startswith("{")] From 81083329b0daf6455460a5d3192f9ed3fdc54f77 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Tue, 28 Jul 2026 16:57:41 -0700 Subject: [PATCH 03/12] Report the verify the agent already ran instead of repeating it (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A governed turn verified twice. The coding agent ran verify because the previous result's next_action told it to, and the Stop hook then ran an identical verify because it had no way to know that happened — a second or more of every turn spent recomputing a result nobody had questioned. verify now records finished worktree runs in the git directory, next to the last_verified_signature cache the hook already keeps, and the hook reports that run when it can prove the repository has not moved. The first attempt at this was reverted for two fail-opens, and the design is shaped by both. It reads nothing from the workspace: the reports directory is writable by the agent under evaluation, so a verdict read from it is a verdict the subject can author. And identity is a content digest — HEAD's tree plus the blob hash of every changed and untracked file — not diff text. Diff text depends on how it was asked for, and an untracked file's content never appears in `git diff` at all, so a digest over diff text calls two different worktrees the same. The commit-after-verify stale pass that sank the first attempt is now impossible by construction, and is a regression test. Config, effective CI mode, base ref and the commit it resolved to must all match too; a forced --ci-mode rewrites the manifest's mode for the run, and the policy-weakening check compares that value against the base. Any mismatch, an unreadable record, or an identity that cannot be established re-verifies. The hook cannot import Shipgate — it is a standalone rendered script — so the digest exists twice. A test runs both implementations against a live repository and compares them; if they ever drift the digests stop matching and the hook simply verifies for itself. Forging the record can only make an advisory hook echo a forged state, which forging last_verified_signature already allowed. PR-time verify and CI read none of it. Closes #295 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 + docs/agents/use-with-claude-code.md | 10 + src/agents_shipgate/cli/install_hooks.py | 145 +++++++++++- src/agents_shipgate/cli/verify/command.py | 32 +++ src/agents_shipgate/cli/verify/git.py | 68 ++++++ src/agents_shipgate/cli/verify/hook_state.py | 111 +++++++++ tests/test_adapter_static_only.py | 4 +- tests/test_install_hooks.py | 235 +++++++++++++++++++ 8 files changed, 612 insertions(+), 6 deletions(-) create mode 100644 src/agents_shipgate/cli/verify/hook_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 19256e07..980b920f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,19 @@ mis-invoked them had to parse English. Each error path now emits the line with its `exit_code`, and `preflight` no longer lets an unexpected failure escape as a bare traceback. +- **One verify per turn (#295).** A governed turn verified twice: the coding + agent ran `verify` because the previous result told it to, and the Stop hook + then ran an identical `verify` because it had no way to know. `verify` now + records finished worktree runs in the git directory, next to the signature + cache the hook already keeps, and the hook reports that run instead of + repeating it. Reuse requires every input to match — config, effective CI + mode, base ref *and* the commit it resolved to, and a content digest of the + working tree covering HEAD's tree plus every changed and untracked file, so + an untracked edit that never appears in `git diff` still invalidates it. A + commit changes the digest by construction, which is the stale-pass the first + attempt at this was reverted for; it is now a regression test. Nothing is + read from the workspace, the reused result routes through the same switch a + fresh one does, and any mismatch or doubt re-verifies. - **A way out of `insufficient_evidence` (#292).** An abstention was unactionable in practice: the decision engine generated the exact manifest diff --git a/docs/agents/use-with-claude-code.md b/docs/agents/use-with-claude-code.md index 3f5fc0df..5f6dd0e4 100644 --- a/docs/agents/use-with-claude-code.md +++ b/docs/agents/use-with-claude-code.md @@ -245,6 +245,16 @@ Three hooks are installed: continuation would contradict. Unparseable or unrecognized verifier output warns loudly, is never cached, and is never treated as passing. + When the agent already ran `verify` itself — which is what the previous + turn's `next_action` asked for — the hook reports that run instead of + repeating it. Reuse requires every input to match, including a content + digest of the working tree (HEAD's tree plus the exact content of every + changed and untracked file), so a commit, an edit, a different `--config`, + or a base ref that moved all re-verify. The record lives in the git + directory next to the hook's own signature cache — never in the workspace, + which the agent under evaluation can write — and it carries no verdict a + fresh run would not have produced. + Local setup failures such as a missing CLI or unavailable base ref are surfaced as context, not as the release gate. CI remains authoritative, and changing the hook files or other Shipgate trust roots is itself diff --git a/src/agents_shipgate/cli/install_hooks.py b/src/agents_shipgate/cli/install_hooks.py index b8f47cd5..d79de0e2 100644 --- a/src/agents_shipgate/cli/install_hooks.py +++ b/src/agents_shipgate/cli/install_hooks.py @@ -434,6 +434,9 @@ def _hook_script_text() -> str: VERIFY_TIMEOUT_SECONDS = 170 UNTRACKED_DIFF_CONTENT_LIMIT_BYTES = 131072 +# Mirror of agents_shipgate.cli.verify.git._MAX_IDENTITY_PATHS. Past this many +# changed paths the reuse digest is refused and the hook verifies for itself. +MAX_IDENTITY_PATHS = 500 _MAX_REMEMBERED_SURFACES = 256 _MAX_REMEMBERED_SESSIONS = 8 # Host permission modes that answer a hook's permission request without asking @@ -753,6 +756,20 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in ) base = "" + reused = _reusable_verify(root, args, base) + if reused is not None: + return _route_control( + decision=str(reused.get("decision") or "unknown"), + blockers=int(reused.get("blockers") or 0), + review_items=int(reused.get("review_items") or 0), + control=reused.get("control") or {}, + root=root, + args=args, + signature=signature, + base_note=base_note, + stop_hook_active=stop_hook_active, + ) + command = [ *_cli(), "verify", @@ -821,10 +838,32 @@ def _route_verify_result( base_note: str, stop_hook_active: bool, ) -> int: - 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 []) - control = verifier.get("control") if isinstance(verifier.get("control"), dict) else {} + release = verifier.get("release_decision") or {} + return _route_control( + decision=release.get("decision") or "unknown", + blockers=len(release.get("blockers") or []), + review_items=len(release.get("review_items") or []), + control=verifier.get("control") if isinstance(verifier.get("control"), dict) else {}, + root=root, + args=args, + signature=signature, + base_note=base_note, + stop_hook_active=stop_hook_active, + ) + + +def _route_control( + *, + decision: str, + blockers: int, + review_items: int, + control: dict[str, Any], + root: Path, + args: argparse.Namespace, + signature: str, + base_note: str, + stop_hook_active: bool, +) -> int: state = control.get("state") summary = f"decision={decision}, blockers={blockers}, review_items={review_items}" @@ -1070,6 +1109,104 @@ def _diff_context(root: Path, revspec: str) -> tuple[list[str], str] | None: return paths, body.stdout +def _worktree_identity(root: Path) -> str | None: + """A digest of the working-tree state a verify run would read. + + Must stay byte-compatible with + ``agents_shipgate.cli.verify.git.worktree_identity`` — the CLI records a + finished run under the digest it computed, and this hook only reuses that + run when its own digest matches. A test compares the two implementations + on a live repository. If they ever drift, the digests stop matching and + the hook simply verifies for itself, which is the behavior reuse replaced. + + ``None`` means identity could not be established. It never means + "unchanged". + """ + + head = _run_git(root, ["rev-parse", "HEAD^{tree}"]) + if head is None or head.returncode != 0: + return None + changed = _run_git(root, ["diff", "HEAD", "--name-only"]) + untracked = _run_git(root, ["ls-files", "--others", "--exclude-standard"]) + if changed is None or untracked is None: + return None + if changed.returncode != 0 or untracked.returncode != 0: + return None + paths = sorted( + { + line.strip() + for line in (*changed.stdout.splitlines(), *untracked.stdout.splitlines()) + if line.strip() + } + ) + if len(paths) > MAX_IDENTITY_PATHS: + return None + present = [path for path in paths if (root / path).is_file()] + blobs: dict[str, str] = {} + if present: + hashed = _run_git(root, ["hash-object", "--", *present]) + if hashed is None or hashed.returncode != 0: + return None + lines = hashed.stdout.split() + if len(lines) != len(present): + return None + blobs = dict(zip(present, lines)) + payload = json.dumps( + { + "head_tree": head.stdout.strip(), + "files": [[path, blobs.get(path, "absent")] for path in paths], + }, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _reusable_verify(root: Path, args: argparse.Namespace, base: str) -> dict | None: + """The agent's own verify run, when it provably still describes this state. + + A governed turn asks the coding agent to run verify and then runs an + identical verify here, which changes nothing and costs a second of every + turn. The CLI records its finished worktree runs next to this hook's own + signature cache; this reuses one only when every input matches, including + a content digest of the working tree. A commit, an edit, a different + config, or a moved base all fail the comparison and re-verify. + """ + + record = _read_state(root).get("last_verify") + if not isinstance(record, dict): + return None + if record.get("config") != args.config: + return None + if (record.get("ci_mode") or "") != (args.ci_mode or ""): + return None + if (record.get("base_ref") or "") != base: + return None + if (record.get("head_ref") or "") != (args.head or ""): + return None + recorded_base = record.get("base_commit") or "" + if base: + # Same spelling as the CLI's commit_sha, so a tag and the commit it + # points at cannot compare unequal for spurious reasons. + resolved = _run_git(root, ["rev-parse", "--verify", "--quiet", f"{base}^{{commit}}"]) + current = ( + resolved.stdout.strip() + if resolved is not None and resolved.returncode == 0 + else "" + ) + if not recorded_base or recorded_base != current: + return None + elif recorded_base: + return None + identity = _worktree_identity(root) + if identity is None or identity != record.get("identity"): + return None + control = record.get("control") + if not isinstance(control, dict) or not control.get("state"): + return None + return record + + def _state_path(root: Path) -> Path | None: completed = _run_git(root, ["rev-parse", "--git-path", "agents-shipgate-hooks-state.json"]) if completed is None or completed.returncode != 0: diff --git a/src/agents_shipgate/cli/verify/command.py b/src/agents_shipgate/cli/verify/command.py index 7a8e98f7..ef316867 100644 --- a/src/agents_shipgate/cli/verify/command.py +++ b/src/agents_shipgate/cli/verify/command.py @@ -20,6 +20,7 @@ from agents_shipgate.schemas.diagnostics import NextAction from .git import ensure_git_workspace, staged_paths_under +from .hook_state import record_verify_for_hooks from .orchestrator import run_preview, run_verify logger = logging.getLogger(__name__) @@ -302,6 +303,37 @@ def verify( _warn_if_reports_staged(workspace, out) + if not preview and head is None and verifier.execution == "succeeded": + # Worktree runs only. A ``--head`` run evaluates a commit, which is not + # the state the Stop hook is looking at. + record_verify_for_hooks( + git_root=Path(verifier.workspace), + config=str(config), + # The *effective* mode, not the flag: a forced --ci-mode rewrites + # the manifest's mode for the run, and the policy-weakening check + # compares that value against the base, so two runs under different + # effective modes can reach different findings. + ci_mode=verifier.mode, + base_ref=verifier.base_ref, + head_ref=head, + decision=( + verifier.release_decision.decision + if verifier.release_decision is not None + else "unknown" + ), + blockers=( + len(verifier.release_decision.blockers) + if verifier.release_decision is not None + else 0 + ), + review_items=( + len(verifier.release_decision.review_items) + if verifier.release_decision is not None + else 0 + ), + control=verifier.control.model_dump(mode="json"), + ) + if stdout_format == "json": typer.echo(json.dumps(verifier.model_dump(mode="json"), indent=2)) else: diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 072e11b3..3cd8994a 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import json import os import re import subprocess @@ -368,6 +369,73 @@ def read_file_at_ref(workspace: Path, ref: str, path: Path) -> str | None: return result.stdout +# Cap on how many changed paths a worktree identity will cover. Past this the +# `git hash-object` argv gets unwieldy, and a change set that large is not the +# one-second rerun this identity exists to skip. Refusing identity is always +# safe: the caller just re-verifies. +_MAX_IDENTITY_PATHS = 500 + + +def worktree_identity(workspace: Path) -> str | None: + """A digest of the working-tree state a verify run would read. + + HEAD's tree, plus the exact content of every tracked-and-changed and + untracked file. Equal digests mean two runs looked at the same repository + state, which is what lets an already-computed verdict be reported for a + later moment instead of recomputed. + + Deliberately built from content hashes rather than diff text: the diff a + consumer produces depends on how it was asked for it (path filters, + untracked handling, size limits), and an untracked file's *content* does + not appear in `git diff HEAD` at all — so a digest over diff text would + call two different worktrees identical. + + Returns ``None`` when identity cannot be established (no commit yet, a git + failure, or too many changed paths). Callers must re-verify on ``None``; + they must never treat it as "unchanged". + """ + + head = _run_git(workspace, ["rev-parse", "HEAD^{tree}"], check=False) + if head.returncode != 0: + return None + changed = _run_git(workspace, ["diff", "HEAD", "--name-only"], check=False) + untracked = _run_git( + workspace, ["ls-files", "--others", "--exclude-standard"], check=False + ) + if changed.returncode != 0 or untracked.returncode != 0: + return None + paths = sorted( + { + line.strip() + for line in (*changed.stdout.splitlines(), *untracked.stdout.splitlines()) + if line.strip() + } + ) + if len(paths) > _MAX_IDENTITY_PATHS: + return None + present = [path for path in paths if (workspace / path).is_file()] + blobs: dict[str, str] = {} + if present: + hashed = _run_git(workspace, ["hash-object", "--", *present], check=False) + if hashed.returncode != 0: + return None + lines = hashed.stdout.split() + if len(lines) != len(present): + return None + blobs = dict(zip(present, lines, strict=True)) + payload = json.dumps( + { + "head_tree": head.stdout.strip(), + # "absent" covers a deleted tracked file, which has no blob but is + # every bit a change. + "files": [[path, blobs.get(path, "absent")] for path in paths], + }, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def paths_named_at_ref(workspace: Path, ref: str, names: frozenset[str]) -> list[str]: """Tracked paths at ``ref`` whose file name is one of ``names``. diff --git a/src/agents_shipgate/cli/verify/hook_state.py b/src/agents_shipgate/cli/verify/hook_state.py new file mode 100644 index 00000000..b82401c3 --- /dev/null +++ b/src/agents_shipgate/cli/verify/hook_state.py @@ -0,0 +1,111 @@ +"""Hand the installed Stop hook the verify run the agent already did. + +A governed turn verifies twice: the coding agent runs ``verify`` because the +previous result told it to, and then the Stop hook runs an identical ``verify`` +because it has no way to know that happened. The second run changes nothing and +costs the user a second or more of every turn. + +This records the finished run where the hook already keeps its own state — the +git directory, alongside ``last_verified_signature`` — so the hook can *report* +that result instead of recomputing it, but only when it can prove the repository +has not moved since (see :func:`agents_shipgate.cli.verify.git.worktree_identity` +and the identity fields below). + +Two rules this module exists to keep: + +- **Never the workspace.** An earlier attempt read ``verifier.json`` out of the + reports directory, which anything in the workspace can write — including the + agent whose work is being judged. The git directory is the same trust tier as + the signature cache the hook already keeps: forging it can only make an + advisory hook echo a forged state, exactly as forging + ``last_verified_signature`` already makes it skip verification. PR-time verify + and CI read none of this. +- **Never more trusted than fresh.** The record carries no verdict the hook + would not have gotten from a fresh run, and the hook routes it through the + same switch. On any mismatch — or any doubt — the hook re-verifies. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from agents_shipgate.cli.verify.git import commit_sha, git_path, worktree_identity + +STATE_FILENAME = "agents-shipgate-hooks-state.json" +RECORD_KEY = "last_verify" + + +def record_verify_for_hooks( + *, + git_root: Path, + config: str, + ci_mode: str, + base_ref: str | None, + head_ref: str | None, + decision: str, + blockers: int, + review_items: int, + control: dict[str, Any], +) -> None: + """Record a completed worktree verify for the installed Stop hook. + + Fail-soft by construction: any problem skips the record and the hook simply + runs its own verify, which is the behavior this replaces. + """ + + try: + identity = worktree_identity(git_root) + if identity is None: + return + record = { + "identity": identity, + "config": config, + "ci_mode": ci_mode, + "base_ref": base_ref or "", + # The base is a moving ref; pin what it pointed at. A base that + # advanced between the two runs is a different comparison. + "base_commit": (commit_sha(git_root, base_ref) or "") if base_ref else "", + "head_ref": head_ref or "", + "decision": decision, + "blockers": blockers, + "review_items": review_items, + "control": control, + } + state = _read_state(git_root) + state[RECORD_KEY] = record + _write_state(git_root, state) + except Exception: # noqa: BLE001 - an advisory optimization never fails a run. + return + + +def _state_file(git_root: Path) -> Path: + return git_path(git_root, STATE_FILENAME) + + +def _read_state(git_root: Path) -> dict[str, Any]: + path = _state_file(git_root) + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _write_state(git_root: Path, data: dict[str, Any]) -> None: + # Merge-then-atomic-replace, matching the hook: the same file carries the + # hook's verification signature and the session's approved surfaces, and a + # torn advisory cache is worse than a stale one. + path = _state_file(git_root) + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(data, indent=2, sort_keys=True) + "\n" + temp = path.with_name(f"{path.name}.{os.getpid()}.tmp") + temp.write_text(payload, encoding="utf-8") + os.replace(temp, path) + + +__all__ = ["RECORD_KEY", "STATE_FILENAME", "record_verify_for_hooks"] diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index 9f57ec60..c67e8331 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -291,7 +291,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="import:subprocess", - line=6, + line=7, snippet="import subprocess", rationale=( "Verify uses local git commands to resolve refs, collect diffs, " @@ -303,7 +303,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=634, + line=702, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index a70a3dad..d859be5d 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import json import os import subprocess @@ -14,6 +15,8 @@ render_or_install_hooks, ) from agents_shipgate.cli.main import app +from agents_shipgate.cli.verify.git import worktree_identity +from agents_shipgate.cli.verify.hook_state import record_verify_for_hooks REPO_ROOT = Path(__file__).resolve().parent.parent runner = CliRunner() @@ -882,3 +885,235 @@ def test_rendered_script_glob_matcher_matches_canonical_globbing( pattern, probe, ) + + +# --- one verify per turn (#295) --------------------------------------------- + + +def _hook_module(tmp_path: Path): + """Import the *rendered* hook script, so tests exercise what ships.""" + + script = _render_hook_script(tmp_path) + spec = importlib.util.spec_from_file_location("rendered_shipgate_hook", script) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_hook_and_cli_compute_the_same_worktree_identity(tmp_path: Path) -> None: + """Reuse is only sound while these two agree; drift must fail loudly. + + They cannot share code — the hook is a standalone rendered script with no + Shipgate imports — so the guarantee is this comparison. + """ + + module = _hook_module(tmp_path) + _init_repo(tmp_path) + + def both() -> tuple[str | None, str | None]: + return worktree_identity(tmp_path), module._worktree_identity(tmp_path) + + clean_cli, clean_hook = both() + assert clean_cli is not None + assert clean_cli == clean_hook + + # A tracked edit moves it. + (tmp_path / "shipgate.yaml").write_text("version: '0.2'\n", encoding="utf-8") + edited_cli, edited_hook = both() + assert edited_cli == edited_hook + assert edited_cli != clean_cli + + # So does an untracked file — whose content never appears in `git diff`. + (tmp_path / "new_tool.py").write_text("x = 1\n", encoding="utf-8") + added_cli, added_hook = both() + assert added_cli == added_hook + assert added_cli != edited_cli + + (tmp_path / "new_tool.py").write_text("x = 2\n", encoding="utf-8") + changed_cli, changed_hook = both() + assert changed_cli == changed_hook + assert changed_cli != added_cli, "untracked content must be part of identity" + + # And so does committing: same worktree, different HEAD tree. + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "commit", "-m", "commit the work"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + committed_cli, committed_hook = both() + assert committed_cli == committed_hook + assert committed_cli != changed_cli + + +def _record(tmp_path: Path, **overrides) -> None: + payload = { + "git_root": tmp_path, + "config": "shipgate.yaml", + "ci_mode": "advisory", + "base_ref": None, + "head_ref": None, + "decision": "review_required", + "blockers": 0, + "review_items": 1, + "control": { + "state": "agent_action_required", + "reason": "recorded by the agent's own verify", + "next_action": {"kind": "verify", "command": "agents-shipgate verify --json"}, + }, + } + payload.update(overrides) + record_verify_for_hooks(**payload) + + +def _verify_calls(log: Path) -> list[list[str]]: + if not log.exists(): + return [] + entries = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()] + return [entry for entry in entries if entry and entry[0] == "verify"] + + +def _stop_hook_with_record(tmp_path: Path) -> tuple[subprocess.CompletedProcess[str], Path]: + 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}" + result = subprocess.run( + [sys.executable, str(tmp_path / HOOK_SCRIPT_RELATIVE_PATH), "verify"], + input=json.dumps({"cwd": str(tmp_path)}), + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + check=False, + ) + return result, log + + +def _dirty_opted_in_repo(tmp_path: Path) -> None: + _stop_hook_workspace(tmp_path) + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "commit", "-m", "prompts"], cwd=tmp_path, check=True, capture_output=True + ) + (tmp_path / "prompts" / "refund.md").write_text( + "require approval and log\n", encoding="utf-8" + ) + + +def test_stop_hook_reuses_the_verify_the_agent_already_ran(tmp_path: Path) -> None: + _dirty_opted_in_repo(tmp_path) + _record(tmp_path) + + result, log = _stop_hook_with_record(tmp_path) + + assert result.returncode == 0, result.stderr + assert _verify_calls(log) == [], "the recorded run must not be recomputed" + out = json.loads(result.stdout) + assert out["decision"] == "block" + assert "agents-shipgate verify --json" in out["reason"] + + +def test_committing_after_verify_forces_a_fresh_run(tmp_path: Path) -> None: + """The stale-pass repro that sank the first attempt at this.""" + + _dirty_opted_in_repo(tmp_path) + _record(tmp_path) + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "commit", "-m", "commit the work"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + (tmp_path / "prompts" / "refund.md").write_text("changed again\n", encoding="utf-8") + + result, log = _stop_hook_with_record(tmp_path) + + assert result.returncode == 0, result.stderr + assert len(_verify_calls(log)) == 1 + + +def test_editing_after_verify_forces_a_fresh_run(tmp_path: Path) -> None: + _dirty_opted_in_repo(tmp_path) + _record(tmp_path) + (tmp_path / "prompts" / "refund.md").write_text("edited again\n", encoding="utf-8") + + result, log = _stop_hook_with_record(tmp_path) + + assert result.returncode == 0, result.stderr + assert len(_verify_calls(log)) == 1 + + +def test_a_record_from_another_config_is_not_reused(tmp_path: Path) -> None: + _dirty_opted_in_repo(tmp_path) + _record(tmp_path, config="other.yaml") + + result, log = _stop_hook_with_record(tmp_path) + + assert result.returncode == 0, result.stderr + assert len(_verify_calls(log)) == 1 + + +def test_a_record_from_a_different_base_is_not_reused(tmp_path: Path) -> None: + _dirty_opted_in_repo(tmp_path) + # The hook drops an unavailable base, so a record made *with* one is a + # different comparison than the one the hook is about to perform. + _record(tmp_path, base_ref="HEAD") + + result, log = _stop_hook_with_record(tmp_path) + + assert result.returncode == 0, result.stderr + assert len(_verify_calls(log)) == 1 + + +def test_recording_preserves_the_sessions_approved_surfaces(tmp_path: Path) -> None: + _dirty_opted_in_repo(tmp_path) + state_path = tmp_path / ".git" / "agents-shipgate-hooks-state.json" + state_path.write_text( + json.dumps({"approved_surfaces": {"s1": ["CLAUDE.md"]}}), encoding="utf-8" + ) + + _record(tmp_path) + + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["approved_surfaces"] == {"s1": ["CLAUDE.md"]} + assert state["last_verify"]["decision"] == "review_required" + + +def test_a_base_that_moved_since_the_verify_is_not_reused(tmp_path: Path) -> None: + """Same base ref, different commit: a different comparison. + + The name matching is not enough — `origin/main` advancing between the + agent's verify and the Stop hook changes what the diff means. + """ + + _dirty_opted_in_repo(tmp_path) + subprocess.run( + ["git", "update-ref", "refs/remotes/origin/main", "HEAD"], + cwd=tmp_path, + check=True, + ) + _record(tmp_path, base_ref="origin/main") + + result, log = _stop_hook_with_record(tmp_path) + assert result.returncode == 0, result.stderr + assert _verify_calls(log) == [], "an unmoved base still reuses" + + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "commit", "-m", "advance"], cwd=tmp_path, check=True, capture_output=True + ) + subprocess.run( + ["git", "update-ref", "refs/remotes/origin/main", "HEAD"], + cwd=tmp_path, + check=True, + ) + (tmp_path / "prompts" / "refund.md").write_text("more\n", encoding="utf-8") + + result, log = _stop_hook_with_record(tmp_path) + assert result.returncode == 0, result.stderr + assert len(_verify_calls(log)) == 1 From 1194ee5ffabe9eb4c971a35e90df0d42b599d4cb Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Tue, 28 Jul 2026 16:59:54 -0700 Subject: [PATCH 04/12] Record the adoption-verdict follow-up in the cold-start design doc Co-Authored-By: Claude Fable 5 --- docs/engineering/insufficient-evidence-cold-start.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/engineering/insufficient-evidence-cold-start.md b/docs/engineering/insufficient-evidence-cold-start.md index 05396a10..01d27182 100644 --- a/docs/engineering/insufficient-evidence-cold-start.md +++ b/docs/engineering/insufficient-evidence-cold-start.md @@ -109,6 +109,15 @@ one-time human act instead of schema archaeology — and corrects the attributio so the notice stops implicating the current change. The residual gap belongs to the host-authenticated approval work, not here. +**Follow-up (2026-07-28).** The adjacent half of the same cold-start experience +shipped separately: the *adoption* verdict itself. Verify used to greet a first +adoption with "This PR weakens the release policy that evaluates it" and no +`fix_task`, which is the same failure as an unreachable remedy — a correct +routing decision with nothing a reader can act on. Adoption is now proved from +git (the base carries no manifest under any name) and the copy says so, while +the verdict, severity, and check id are untouched. Same principle as the +scaffold: change what the tool *says*, never what it *allows*. + **Observed end to end.** On a representative cold-start repo the loop now closes: the scaffold's effect and authority blocks, filled in and merged, clear `inferred_effect_only` and `missing_authority_evidence`, and the verdict moves From 7e00b3e48de3c0df17398ce5b4e401ae6f725498 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Tue, 28 Jul 2026 18:31:52 -0700 Subject: [PATCH 05/12] Address review: six fail-opens and an unusable rerun command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every finding reproduced. Fixes, in the reviewer's order: 1. The fix_task rerun command omitted --config, invented origin/main, and always appended --head HEAD. Verified: a nested-manifest uncommitted adoption emitted a command that exits 2. It now emits the config always, a base only when one was used, and no --head for a working-tree run. 2/3/4. Reuse now has to identify the actual verification request. A run is recorded only when it is shaped like the hook's own invocation — no baseline, diff reference, policy pack, authorization, fail-on set, or non-default plugin/heuristic mode — and any other run clears the record instead of leaving a stale one. The identity hashes the HEAD commit, not only its tree, because an empty commit leaves the tree identical while moving the clock verify evaluates expiries against. A git-ignored scan input is read by the scan and invisible to the digest, so its presence vetoes the record outright rather than being silently uncovered. The identity is captured before the scan and re-checked at write time, closing the window where an edit lands mid-scan and binds the old verdict to the new state; the base commit gets the same treatment. The hook compares the effective base, head and ci-mode it is about to use, not the raw installed arguments. 5. The new error emitters carried only the legacy next_action string. docs/errors.json states the line always carries next_actions[] too, and that the kind is one of its published ids — `shipgate_error` was not. Both now derive from one NextAction so they cannot disagree, and a test asserts the documented envelope against docs/errors.json itself rather than against what we happen to emit. 6. policy_weakened stayed true through an adoption, so the registry, attestations, feedback's gate-bypass alarm and reviewer routing all inherited a contradiction with a headline saying the opposite. It is now derived from the evidence kind, not the check id alone. The fail-closed property does not move: an adoption must contain the manifest in its own diff (which is also the literal claim being made), so trust_root_touched is structurally set, and can_merge_without_human stays exactly the pure projection its schema validates it to be. Plus the three P2s: the adoption wording now requires every touched policy surface to be the manifest, so a diff that also edits an existing policies/** file keeps the strict copy; ls-tree failure is "cannot prove" rather than proven absence, and git's own rename/delete detection catches old-gate.yml -> new-gate.yml, which no name check could; audit reports an unwritable --out as a config error instead of a traceback and exit 1; and preflight still prints the traceback under --verbose. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 +- src/agents_shipgate/checks/verify_policy.py | 8 +- src/agents_shipgate/cli/agent_mode.py | 28 +++ src/agents_shipgate/cli/check.py | 57 +++--- src/agents_shipgate/cli/host_audit.py | 44 ++++- src/agents_shipgate/cli/install_hooks.py | 28 ++- src/agents_shipgate/cli/preflight.py | 30 ++- .../cli/verify/capability_review.py | 8 +- src/agents_shipgate/cli/verify/command.py | 32 ---- src/agents_shipgate/cli/verify/fix_task.py | 63 +++++-- src/agents_shipgate/cli/verify/git.py | 73 ++++++- src/agents_shipgate/cli/verify/hook_state.py | 76 ++++++-- .../cli/verify/orchestrator.py | 178 ++++++++++++++++-- .../core/findings/verifier_blocks.py | 9 +- tests/test_adapter_static_only.py | 2 +- tests/test_agent_mode.py | 78 +++++++- tests/test_first_adoption.py | 120 +++++++++++- tests/test_fix_task_contract.py | 18 +- tests/test_install_hooks.py | 105 ++++++++++- 19 files changed, 829 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 980b920f..913398f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,20 @@ commit changes the digest by construction, which is the stale-pass the first attempt at this was reverted for; it is now a regression test. Nothing is read from the workspace, the reused result routes through the same switch a - fresh one does, and any mismatch or doubt re-verifies. + fresh one does, and any mismatch or doubt re-verifies. A run is offered to the + hook only when it answered the hook's own question: default-shaped (no + baseline, diff reference, policy pack, authorization, fail-on set, or + non-default plugin/heuristic mode), with no git-ignored scan input — an + ignored tool source is read by the scan and invisible to the digest — and + with the working tree unmoved across the scan itself. Any other run clears + the record rather than leaving a stale one behind. +- **A rerun command that actually reruns.** The `fix_task` verification command + omitted `--config`, substituted `origin/main` for a base the run never used, + and always appended `--head HEAD`. In a repository with a nested manifest it + re-ran against a different gate, and for an uncommitted first adoption it + switched to the committed tree — where the new manifest does not exist — and + exited 2. It now emits the config always, the base only when one was used, + and no `--head` for a working-tree run. - **A way out of `insufficient_evidence` (#292).** An abstention was unactionable in practice: the decision engine generated the exact manifest diff --git a/src/agents_shipgate/checks/verify_policy.py b/src/agents_shipgate/checks/verify_policy.py index 3f6170e0..4d1cb0d5 100644 --- a/src/agents_shipgate/checks/verify_policy.py +++ b/src/agents_shipgate/checks/verify_policy.py @@ -39,6 +39,7 @@ verify_finding, ) from agents_shipgate.core.context import ScanContext +from agents_shipgate.core.trust_roots import trust_root_class_for from agents_shipgate.schemas.report import Finding CHECK_ID = "SHIP-VERIFY-POLICY-WEAKENED" @@ -185,7 +186,12 @@ def _fail_safe(context: ScanContext) -> list[Finding]: if not hit: return [] verification = context.verification - if verification is not None and verification.manifest_introduced: + introducing = verification is not None and verification.manifest_introduced + # An adoption that *also* edits an existing policy pack or baseline is not + # covered by "nothing existed to weaken": those files were already there. + # The friendlier wording is only correct when every touched policy surface + # is the manifest being introduced. + if introducing and all(trust_root_class_for(path) == "manifest" for path in hit): # A base with no manifest at all cannot have been weakened. This still # emits — at the same check id and severity, so the verdict and every # fail-closed consumer are unchanged — because the human decision diff --git a/src/agents_shipgate/cli/agent_mode.py b/src/agents_shipgate/cli/agent_mode.py index e2671ed7..2450e9be 100644 --- a/src/agents_shipgate/cli/agent_mode.py +++ b/src/agents_shipgate/cli/agent_mode.py @@ -5,6 +5,7 @@ import sys from collections.abc import Mapping from pathlib import Path +from typing import Any AGENT_MODE_ENV_VAR = "AGENTS_SHIPGATE_AGENT_MODE" @@ -62,6 +63,33 @@ def detect_actor(env: Mapping[str, str] | None = None) -> str: return DEFAULT_ACTOR +def emit_agent_mode_error_action( + error_kind: str, + *, + message: object, + exit_code: int, + action: Any, + **fields: object, +) -> None: + """Emit a structured error whose recovery is one ranked ``NextAction``. + + ``docs/errors.json`` states that an agent-mode error line *always* carries + both ``next_action`` (the single-string back-compat form) and + ``next_actions`` (the ranked array). Emitting only the legacy string leaves + every agent that consumes the documented array with nothing to route on, so + this derives both from one object and they cannot disagree. + """ + + emit_agent_mode_error( + error_kind, + message=message, + exit_code=exit_code, + next_action=action.to_legacy_string(), + next_actions=[action.model_dump(mode="json")], + **fields, + ) + + def emit_agent_mode_error( error_kind: str, *, diff --git a/src/agents_shipgate/cli/check.py b/src/agents_shipgate/cli/check.py index 8366a99e..7738c594 100644 --- a/src/agents_shipgate/cli/check.py +++ b/src/agents_shipgate/cli/check.py @@ -5,7 +5,10 @@ import typer -from agents_shipgate.cli.agent_mode import detect_actor, emit_agent_mode_error +from agents_shipgate.cli.agent_mode import ( + detect_actor, + emit_agent_mode_error_action, +) from agents_shipgate.cli.agent_result import ( agent_result_json, build_agent_boundary_result, @@ -16,6 +19,20 @@ from agents_shipgate.schemas.agent_boundary import AgentBoundaryResultV1 from agents_shipgate.schemas.agent_control import CodingAgentCommandAction from agents_shipgate.schemas.codex_boundary_result import CodexBoundaryResultV2 +from agents_shipgate.schemas.diagnostics import NextAction + + +def _flag_error(message: str, *, command: str, expects: str) -> typer.Exit: + """Report flag misuse on both channels and return the exit to raise.""" + + typer.echo(message, err=True) + emit_agent_mode_error_action( + "config_error", + message=message, + exit_code=2, + action=NextAction(kind="command", command=command, why=message, expects=expects), + ) + return typer.Exit(2) def check( @@ -75,38 +92,24 @@ def check( agent = agent or detect_actor() if agent not in {"codex", "claude-code", "cursor"}: - message = "--agent must be one of: codex, claude-code, cursor." - typer.echo(message, err=True) - emit_agent_mode_error( - "config_error", - message=message, - exit_code=2, - next_action="Re-run shipgate check with --agent codex, claude-code, or cursor.", + raise _flag_error( + "--agent must be one of: codex, claude-code, cursor.", + command="agents-shipgate check --agent codex", + expects="An --agent value the boundary evaluator recognizes.", ) - raise typer.Exit(2) if format_ == "agent-json": - message = ( + raise _flag_error( "--format agent-json was removed in the 0.14.0 contract cleanup. " - "Use --format agent-boundary-json." - ) - typer.echo(message, err=True) - emit_agent_mode_error( - "config_error", - message=message, - exit_code=2, - next_action="Re-run shipgate check with --format agent-boundary-json.", + "Use --format agent-boundary-json.", + command="agents-shipgate check --format agent-boundary-json", + expects="The current agent-boundary result contract.", ) - raise typer.Exit(2) if format_ not in {"agent-boundary-json", "codex-boundary-json"}: - message = "--format must be 'agent-boundary-json' or 'codex-boundary-json'." - typer.echo(message, err=True) - emit_agent_mode_error( - "config_error", - message=message, - exit_code=2, - next_action="Re-run shipgate check with --format agent-boundary-json.", + raise _flag_error( + "--format must be 'agent-boundary-json' or 'codex-boundary-json'.", + command="agents-shipgate check --format agent-boundary-json", + expects="The current agent-boundary result contract.", ) - raise typer.Exit(2) try: input_issues = [] if diff == "-": diff --git a/src/agents_shipgate/cli/host_audit.py b/src/agents_shipgate/cli/host_audit.py index d282454b..36f204fb 100644 --- a/src/agents_shipgate/cli/host_audit.py +++ b/src/agents_shipgate/cli/host_audit.py @@ -7,7 +7,7 @@ import typer -from agents_shipgate.cli.agent_mode import emit_agent_mode_error +from agents_shipgate.cli.agent_mode import emit_agent_mode_error_action from agents_shipgate.core.host_grants import ( DEFAULT_BASELINE_FILE, HOST_GRANTS_INVENTORY_SCHEMA_VERSION, @@ -25,23 +25,35 @@ render_host_audit_markdown, render_host_drift_markdown, ) +from agents_shipgate.schemas.diagnostics import NextAction -def _config_error(message: str, *, next_action: str) -> typer.Exit: +def _config_error( + message: str, + *, + next_action: str, + command: str = "agents-shipgate audit --host", +) -> typer.Exit: """Report flag misuse on both channels and return the exit to raise. Agent-facing docs promise that with ``AGENTS_SHIPGATE_AGENT_MODE=1`` a - failing command emits a structured ``next_action`` line on stderr. ``audit`` - printed prose only, so an agent that mis-invoked it had to parse English or - guess. + failing command emits a structured error line on stderr, carrying both the + legacy ``next_action`` string and the ranked ``next_actions`` array. + ``audit`` printed prose only, so an agent that mis-invoked it had to parse + English or guess. """ typer.echo(message, err=True) - emit_agent_mode_error( + emit_agent_mode_error_action( "config_error", message=message, exit_code=2, - next_action=next_action, + action=NextAction( + kind="command", + command=command, + why=next_action, + expects="A host-capability audit that completes.", + ), ) return typer.Exit(2) @@ -207,8 +219,22 @@ def audit( def _write_json_out(out: Path | None, payload: dict) -> None: if out is None: return - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + except OSError as exc: + # An unwritable --out is ordinary misuse (bad path, read-only mount), + # not a Shipgate defect: it was reaching the user as a Rich traceback + # and exit 1, which is neither the documented exit code nor something + # an agent can route on. + message = f"Could not write --out {out}: {exc}" + raise _config_error( + message, + next_action=message, + command="agents-shipgate audit --host --out ./host-audit.json", + ) from exc __all__ = [ diff --git a/src/agents_shipgate/cli/install_hooks.py b/src/agents_shipgate/cli/install_hooks.py index d79de0e2..4f34d479 100644 --- a/src/agents_shipgate/cli/install_hooks.py +++ b/src/agents_shipgate/cli/install_hooks.py @@ -756,7 +756,7 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in ) base = "" - reused = _reusable_verify(root, args, base) + reused = _reusable_verify(root, args, base=base, head=head, ci_mode=ci_mode) if reused is not None: return _route_control( decision=str(reused.get("decision") or "unknown"), @@ -1123,7 +1123,7 @@ def _worktree_identity(root: Path) -> str | None: "unchanged". """ - head = _run_git(root, ["rev-parse", "HEAD^{tree}"]) + head = _run_git(root, ["rev-parse", "HEAD", "HEAD^{tree}"]) if head is None or head.returncode != 0: return None changed = _run_git(root, ["diff", "HEAD", "--name-only"]) @@ -1153,7 +1153,10 @@ def _worktree_identity(root: Path) -> str | None: blobs = dict(zip(present, lines)) payload = json.dumps( { - "head_tree": head.stdout.strip(), + # The commit, not only its tree: an empty commit leaves the tree + # identical while moving HEAD, and verify reads the commit date as + # its evaluation clock. + "head": head.stdout.split(), "files": [[path, blobs.get(path, "absent")] for path in paths], }, sort_keys=True, @@ -1162,7 +1165,14 @@ def _worktree_identity(root: Path) -> str | None: return hashlib.sha256(payload.encode("utf-8")).hexdigest() -def _reusable_verify(root: Path, args: argparse.Namespace, base: str) -> dict | None: +def _reusable_verify( + root: Path, + args: argparse.Namespace, + *, + base: str, + head: str, + ci_mode: str, +) -> dict | None: """The agent's own verify run, when it provably still describes this state. A governed turn asks the coding agent to run verify and then runs an @@ -1171,6 +1181,12 @@ def _reusable_verify(root: Path, args: argparse.Namespace, base: str) -> dict | signature cache; this reuses one only when every input matches, including a content digest of the working tree. A commit, an edit, a different config, or a moved base all fail the comparison and re-verify. + + The comparison is against the *effective* base, head and ci-mode — the + values this hook is about to invoke verify with after applying the + AGENTS_SHIPGATE_VERIFY_* overrides and dropping an unavailable base — not + the raw installed arguments. Comparing the raw arguments would accept a + record produced under a different question than the one being asked. """ record = _read_state(root).get("last_verify") @@ -1178,11 +1194,11 @@ def _reusable_verify(root: Path, args: argparse.Namespace, base: str) -> dict | return None if record.get("config") != args.config: return None - if (record.get("ci_mode") or "") != (args.ci_mode or ""): + if (record.get("ci_mode") or "") != (ci_mode or ""): return None if (record.get("base_ref") or "") != base: return None - if (record.get("head_ref") or "") != (args.head or ""): + if (record.get("head_ref") or "") != (head or ""): return None recorded_base = record.get("base_commit") or "" if base: diff --git a/src/agents_shipgate/cli/preflight.py b/src/agents_shipgate/cli/preflight.py index d5ed9f1c..c61be099 100644 --- a/src/agents_shipgate/cli/preflight.py +++ b/src/agents_shipgate/cli/preflight.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import sys from pathlib import Path from typing import Any @@ -8,11 +9,12 @@ import typer from pydantic import ValidationError -from agents_shipgate.cli.agent_mode import emit_agent_mode_error +from agents_shipgate.cli.agent_mode import emit_agent_mode_error_action from agents_shipgate.core.codex_boundary import parse_unified_diff from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError from agents_shipgate.core.logging import configure_logging from agents_shipgate.core.preflight import build_preflight_result +from agents_shipgate.schemas.diagnostics import NextAction from agents_shipgate.schemas.preflight import ( CapabilityRequestV1, PreflightPlanV1, @@ -21,6 +23,8 @@ PreflightResultV3, ) +logger = logging.getLogger(__name__) + def _agent_mode_exit( error_kind: str, @@ -28,14 +32,24 @@ def _agent_mode_exit( *, exit_code: int, next_action: str, + command: str = "agents-shipgate preflight --json", ) -> typer.Exit: - """Emit the structured agent-mode error line and return the exit to raise.""" + """Emit the structured agent-mode error line and return the exit to raise. + + ``error_kind`` must be one of the ids published in ``docs/errors.json``; + an id an agent cannot look up is no better than prose. + """ - emit_agent_mode_error( + emit_agent_mode_error_action( error_kind, message=str(exc), exit_code=exit_code, - next_action=next_action, + action=NextAction( + kind="command", + command=command, + why=next_action, + expects="A preflight run that completes and returns its plan.", + ), ) return typer.Exit(exit_code) @@ -152,7 +166,7 @@ def preflight( except AgentsShipgateError as exc: typer.echo(f"Agents Shipgate error: {exc}", err=True) raise _agent_mode_exit( - "shipgate_error", + "other_error", exc, exit_code=4, next_action="Resolve the error above, then re-run `agents-shipgate preflight`.", @@ -171,7 +185,11 @@ def preflight( except Exception as exc: # noqa: BLE001 - agents must never see a bare traceback. # Every other command reports unexpected failures on the agent channel; # preflight let them escape as a traceback, which an agent cannot route - # on. Prose still goes to stderr and the exit code is unchanged. + # on. Prose still goes to stderr and the exit code is unchanged — and + # --verbose still gets the traceback, since swallowing it is exactly + # what that flag exists to prevent. + if verbose: + logger.exception("unhandled exception") typer.echo(f"Agents Shipgate internal error: {exc}", err=True) raise _agent_mode_exit( "internal_error", diff --git a/src/agents_shipgate/cli/verify/capability_review.py b/src/agents_shipgate/cli/verify/capability_review.py index 5e568884..cfe92d14 100644 --- a/src/agents_shipgate/cli/verify/capability_review.py +++ b/src/agents_shipgate/cli/verify/capability_review.py @@ -164,7 +164,13 @@ def _trust_root_touched(report: ReadinessReport) -> bool: def _policy_weakened(report: ReadinessReport) -> bool: if report.verifier_summary is not None: return report.verifier_summary.policy_weakened - return any(f.check_id == POLICY_WEAKENING_CHECK_ID for f in _active_findings(report)) + # Same exclusion as ``verifier_blocks``: a first adoption fires this check + # without weakening anything, and this flag is read as a fact downstream. + return any( + f.check_id == POLICY_WEAKENING_CHECK_ID + and (f.evidence or {}).get("kind") != "manifest_introduced" + for f in _active_findings(report) + ) def _active_findings(report: ReadinessReport) -> list[Finding]: diff --git a/src/agents_shipgate/cli/verify/command.py b/src/agents_shipgate/cli/verify/command.py index ef316867..7a8e98f7 100644 --- a/src/agents_shipgate/cli/verify/command.py +++ b/src/agents_shipgate/cli/verify/command.py @@ -20,7 +20,6 @@ from agents_shipgate.schemas.diagnostics import NextAction from .git import ensure_git_workspace, staged_paths_under -from .hook_state import record_verify_for_hooks from .orchestrator import run_preview, run_verify logger = logging.getLogger(__name__) @@ -303,37 +302,6 @@ def verify( _warn_if_reports_staged(workspace, out) - if not preview and head is None and verifier.execution == "succeeded": - # Worktree runs only. A ``--head`` run evaluates a commit, which is not - # the state the Stop hook is looking at. - record_verify_for_hooks( - git_root=Path(verifier.workspace), - config=str(config), - # The *effective* mode, not the flag: a forced --ci-mode rewrites - # the manifest's mode for the run, and the policy-weakening check - # compares that value against the base, so two runs under different - # effective modes can reach different findings. - ci_mode=verifier.mode, - base_ref=verifier.base_ref, - head_ref=head, - decision=( - verifier.release_decision.decision - if verifier.release_decision is not None - else "unknown" - ), - blockers=( - len(verifier.release_decision.blockers) - if verifier.release_decision is not None - else 0 - ), - review_items=( - len(verifier.release_decision.review_items) - if verifier.release_decision is not None - else 0 - ), - control=verifier.control.model_dump(mode="json"), - ) - if stdout_format == "json": typer.echo(json.dumps(verifier.model_dump(mode="json"), indent=2)) else: diff --git a/src/agents_shipgate/cli/verify/fix_task.py b/src/agents_shipgate/cli/verify/fix_task.py index 8b3d1dcf..d41b7986 100644 --- a/src/agents_shipgate/cli/verify/fix_task.py +++ b/src/agents_shipgate/cli/verify/fix_task.py @@ -73,6 +73,8 @@ def build_fix_task( base_ref: str | None, head_ref: str, manifest_introduced: bool = False, + config: str | None = None, + worktree: bool = False, ) -> VerifierFixTask | None: """Project the head scan onto a single repair task. @@ -88,7 +90,9 @@ def build_fix_task( if merge_verdict == "mergeable": return None - verification_command = _verification_command(base_ref, head_ref) + verification_command = _verification_command( + base_ref, head_ref, config=config, worktree=worktree + ) # No completed head decision (scan skipped/failed → ``unknown``) but the PR # is not mergeable: there are no findings to route on, so fail closed to a @@ -146,6 +150,10 @@ def build_fix_task( authority_escalation = ( capability_review.policy_weakened or capability_review.trust_root_touched + # Listed in its own right, not via ``policy_weakened``: that flag now + # reports the honest ``false`` for an adoption, and adopting a release + # policy is an authority decision no coding agent may make. + or manifest_introduced or merge_verdict in {"insufficient_evidence", "unknown"} or evidence_degraded ) @@ -255,15 +263,16 @@ def _adoption_instruction( ) -> str | None: """The one human act a first adoption needs, or ``None``. - Returns a string in exactly the cases where the generic policy/trust-root - instructions would have fired, so it replaces them rather than adding to - them; the routing decision that produced them is untouched. + Keyed on the adoption itself, not on ``policy_weakened``/ + ``trust_root_touched``: the first now reports the honest ``false`` for an + adoption, and borrowing the second would make the instruction disappear in + the one case it exists for. It replaces the generic instructions rather + than adding to them; the routing that produced them is untouched. """ + del capability_review # Kept for call-site symmetry with the generic path. if not manifest_introduced: return None - if not (capability_review.policy_weakened or capability_review.trust_root_touched): - return None return ( "This PR adopts Agents Shipgate: the base carries no manifest, so " "nothing existing was weakened. Review the generated shipgate.yaml " @@ -554,13 +563,41 @@ def _dedupe_cap(items: list[str]) -> list[str]: return out[:_MAX_INSTRUCTIONS] -def _verification_command(base_ref: str | None, head_ref: str) -> str: - # Refs come from CLI / GitHub branch inputs and a valid git ref may contain - # shell metacharacters (e.g. ``;``); quote them so the emitted command is - # safe to run when an agent or human copies it verbatim. - base = shlex.quote(base_ref or "origin/main") - head = shlex.quote(head_ref or "HEAD") - return f"agents-shipgate verify --base {base} --head {head} --json" +def _verification_command( + base_ref: str | None, + head_ref: str, + *, + config: str | None = None, + worktree: bool = False, +) -> str: + """The exact command that re-runs *this* verification. + + Every part of it is the run that actually happened, because an agent runs + it verbatim: + + - ``--config`` is always emitted. A repository with a nested manifest + (``services/api/shipgate.yaml``) re-ran against the default path, which + is a different gate or no gate at all. + - The base is emitted only when one was used. Substituting ``origin/main`` + invented a comparison the run never made, and fails outright in a + repository without that remote. + - ``--head`` is omitted for a working-tree run. Passing ``HEAD`` switches + verify to the committed tree, which for an uncommitted first adoption is + a tree with no manifest in it — the command exits 2. + + Refs and paths come from CLI / GitHub inputs and a valid git ref may + contain shell metacharacters, so every interpolated value is quoted. + """ + + parts = ["agents-shipgate", "verify"] + if config: + parts.extend(["--config", shlex.quote(config)]) + if base_ref: + parts.extend(["--base", shlex.quote(base_ref)]) + if not worktree: + parts.extend(["--head", shlex.quote(head_ref or "HEAD")]) + parts.append("--json") + return " ".join(parts) __all__ = ["FORBIDDEN_SHORTCUTS", "build_fix_task"] diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 3cd8994a..6ffdbba5 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -395,7 +395,7 @@ def worktree_identity(workspace: Path) -> str | None: they must never treat it as "unchanged". """ - head = _run_git(workspace, ["rev-parse", "HEAD^{tree}"], check=False) + head = _run_git(workspace, ["rev-parse", "HEAD", "HEAD^{tree}"], check=False) if head.returncode != 0: return None changed = _run_git(workspace, ["diff", "HEAD", "--name-only"], check=False) @@ -425,7 +425,11 @@ def worktree_identity(workspace: Path) -> str | None: blobs = dict(zip(present, lines, strict=True)) payload = json.dumps( { - "head_tree": head.stdout.strip(), + # The commit, not only its tree. An empty commit leaves the tree + # byte-identical while moving HEAD — and verify reads the commit: + # its date is the evaluation clock that expires overrides and + # acknowledgements, and its ancestry decides the base. + "head": head.stdout.split(), # "absent" covers a deleted tracked file, which has no blob but is # every bit a change. "files": [[path, blobs.get(path, "absent")] for path in paths], @@ -436,17 +440,45 @@ def worktree_identity(workspace: Path) -> str | None: return hashlib.sha256(payload.encode("utf-8")).hexdigest() -def paths_named_at_ref(workspace: Path, ref: str, names: frozenset[str]) -> list[str]: +def ignored_paths(workspace: Path, paths: list[str]) -> list[str] | None: + """Which of ``paths`` git ignores, or ``None`` when that cannot be decided. + + A worktree identity is built from HEAD's tree plus every changed and + untracked file, which covers everything git tracks or reports — but not an + ignored file. A declared tool source (an OpenAPI spec, an MCP export) can + be ignored and still be read by the scan, and it would then change without + moving the identity at all. Callers use this to refuse a reuse record + rather than to paper over it. + """ + + if not paths: + return [] + if len(paths) > _MAX_IDENTITY_PATHS: + return None + result = _run_git(workspace, ["check-ignore", "--", *paths], check=False) + if result.returncode == 1: # documented: nothing matched + return [] + if result.returncode != 0: + return None + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def paths_named_at_ref( + workspace: Path, ref: str, names: frozenset[str] +) -> list[str] | None: """Tracked paths at ``ref`` whose file name is one of ``names``. Used to prove that a ref carries no Shipgate manifest *anywhere*, not just at the configured path — otherwise moving the manifest to a new path would make a modified gate look like a first adoption. + + ``None`` means the listing failed, which is not the same as an empty + result: callers must treat it as "cannot prove", never as proven absence. """ result = _run_git(workspace, ["ls-tree", "-r", "--name-only", ref], check=False) if result.returncode != 0: - return [] + return None return [ line for line in result.stdout.splitlines() @@ -454,6 +486,39 @@ def paths_named_at_ref(workspace: Path, ref: str, names: frozenset[str]) -> list ] +# Suffixes a Shipgate manifest can plausibly carry. A rename away from one of +# these is the shape the adoption claim must not survive. +_MANIFEST_SUFFIXES = (".yaml", ".yml") + + +def removes_a_yaml_file(workspace: Path, base: str | None, head: str) -> bool | None: + """Whether the evaluated diff deletes or renames away any YAML file. + + A repository is free to name its manifest anything, so "the base has no + file called shipgate.yaml" cannot by itself prove the base had no gate: a + PR that renames ``old-gate.yml`` to ``new-gate.yml`` while loosening it + passes that test. Git's own rename/delete detection answers the question + the name check cannot, without having to guess names. + + ``None`` means the diff could not be read — cannot prove, so callers must + not claim an adoption. + """ + + args = ["diff", "--name-status", "--find-renames", "--diff-filter=DR"] + args.extend([f"{base}...{head}"] if base else ["HEAD"]) + result = _run_git(workspace, args, check=False) + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + fields = [field for field in line.split("\t") if field.strip()] + if len(fields) < 2: + continue + source = fields[1].replace("\\", "/") + if source.lower().endswith(_MANIFEST_SUFFIXES): + return True + return False + + def working_tree_context(workspace: Path) -> tuple[list[str], str]: """Return uncommitted changed paths and tracked-file diff text. diff --git a/src/agents_shipgate/cli/verify/hook_state.py b/src/agents_shipgate/cli/verify/hook_state.py index b82401c3..ecdfc60c 100644 --- a/src/agents_shipgate/cli/verify/hook_state.py +++ b/src/agents_shipgate/cli/verify/hook_state.py @@ -7,11 +7,11 @@ This records the finished run where the hook already keeps its own state — the git directory, alongside ``last_verified_signature`` — so the hook can *report* -that result instead of recomputing it, but only when it can prove the repository -has not moved since (see :func:`agents_shipgate.cli.verify.git.worktree_identity` -and the identity fields below). +that result instead of recomputing it, but only when it can prove the +repository has not moved and that the run it is reporting is the run it would +otherwise have performed. -Two rules this module exists to keep: +Three rules this module exists to keep: - **Never the workspace.** An earlier attempt read ``verifier.json`` out of the reports directory, which anything in the workspace can write — including the @@ -23,6 +23,12 @@ - **Never more trusted than fresh.** The record carries no verdict the hook would not have gotten from a fresh run, and the hook routes it through the same switch. On any mismatch — or any doubt — the hook re-verifies. +- **Only a run the hook could have produced.** The hook invokes verify with + workspace, config, base, head and ci-mode and nothing else. A run that used a + baseline, a diff reference, policy packs, an authorization file, a fail-on + set, or non-default plugin/heuristic modes answered a *different* question, + so it is never recorded. Same for a run whose inputs the hook's identity + cannot see (an ignored tool source) or whose worktree moved mid-scan. """ from __future__ import annotations @@ -32,7 +38,12 @@ from pathlib import Path from typing import Any -from agents_shipgate.cli.verify.git import commit_sha, git_path, worktree_identity +from agents_shipgate.cli.verify.git import ( + commit_sha, + git_path, + ignored_paths, + worktree_identity, +) STATE_FILENAME = "agents-shipgate-hooks-state.json" RECORD_KEY = "last_verify" @@ -44,30 +55,53 @@ def record_verify_for_hooks( config: str, ci_mode: str, base_ref: str | None, + base_commit_before: str | None, head_ref: str | None, + identity_before: str | None, + input_paths: list[str], + input_set_id: str | None, decision: str, blockers: int, review_items: int, control: dict[str, Any], ) -> None: - """Record a completed worktree verify for the installed Stop hook. + """Record a completed default-shaped worktree verify for the Stop hook. + + ``identity_before`` is the worktree identity captured *before* the scan + started. Recomputing it here and requiring the two to match closes the + window where an edit lands while the scan is running: without it, the + verdict for the pre-edit tree would be filed under the post-edit state, and + the hook would report a result for work it never saw. ``base_commit_before`` + does the same for a base ref that advances mid-scan. Fail-soft by construction: any problem skips the record and the hook simply runs its own verify, which is the behavior this replaces. """ try: - identity = worktree_identity(git_root) - if identity is None: + if identity_before is None: + return + if worktree_identity(git_root) != identity_before: + return + if base_ref: + current_base = commit_sha(git_root, base_ref) + if not current_base or current_base != base_commit_before: + return + elif base_commit_before: + return + # An ignored input is read by the scan and invisible to the identity, + # so a repository that has one can never reuse. Refusing the record is + # the whole mitigation: the hook needs no knowledge of the manifest. + ignored = ignored_paths(git_root, input_paths) + if ignored is None or ignored: return record = { - "identity": identity, + "identity": identity_before, + "input_set_id": input_set_id, "config": config, "ci_mode": ci_mode, "base_ref": base_ref or "", - # The base is a moving ref; pin what it pointed at. A base that - # advanced between the two runs is a different comparison. - "base_commit": (commit_sha(git_root, base_ref) or "") if base_ref else "", + "base_commit": base_commit_before or "", "head_ref": head_ref or "", "decision": decision, "blockers": blockers, @@ -81,6 +115,17 @@ def record_verify_for_hooks( return +def discard_hook_verify_record(git_root: Path) -> None: + """Drop any recorded run — used when this run must not be reusable.""" + + try: + state = _read_state(git_root) + if state.pop(RECORD_KEY, None) is not None: + _write_state(git_root, state) + except Exception: # noqa: BLE001 - advisory only. + return + + def _state_file(git_root: Path) -> Path: return git_path(git_root, STATE_FILENAME) @@ -108,4 +153,9 @@ def _write_state(git_root: Path, data: dict[str, Any]) -> None: os.replace(temp, path) -__all__ = ["RECORD_KEY", "STATE_FILENAME", "record_verify_for_hooks"] +__all__ = [ + "RECORD_KEY", + "STATE_FILENAME", + "discard_hook_verify_record", + "record_verify_for_hooks", +] diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index ece86686..4c10cd86 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -97,11 +97,14 @@ paths_named_at_ref, read_file_at_ref, ref_exists, + removes_a_yaml_file, repository_identity, resolve_source_head_identity, tree_sha, working_tree_context, + worktree_identity, ) +from .hook_state import discard_hook_verify_record, record_verify_for_hooks HEAD_FORMATS = ["markdown", "json", "sarif"] # Verify owns the PR artifact contract and writes packet.json only; the @@ -189,6 +192,7 @@ def run_verify( "preview, then correct --config or initialize shipgate.yaml." ), ), + worktree=not archive_head, ) _remove_scan_artifacts(out_dir) _write_artifacts( @@ -249,6 +253,7 @@ def run_verify( expects=head, why="Make the requested head ref available locally, then rerun verify.", ), + worktree=not archive_head, ) _remove_scan_artifacts(out_dir) _write_artifacts( @@ -333,6 +338,7 @@ def run_verify( head_exit_code=0, out_dir=out_dir, ci_mode=ci_mode, + worktree=not archive_head, ) if diff_unavailable: @@ -353,6 +359,7 @@ def run_verify( head_exit_code=2, out_dir=out_dir, ci_mode=ci_mode, + worktree=not archive_head, ) _write_artifacts( verifier, @@ -391,6 +398,7 @@ def run_verify( head_exit_code=0, out_dir=out_dir, ci_mode=ci_mode, + worktree=not archive_head, ) _write_artifacts( verifier, @@ -433,12 +441,35 @@ def run_verify( ) base_notes.extend(cache_notes) + # Captured before the scan starts, so a worktree edit or a base ref that + # advances *while* the scan runs is detectable afterwards. Without this the + # verdict for the pre-edit tree would be filed under the post-edit state. + identity_before = worktree_identity(git_root) if not archive_head else None + base_commit_before = commit_sha(git_root, base) if base else None + # The Stop hook invokes verify with workspace/config/base/head/ci-mode and + # nothing else. A run carrying any other gate-affecting input answered a + # different question and must never stand in for the hook's own. + default_shaped = ( + baseline is None + and diff_from is None + and not policy_pack_paths + and authorization is None + and plugins_enabled is None + and not strict_plugins + and not no_heuristics + and not suggest_patches + and not fail_on + and baseline_mode == "new-findings" + ) + scan_input_paths: list[str] = [] + manifest_introduced = _manifest_introduced( git_root=git_root, config_relative=config_relative, base_status=base_status, base=base, head=head, + changed_files=changed_files, ) report: ReadinessReport | None = None @@ -558,6 +589,7 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: out_dir=out_dir, ci_mode=ci_mode, manifest_introduced=manifest_introduced, + worktree=not archive_head, ) try: try: @@ -592,6 +624,7 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: ), }, evaluation_date=verification_date, + scan_input_paths=scan_input_paths, ) except Exception: if scan_error is None: @@ -602,9 +635,73 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: finally: if head_tmp is not None: head_tmp.cleanup() + _record_or_discard_hook_verify( + git_root=git_root, + verifier=verifier, + head_status=head_status, + archive_head=archive_head, + config_relative=config_relative, + identity_before=identity_before, + base_commit_before=base_commit_before, + default_shaped=default_shaped, + scan_input_paths=scan_input_paths, + ) return verifier, report, head_exit_code +def _record_or_discard_hook_verify( + *, + git_root: Path, + verifier: VerifierArtifact, + head_status: str, + archive_head: bool, + config_relative: Path, + identity_before: str | None, + base_commit_before: str | None, + default_shaped: bool, + scan_input_paths: list[str], +) -> None: + """Offer this run to the Stop hook, or make sure nothing stale is offered. + + Discarding matters as much as recording: a failed or differently-shaped run + must not leave an earlier record in place for the hook to find, because the + repository has usually moved since that record was written and "no record" + is the safe state. + """ + + reusable = ( + head_status == "succeeded" + and not archive_head + and default_shaped + and verifier.control.state is not None + ) + if not reusable: + discard_hook_verify_record(git_root) + return + release = verifier.release_decision + record_verify_for_hooks( + git_root=git_root, + config=config_relative.as_posix(), + # The *effective* mode, not the flag: a forced --ci-mode rewrites the + # manifest's mode for the run, and the policy-weakening check compares + # that value against the base, so two runs under different effective + # modes can reach different findings. + ci_mode=verifier.mode, + base_ref=verifier.base_ref, + base_commit_before=base_commit_before, + head_ref=None, + identity_before=identity_before, + input_paths=scan_input_paths, + input_set_id=verifier.input_set_id, + decision=release.decision if release is not None else "unknown", + blockers=len(release.blockers) if release is not None else 0, + review_items=len(release.review_items) if release is not None else 0, + control=verifier.control.model_dump(mode="json"), + ) + + + + def _prepare_base_report( *, git_root: Path, @@ -931,6 +1028,7 @@ def _manifest_introduced( base_status: VerifierBaseStatus, base: str | None, head: str, + changed_files: list[str], ) -> bool: """True when the comparison base carries no Shipgate manifest at all. @@ -942,13 +1040,29 @@ def _manifest_introduced( The proof is deliberately stronger than "the configured path is absent on the base". A PR that *moves* the manifest — say to ``config/shipgate.yaml`` while loosening it — also finds nothing at the configured path on the base, - and would otherwise get to call itself a first adoption. Requiring that the - base carry no manifest under any name closes that. + and would otherwise get to call itself a first adoption. + + Two independent checks close that, because a name check alone cannot: a + repository may call its manifest anything, so ``old-gate.yml`` renamed to + ``new-gate.yml`` passes any name test. The base must carry no manifest + under the configured or default name, *and* the evaluated diff must not + delete or rename away any YAML file. Both are fail-closed: a git command + that cannot answer means "not an adoption", never "proven absent". Unknown bases (``ref_missing``, ``archive_failed``) are never treated as adoptions: absence of evidence is not evidence of absence. + + The evaluated diff must also actually contain the manifest. That is the + literal claim being made ("this PR introduces it"), and it is what makes + ``trust_root_touched`` structurally true for every adoption — which matters + because ``policy_weakened`` is honestly ``false`` here, so the trust-root + signal is the one machine consumers are left with. """ + if config_relative.as_posix() not in { + str(path).replace("\\", "/").strip() for path in changed_files + }: + return False if base_status == "missing_manifest" and base is not None: ref: str = base elif base_status in {"not_requested", "skipped"}: @@ -959,7 +1073,13 @@ def _manifest_introduced( else: return False names = _MANIFEST_FILE_NAMES | {config_relative.name} - return not paths_named_at_ref(git_root, ref, frozenset(names)) + existing = paths_named_at_ref(git_root, ref, frozenset(names)) + if existing is None or existing: + return False + removed = removes_a_yaml_file( + git_root, base if base_status == "missing_manifest" else None, head + ) + return removed is False def _can_merge_without_human( @@ -968,10 +1088,20 @@ def _can_merge_without_human( release_decision: ReleaseDecision | None, capability_review: VerifierCapabilityReview | None = None, ) -> bool: - """Pure merge projection; contradictory passed substrate fails closed.""" + """Pure merge projection; contradictory passed substrate fails closed. + + ``manifest_introduced`` is deliberately *not* consulted. ``VerifierArtifact`` + validates this as a pure projection of ``decision``/``execution``, so a + third input could only produce an artifact the schema rejects. The adoption + stays fail-closed through the substrate instead: it always touches the + manifest — ``_manifest_introduced`` requires the diff to contain it — so + ``trust_root_touched`` is set, the note below is non-``None``, and a + ``passed`` decision alongside it is caught as the contradiction it is. + """ + note = _self_approval_note(capability_review) if release_decision is None: - if merge_verdict == "mergeable" and _self_approval_note(capability_review): + if merge_verdict == "mergeable" and note: raise ValueError("mergeable not-applicable projection contradicts a touched trust root") return merge_verdict == "mergeable" if release_decision.decision != "passed": @@ -979,7 +1109,7 @@ def _can_merge_without_human( contradictions: list[str] = [] if merge_verdict != "mergeable": contradictions.append("merge verdict is not mergeable") - if _self_approval_note(capability_review) is not None: + if note is not None: contradictions.append("release trust root or policy was changed") if release_decision.evidence_coverage.human_review_recommended: contradictions.append("evidence coverage recommends human review") @@ -1012,15 +1142,14 @@ def _self_approval_note( coding agent cannot adopt a release policy on the repository's behalf), but a PR that adds the manifest to a base that had none weakens nothing, and saying it does is both wrong and the first thing every new adopter reads. - ``manifest_introduced`` only changes the sentence: this returns a string in - exactly the same cases as before, so every caller that reads it as "a trust - root is in play" keeps its meaning. + An adoption is its own case and must not depend on ``policy_weakened``: + that flag is now false during an adoption, because it answers "was the gate + weakened" for the registry, attestations, and the gate-bypass alarm. Firing + on ``manifest_introduced`` alone is what keeps every caller that reads this + as "a trust root is in play" — ``_can_merge_without_human`` above all — + fail-closed regardless of the other two flags. """ - if capability_review is None: - return None - if manifest_introduced and ( - capability_review.policy_weakened or capability_review.trust_root_touched - ): + if manifest_introduced: return ( "This PR introduces Agents Shipgate to this repository: the base " "carries no manifest, so there is no prior gate this change could " @@ -1028,6 +1157,8 @@ def _self_approval_note( "generated shipgate.yaml (and the agent-instruction and CI files it " "adds), then merge it through a human-reviewed PR." ) + if capability_review is None: + return None if capability_review.policy_weakened: return ( "This PR weakens the release policy that evaluates it; a coding " @@ -1291,6 +1422,7 @@ def _build_verifier( headline_override: str | None = None, first_next_action_override: AgentControlAction | None = None, manifest_introduced: bool = False, + worktree: bool = False, ) -> VerifierArtifact: release_decision_model = report.release_decision if report is not None else None release_decision = ( @@ -1328,6 +1460,8 @@ def _build_verifier( base_ref=base, head_ref=head, manifest_introduced=manifest_introduced, + config=_display_path(config_path, git_root), + worktree=worktree, ) ) can_merge = _can_merge_without_human( @@ -1698,7 +1832,17 @@ def _write_artifacts( authorization_path: Path | None = None, verification_options: dict[str, Any] | None = None, evaluation_date: str | None = None, + scan_input_paths: list[str] | None = None, ) -> None: + """Write the run's artifacts. + + ``scan_input_paths`` is an out-parameter: when a list is passed, the + workspace-relative path of every file the verification plan counted as an + input is appended to it. The Stop-hook reuse record needs that list to ask + git whether any input is ignored, and the plan is the only place that knows + it. + """ + verifier_path.parent.mkdir(parents=True, exist_ok=True) verifier_path.write_text( json.dumps(verifier.model_dump(mode="json"), indent=2), @@ -1847,6 +1991,12 @@ def _write_artifacts( "can_merge_without_human": verifier.can_merge_without_human, } ) + if scan_input_paths is not None: + scan_input_paths.extend( + blob.path + for blob in (plan.inputs.config, *plan.inputs.tool_sources) + if getattr(blob, "path", None) + ) verifier.request_id = plan.request_id verifier.subject_id = plan.subject.subject_id verifier.input_set_id = plan.inputs.input_set_id diff --git a/src/agents_shipgate/core/findings/verifier_blocks.py b/src/agents_shipgate/core/findings/verifier_blocks.py index e4a80028..dda6f627 100644 --- a/src/agents_shipgate/core/findings/verifier_blocks.py +++ b/src/agents_shipgate/core/findings/verifier_blocks.py @@ -836,8 +836,15 @@ def build_verifier_summary(report: ReadinessReport) -> VerifierSummary: human_ack_required = bool(human_ack.required) if human_ack else False human_ack_satisfied = bool(human_ack.satisfied) if human_ack else True + # A first adoption emits under this check id (there is still a human + # decision to make) but weakens nothing — the base carried no policy. The + # flag is consumed as a fact by the registry, attestations, feedback's + # gate-bypass alarm, and reviewer routing, so it must answer "was the gate + # weakened", not "did this check fire". policy_weakened = any( - f.check_id == "SHIP-VERIFY-POLICY-WEAKENED" for f in active + f.check_id == "SHIP-VERIFY-POLICY-WEAKENED" + and (f.evidence or {}).get("kind") != "manifest_introduced" + for f in active ) return VerifierSummary( diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index c67e8331..ac751013 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -303,7 +303,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=702, + line=767, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " diff --git a/tests/test_agent_mode.py b/tests/test_agent_mode.py index 978c0c8d..11c07073 100644 --- a/tests/test_agent_mode.py +++ b/tests/test_agent_mode.py @@ -362,6 +362,36 @@ def test_check_explicit_agent_flag_beats_detection( # --- structured errors on the commands that had none ------------------------ +_DOCUMENTED_ERROR_IDS = { + entry["id"] + for entry in json.loads( + (Path(__file__).resolve().parent.parent / "docs" / "errors.json").read_text( + encoding="utf-8" + ) + )["errors"] +} + + +def _assert_documented_envelope(payload: dict) -> None: + """`docs/errors.json` is the contract, not a description of what we emit. + + It states that an agent-mode error line always carries `next_action` *and* + the ranked `next_actions` array, and that the kind is one of the published + ids. An agent that routes on the documented array got nothing from an + emitter that shipped only the legacy string. + """ + + assert payload["error"] in _DOCUMENTED_ERROR_IDS, payload["error"] + assert isinstance(payload.get("next_actions"), list) + assert payload["next_actions"], payload + for action in payload["next_actions"]: + assert action["kind"] in {"command", "edit", "review", "stop"} + assert action["why"] + if action["kind"] == "command": + assert action["command"] + assert payload["next_action"] + + def _agent_mode_error(result) -> dict: """The last stderr line of an agent-mode run, parsed.""" @@ -383,6 +413,7 @@ def test_check_rejects_an_unknown_format_on_the_agent_channel( assert payload["error"] == "config_error" assert payload["exit_code"] == 2 assert "agent-boundary-json" in payload["next_action"] + _assert_documented_envelope(payload) def test_check_rejects_an_unknown_agent_on_the_agent_channel( @@ -394,7 +425,7 @@ def test_check_rejects_an_unknown_agent_on_the_agent_channel( ) assert result.exit_code == 2 - assert _agent_mode_error(result)["error"] == "config_error" + _assert_documented_envelope(_agent_mode_error(result)) def test_audit_without_host_reports_a_next_action( @@ -407,6 +438,7 @@ def test_audit_without_host_reports_a_next_action( payload = _agent_mode_error(result) assert payload["error"] == "config_error" assert "--host" in payload["next_action"] + _assert_documented_envelope(payload) def test_preflight_config_error_reports_a_next_action( @@ -422,6 +454,7 @@ def test_preflight_config_error_reports_a_next_action( payload = _agent_mode_error(result) assert payload["error"] == "config_error" assert payload["exit_code"] == 2 + _assert_documented_envelope(payload) def test_preflight_silent_without_agent_mode(tmp_path: Path) -> None: @@ -432,3 +465,46 @@ def test_preflight_silent_without_agent_mode(tmp_path: Path) -> None: assert result.exit_code == 2 assert not [line for line in result.output.splitlines() if line.startswith("{")] + + +def test_audit_reports_an_unwritable_out_path_as_config_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unwritable --out was a Rich traceback and exit 1, not a contract.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + blocker = tmp_path / "blocker" + blocker.write_text("not a directory\n", encoding="utf-8") + + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--json", + "--out", + str(blocker / "nested" / "audit.json"), + ], + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + assert payload["error"] == "config_error" + _assert_documented_envelope(payload) + + +def test_preflight_reports_only_documented_error_kinds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`shipgate_error` was not an id an agent could look up.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + (tmp_path / "shipgate.yaml").write_text( + 'version: "1"\nproject:\n name: demo\n', encoding="utf-8" + ) + result = runner.invoke(app, ["preflight", "--workspace", str(tmp_path)]) + + assert result.exit_code != 0 + _assert_documented_envelope(_agent_mode_error(result)) diff --git a/tests/test_first_adoption.py b/tests/test_first_adoption.py index 33b9390b..c343fc67 100644 --- a/tests/test_first_adoption.py +++ b/tests/test_first_adoption.py @@ -75,6 +75,7 @@ def test_missing_manifest_base_with_no_manifest_anywhere_is_an_adoption(tmp_path base_status="missing_manifest", base="HEAD~1", head="HEAD", + changed_files=[SAMPLE_CONFIG.as_posix()], ) is True ) @@ -105,6 +106,7 @@ def test_moved_manifest_cannot_pass_itself_off_as_an_adoption(tmp_path): base_status="missing_manifest", base="HEAD~1", head="HEAD", + changed_files=["samples/support_refund_agent/config/shipgate.yaml"], ) is False ) @@ -123,6 +125,7 @@ def test_uncommitted_new_manifest_is_an_adoption(tmp_path): base_status="not_requested", base=None, head="HEAD", + changed_files=[SAMPLE_CONFIG.as_posix()], ) is True ) @@ -137,6 +140,7 @@ def test_adopted_repo_is_never_an_adoption(tmp_path): base_status="not_requested", base=None, head="HEAD", + changed_files=[SAMPLE_CONFIG.as_posix()], ) is False ) @@ -154,6 +158,7 @@ def test_unknown_base_is_never_an_adoption(tmp_path): base_status=status, # type: ignore[arg-type] base="HEAD~1", head="HEAD", + changed_files=[SAMPLE_CONFIG.as_posix()], ) is False ), status @@ -217,12 +222,13 @@ def test_adoption_headline_replaces_the_weakening_claim(): assert "weakens the release policy" in modifying -def test_adoption_note_fires_in_exactly_the_same_cases_as_before(): - """``_self_approval_note`` doubles as the "a trust root is in play" probe. +def test_adoption_raises_the_prohibition_on_its_own(): + """``_self_approval_note`` is the "a trust root is in play" probe. - ``_can_merge_without_human`` raises on a passed decision that carries one, - so the introduction wording must not change *whether* a note exists — only - what it says. + ``_can_merge_without_human`` raises on a passed decision that carries one. + Since `policy_weakened` is now honestly `false` during an adoption, the + adoption has to raise the prohibition by itself — including when no other + flag is set and when there is no capability review at all. """ for review in ( @@ -232,9 +238,41 @@ def test_adoption_note_fires_in_exactly_the_same_cases_as_before(): _review(trust_root_touched=True), _review(policy_weakened=True, trust_root_touched=True), ): - introduced = _self_approval_note(review, manifest_introduced=True) - plain = _self_approval_note(review, manifest_introduced=False) - assert (introduced is None) == (plain is None) + assert _self_approval_note(review, manifest_introduced=True) is not None + + +def test_non_adoption_wording_is_unchanged(): + assert _self_approval_note(None, manifest_introduced=False) is None + assert _self_approval_note(_review(), manifest_introduced=False) is None + assert ( + _self_approval_note(_review(trust_root_touched=True), manifest_introduced=False) + is not None + ) + + +def test_adoption_reports_no_policy_weakening_to_machine_consumers(tmp_path): + """The flag is read as a fact by the registry, attestations, and feedback. + + A run whose headline says "introduces Agents Shipgate" while + `capability_review.policy_weakened` is true feeds that contradiction to + every downstream consumer — including feedback's gate-bypass alarm. + """ + + repo = _repo_adopting_shipgate(tmp_path) + verifier = _run_verify(repo, base="HEAD~1", head="HEAD") + + assert verifier.headline is not None + assert "introduces Agents Shipgate" in verifier.headline + assert verifier.capability_review.policy_weakened is False + # The adoption is still visible as a trust-root touch, which is what keeps + # reviewer routing and the gate-bypass alarm intact. + assert verifier.capability_review.trust_root_touched is True + assert verifier.can_merge_without_human is False + + payload = json.loads( + (repo / "agents-shipgate-reports" / "report.json").read_text("utf-8") + ) + assert payload["verifier_summary"]["policy_weakened"] is False # --- end to end -------------------------------------------------------------- @@ -309,3 +347,69 @@ def test_adopted_repo_gets_the_ordinary_wording_back(tmp_path): assert verifier.base_status == "succeeded" assert verifier.headline is not None assert "introduces Agents Shipgate" not in verifier.headline + + +def test_a_renamed_manifest_under_any_name_is_not_an_adoption(tmp_path): + """The name check alone cannot see this. + + A repository may call its manifest anything. `old-gate.yml` renamed to + `new-gate.yml` — while loosening it — leaves no file called + `shipgate.yaml` or `new-gate.yml` on the base, so only git's own + rename/delete detection separates it from a genuine first adoption. + """ + + repo = _repo_adopting_shipgate(tmp_path) + sample = repo / "samples" / "support_refund_agent" + _git(repo, "mv", "samples/support_refund_agent/shipgate.yaml", "samples/support_refund_agent/old-gate.yml") + _git(repo, "commit", "-m", "rename to a custom manifest name") + _git(repo, "mv", "samples/support_refund_agent/old-gate.yml", "samples/support_refund_agent/new-gate.yml") + (sample / "new-gate.yml").write_text( + (sample / "new-gate.yml").read_text("utf-8") + "\n", encoding="utf-8" + ) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "rename and loosen") + + assert ( + _manifest_introduced( + git_root=repo, + config_relative=Path("samples/support_refund_agent/new-gate.yml"), + base_status="missing_manifest", + base="HEAD~1", + head="HEAD", + changed_files=["samples/support_refund_agent/new-gate.yml"], + ) + is False + ) + + +def test_a_manifest_absent_from_the_diff_is_not_an_adoption(tmp_path): + """"This PR introduces it" has to be literally true. + + It is also what makes `trust_root_touched` structural for every adoption, + which matters now that `policy_weakened` is honestly false there. + """ + + repo = _repo_adopting_shipgate(tmp_path) + assert ( + _manifest_introduced( + git_root=repo, + config_relative=SAMPLE_CONFIG, + base_status="missing_manifest", + base="HEAD~1", + head="HEAD", + changed_files=["README.md"], + ) + is False + ) + + +def test_an_adoption_that_also_edits_a_policy_pack_keeps_the_strict_wording(): + """"Nothing existed to weaken" is false for a policy file that did exist.""" + + context = _scan_context( + manifest_introduced=True, + changed=("shipgate.yaml", "policies/refunds.yaml"), + ) + findings = verify_policy.run(context) + assert len(findings) == 1 + assert findings[0].evidence["kind"] == "base_snapshot_unavailable" diff --git a/tests/test_fix_task_contract.py b/tests/test_fix_task_contract.py index d2f9aadd..d05cfa3c 100644 --- a/tests/test_fix_task_contract.py +++ b/tests/test_fix_task_contract.py @@ -767,11 +767,20 @@ def test_first_adoption_replaces_the_weakening_wording() -> None: assert not {"review_policy_weakening", "review_trust_root"} & repair_ids -def test_adoption_wording_needs_a_trust_root_signal() -> None: - """`manifest_introduced` alone must not invent an adoption instruction.""" +def test_adoption_escalates_without_borrowing_another_flag() -> None: + """An adoption is an authority decision in its own right. + + `policy_weakened` is honestly `false` during an adoption, so routing must + not depend on it: with no capability flags set at all, a mechanically + fixable finding must still route to a human rather than opening the + coding-agent auto-fix path. + """ + + f = _finding("F1", requires_human_review=False, autofix_safe=True) + report = _report(decision="review_required", findings=[f], review_items=[f]) task = build_fix_task( - _trust_root_report(), + report, merge_verdict="human_review_required", capability_review=_review(), base_ref="origin/main", @@ -780,4 +789,5 @@ def test_adoption_wording_needs_a_trust_root_signal() -> None: ) assert task is not None - assert "adopts Agents Shipgate" not in " ".join(task.instructions) + assert task.actor == "human" and task.safe_to_attempt is False + assert "adopts Agents Shipgate" in " ".join(task.instructions) diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index d859be5d..20df3125 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -949,12 +949,20 @@ def both() -> tuple[str | None, str | None]: def _record(tmp_path: Path, **overrides) -> None: + base_ref = overrides.pop("base_ref", None) payload = { "git_root": tmp_path, "config": "shipgate.yaml", "ci_mode": "advisory", - "base_ref": None, + "base_ref": base_ref, + "base_commit_before": _commit_sha(tmp_path, base_ref) if base_ref else None, "head_ref": None, + # As the CLI does it: captured before the scan, re-checked at write + # time so an edit during the scan cannot bind an old verdict to a new + # state. + "identity_before": worktree_identity(tmp_path), + "input_paths": ["shipgate.yaml"], + "input_set_id": "sha256:test", "decision": "review_required", "blockers": 0, "review_items": 1, @@ -968,6 +976,21 @@ def _record(tmp_path: Path, **overrides) -> None: record_verify_for_hooks(**payload) +def _commit_sha(repo: Path, ref: str) -> str: + return subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + cwd=repo, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +def _hook_state(repo: Path) -> dict: + path = repo / ".git" / "agents-shipgate-hooks-state.json" + return json.loads(path.read_text("utf-8")) if path.is_file() else {} + + def _verify_calls(log: Path) -> list[list[str]]: if not log.exists(): return [] @@ -1117,3 +1140,83 @@ def test_a_base_that_moved_since_the_verify_is_not_reused(tmp_path: Path) -> Non result, log = _stop_hook_with_record(tmp_path) assert result.returncode == 0, result.stderr assert len(_verify_calls(log)) == 1 + + +def test_an_empty_commit_invalidates_the_record(tmp_path: Path) -> None: + """Same tree, different commit — and verify reads the commit. + + Its date is the evaluation clock that expires overrides and + acknowledgements, so a digest over `HEAD^{tree}` alone would report a + verdict computed under a different clock. + """ + + _dirty_opted_in_repo(tmp_path) + before = worktree_identity(tmp_path) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "empty"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + assert worktree_identity(tmp_path) != before + + +def test_an_ignored_scan_input_is_never_recorded(tmp_path: Path) -> None: + """An ignored tool source is read by the scan and invisible to identity. + + It can change without moving the digest by a single byte, so a repository + that has one must not get a reuse record at all. + """ + + _dirty_opted_in_repo(tmp_path) + (tmp_path / ".gitignore").write_text("generated/\n", encoding="utf-8") + (tmp_path / "generated").mkdir() + (tmp_path / "generated" / "openapi.json").write_text("{}", encoding="utf-8") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True) + + _record(tmp_path, input_paths=["shipgate.yaml", "generated/openapi.json"]) + + assert "last_verify" not in _hook_state(tmp_path) + + +def test_a_worktree_edit_during_the_scan_is_not_recorded(tmp_path: Path) -> None: + """The verdict for the pre-edit tree must not be filed under the new state.""" + + _dirty_opted_in_repo(tmp_path) + stale_identity = worktree_identity(tmp_path) + (tmp_path / "prompts" / "refund.md").write_text( + "edited while the scan ran\n", encoding="utf-8" + ) + + _record(tmp_path, identity_before=stale_identity) + + assert "last_verify" not in _hook_state(tmp_path) + + +def test_the_hook_compares_the_effective_ci_mode_not_the_installed_flag( + tmp_path: Path, +) -> None: + """`AGENTS_SHIPGATE_VERIFY_CI_MODE` changes the question being asked.""" + + _dirty_opted_in_repo(tmp_path) + _record(tmp_path) + + 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}" + env["AGENTS_SHIPGATE_VERIFY_CI_MODE"] = "strict" + + result = subprocess.run( + [sys.executable, str(tmp_path / HOOK_SCRIPT_RELATIVE_PATH), "verify"], + input=json.dumps({"cwd": str(tmp_path)}), + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert len(_verify_calls(log)) == 1, "an advisory record cannot answer a strict run" From 187757267903174cafce8d2950c9a23fa256cdbd Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Tue, 28 Jul 2026 23:28:34 -0700 Subject: [PATCH 06/12] Address round two: drop reuse, protect the configured manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withdrew the Stop-hook verify reuse (#295) rather than patch it again. Findings 2 and 3 were both in that mechanism, and finding 3 explains why they keep appearing: its soundness rests on input_set_id being the complete input set, and it is not — adapter inputs such as openai_api.prompt_files never enter it, so an ignored one can change with neither the plan identity nor a worktree digest moving. The hook is a standalone script that cannot compute the real thing, so every cheap approximation leaks somewhere. It was a latency optimization with no correctness benefit, and two review rounds have now found real fail-opens in it. #295 stays open with these constraints recorded; the incomplete input set is filed separately because it also underpins receipts and attestations. 1. The trust-root table only knew **/shipgate.yaml, so a run pointed at --config new-gate.yml had no manifest trust root at all: its gate could be introduced or rewritten with an empty release substrate, and a clean scan then produced passed/mergeable/complete beneath an adoption headline claiming a human was required. Whatever a run loads as its gate is now classified as one, through a single shared predicate used by the trust-root check, the policy fail-safe, and the adoption wording — three sites that previously would have disagreed about path spelling. 4. Rerun commands now carry the whole evaluated request: policy packs, baseline and mode, diff reference, ci-mode, fail-on, plugin and heuristic flags, authorization, and an explicit --no-base. The last is the subtle one — without it a rerun auto-detects a base the evaluated run never compared against. 5. Preflight recovery reproduces the invocation that failed instead of recommending a bare `preflight --json`, which discarded the workspace, config, plan and diff and answered a question nobody asked. When the request came from stdin it offers a review action rather than a command it cannot reproduce. 6. Host-audit filesystem failures are other_error/4 per docs/errors.json on both --baseline-file and --out; a directory passed as --baseline-file no longer escapes as a traceback and exit 1. 7. The adoption repair names the resolved config path, not a hardcoded shipgate.yaml. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 47 +-- docs/agents/use-with-claude-code.md | 10 - src/agents_shipgate/checks/verify.py | 16 +- src/agents_shipgate/checks/verify_policy.py | 26 +- src/agents_shipgate/cli/host_audit.py | 71 +++- src/agents_shipgate/cli/install_hooks.py | 161 +-------- src/agents_shipgate/cli/preflight.py | 71 +++- src/agents_shipgate/cli/verify/fix_task.py | 16 +- src/agents_shipgate/cli/verify/git.py | 96 ----- src/agents_shipgate/cli/verify/hook_state.py | 161 --------- .../cli/verify/orchestrator.py | 187 +++++----- src/agents_shipgate/core/trust_roots.py | 27 ++ tests/test_adapter_static_only.py | 4 +- tests/test_agent_mode.py | 86 ++++- tests/test_first_adoption.py | 92 +++++ tests/test_install_hooks.py | 338 ------------------ 16 files changed, 481 insertions(+), 928 deletions(-) delete mode 100644 src/agents_shipgate/cli/verify/hook_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 913398f0..6c211c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ correction locally, keyed on the diff carrying exactly one manifest record and that record being a plain addition. Adoption remains a human decision — only the claim about what happened changed. +- **The manifest a run actually loaded is a trust root.** The trust-root table + only knew `**/shipgate.yaml`, so a repository run with `--config new-gate.yml` + had no manifest trust root at all: the file defining its gate could be + introduced or rewritten without a single finding, leaving the release + substrate empty. With a clean scan that produced `passed` / `mergeable` / + `complete` — beneath an adoption headline that said a human was required. + Whatever a run loads as its gate is now classified as one, in both the + trust-root check and the policy fail-safe. - **`check` detects which agent is running it.** `--agent` defaulted to `codex` and never consulted the harness variables Shipgate already reads to switch on agent mode, so every Claude Code and Cursor run recorded the wrong actor in @@ -30,32 +38,31 @@ mis-invoked them had to parse English. Each error path now emits the line with its `exit_code`, and `preflight` no longer lets an unexpected failure escape as a bare traceback. -- **One verify per turn (#295).** A governed turn verified twice: the coding - agent ran `verify` because the previous result told it to, and the Stop hook - then ran an identical `verify` because it had no way to know. `verify` now - records finished worktree runs in the git directory, next to the signature - cache the hook already keeps, and the hook reports that run instead of - repeating it. Reuse requires every input to match — config, effective CI - mode, base ref *and* the commit it resolved to, and a content digest of the - working tree covering HEAD's tree plus every changed and untracked file, so - an untracked edit that never appears in `git diff` still invalidates it. A - commit changes the digest by construction, which is the stale-pass the first - attempt at this was reverted for; it is now a regression test. Nothing is - read from the workspace, the reused result routes through the same switch a - fresh one does, and any mismatch or doubt re-verifies. A run is offered to the - hook only when it answered the hook's own question: default-shaped (no - baseline, diff reference, policy pack, authorization, fail-on set, or - non-default plugin/heuristic mode), with no git-ignored scan input — an - ignored tool source is read by the scan and invisible to the digest — and - with the working tree unmoved across the scan itself. Any other run clears - the record rather than leaving a stale one behind. - **A rerun command that actually reruns.** The `fix_task` verification command omitted `--config`, substituted `origin/main` for a base the run never used, and always appended `--head HEAD`. In a repository with a nested manifest it re-ran against a different gate, and for an uncommitted first adoption it switched to the committed tree — where the new manifest does not exist — and exited 2. It now emits the config always, the base only when one was used, - and no `--head` for a working-tree run. + and no `--head` for a working-tree run — plus the rest of the evaluated + request (policy packs, baseline, `--ci-mode`, plugin and heuristic modes, and + an explicit `--no-base`), because a rerun that drops them evaluates a + different question than the one whose findings it is meant to reproduce. The + structured adoption repair now names the resolved config path instead of a + hardcoded `shipgate.yaml`. +- **Preflight recovery keeps the request it failed on.** Every preflight error + recommended a bare `agents-shipgate preflight --json`, discarding workspace, + config, plan, diff, and capability request: following it after a failed + targeted run evaluated the current repository with an empty plan and returned + `control.state=complete`. The recovery action now reproduces the actual + invocation, and offers no command at all when the request came from stdin and + cannot be reproduced. +- **Host-audit filesystem failures follow the catalog.** A `--baseline-file` + naming a directory raised `IsADirectoryError` through typer as a traceback + and exit 1. Filesystem failures on both `--baseline-file` and `--out` are now + `other_error` with exit 4, as `docs/errors.json` specifies — they were + briefly reported as `config_error`/2, which sends an agent back to re-read + flags that were fine. - **A way out of `insufficient_evidence` (#292).** An abstention was unactionable in practice: the decision engine generated the exact manifest diff --git a/docs/agents/use-with-claude-code.md b/docs/agents/use-with-claude-code.md index 5f6dd0e4..3f5fc0df 100644 --- a/docs/agents/use-with-claude-code.md +++ b/docs/agents/use-with-claude-code.md @@ -245,16 +245,6 @@ Three hooks are installed: continuation would contradict. Unparseable or unrecognized verifier output warns loudly, is never cached, and is never treated as passing. - When the agent already ran `verify` itself — which is what the previous - turn's `next_action` asked for — the hook reports that run instead of - repeating it. Reuse requires every input to match, including a content - digest of the working tree (HEAD's tree plus the exact content of every - changed and untracked file), so a commit, an edit, a different `--config`, - or a base ref that moved all re-verify. The record lives in the git - directory next to the hook's own signature cache — never in the workspace, - which the agent under evaluation can write — and it carries no verdict a - fresh run would not have produced. - Local setup failures such as a missing CLI or unavailable base ref are surfaced as context, not as the release gate. CI remains authoritative, and changing the hook files or other Shipgate trust roots is itself diff --git a/src/agents_shipgate/checks/verify.py b/src/agents_shipgate/checks/verify.py index 3caa5795..81c533d9 100644 --- a/src/agents_shipgate/checks/verify.py +++ b/src/agents_shipgate/checks/verify.py @@ -31,6 +31,7 @@ _LEGACY_TRUST_ROOT_SURFACES, PROTECTED_FILE_EDITS, TRUST_ROOT_SURFACES, + is_configured_manifest, ) from agents_shipgate.schemas.common import ( SourceReference, @@ -53,7 +54,7 @@ def run(context: ScanContext) -> list[Finding]: if not path or path in seen: continue seen.add(path) - classification = _classify(path) + classification = _classify(path) or _configured_manifest(context, path) if classification is None: continue trust_root_class, matched_glob = classification @@ -70,6 +71,19 @@ def _classify(path: str) -> tuple[str, str] | None: return None +def _configured_manifest(context: ScanContext, path: str) -> tuple[str, str] | None: + """Classify the manifest this run was actually pointed at. + + Whatever a run loaded as its gate is a manifest trust root, even when it is + not called ``shipgate.yaml``. + """ + + config = getattr(context, "config_path", None) + if not is_configured_manifest(config, path): + return None + return "manifest", str(config).replace("\\", "/") + + def _finding( context: ScanContext, path: str, diff --git a/src/agents_shipgate/checks/verify_policy.py b/src/agents_shipgate/checks/verify_policy.py index 4d1cb0d5..a60341d8 100644 --- a/src/agents_shipgate/checks/verify_policy.py +++ b/src/agents_shipgate/checks/verify_policy.py @@ -39,7 +39,7 @@ verify_finding, ) from agents_shipgate.core.context import ScanContext -from agents_shipgate.core.trust_roots import trust_root_class_for +from agents_shipgate.core.trust_roots import is_configured_manifest, trust_root_class_for from agents_shipgate.schemas.report import Finding CHECK_ID = "SHIP-VERIFY-POLICY-WEAKENED" @@ -180,18 +180,38 @@ def _effective_severity( return overrides.get(check_id) or defaults.get(check_id) +def _touched_policy_surfaces(context: ScanContext) -> list[str]: + """Changed policy trust roots, including a non-default manifest name. + + ``_POLICY_SURFACES`` only knows ``**/shipgate.yaml``. A repository whose + gate is ``new-gate.yml`` was therefore invisible to this fail-safe: its + manifest could be introduced or rewritten with no base snapshot and this + check emitted nothing at all. + """ + + files = changed_files(context) + config = getattr(context, "config_path", None) + hits = set(touched(_POLICY_SURFACES, files)) + hits.update(path for path in files if is_configured_manifest(config, path)) + return sorted(hits) + + def _fail_safe(context: ScanContext) -> list[Finding]: """No base snapshot: emit review-required iff a policy root was touched.""" - hit = touched(_POLICY_SURFACES, changed_files(context)) + hit = _touched_policy_surfaces(context) if not hit: return [] verification = context.verification introducing = verification is not None and verification.manifest_introduced + config = getattr(context, "config_path", None) # An adoption that *also* edits an existing policy pack or baseline is not # covered by "nothing existed to weaken": those files were already there. # The friendlier wording is only correct when every touched policy surface # is the manifest being introduced. - if introducing and all(trust_root_class_for(path) == "manifest" for path in hit): + if introducing and all( + trust_root_class_for(path) == "manifest" or is_configured_manifest(config, path) + for path in hit + ): # A base with no manifest at all cannot have been weakened. This still # emits — at the same check id and severity, so the verdict and every # fail-closed consumer are unchanged — because the human decision diff --git a/src/agents_shipgate/cli/host_audit.py b/src/agents_shipgate/cli/host_audit.py index 36f204fb..36b7a0aa 100644 --- a/src/agents_shipgate/cli/host_audit.py +++ b/src/agents_shipgate/cli/host_audit.py @@ -28,6 +28,25 @@ from agents_shipgate.schemas.diagnostics import NextAction +def _io_error(message: str, *, next_action: str) -> typer.Exit: + """Report a filesystem failure on both channels. + + ``docs/errors.json`` gives filesystem and unexpected failures + ``other_error`` and exit 4; reporting them as ``config_error``/2 tells an + agent to go re-read its flags when the flags were fine and the path was + not writable. + """ + + typer.echo(message, err=True) + emit_agent_mode_error_action( + "other_error", + message=message, + exit_code=4, + action=NextAction(kind="review", why=next_action), + ) + return typer.Exit(4) + + def _config_error( message: str, *, @@ -155,14 +174,25 @@ def audit( ), ) from exc text = json.dumps(payload, indent=2, sort_keys=True) + "\n" - if resolved_baseline.is_file() and resolved_baseline.read_text( - encoding="utf-8" - ) == text: - status = "unchanged" - else: - status = "updated" if resolved_baseline.is_file() else "created" - resolved_baseline.parent.mkdir(parents=True, exist_ok=True) - resolved_baseline.write_text(text, encoding="utf-8") + try: + if resolved_baseline.is_file() and resolved_baseline.read_text( + encoding="utf-8" + ) == text: + status = "unchanged" + else: + status = "updated" if resolved_baseline.is_file() else "created" + resolved_baseline.parent.mkdir(parents=True, exist_ok=True) + resolved_baseline.write_text(text, encoding="utf-8") + except OSError as exc: + # --baseline-file naming a directory raised IsADirectoryError + # straight through typer: a traceback and exit 1. + raise _io_error( + f"Could not write the host-grants baseline {baseline_file}: {exc}", + next_action=( + "Point --baseline-file at a writable file path, then re-run " + "the audit." + ), + ) from exc outcome = { "baseline_file": str(baseline_file), "inventory_sha256": payload["inventory_sha256"], @@ -183,6 +213,14 @@ def audit( if drift: try: baseline = load_host_grants_baseline(resolved_baseline) + except OSError as exc: + raise _io_error( + f"Could not read the host-grants baseline {baseline_file}: {exc}", + next_action=( + "Point --baseline-file at a readable baseline file, then " + "re-run the audit." + ), + ) from exc except ValueError as exc: raise _config_error( str(exc), @@ -225,15 +263,14 @@ def _write_json_out(out: Path | None, payload: dict) -> None: json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) except OSError as exc: - # An unwritable --out is ordinary misuse (bad path, read-only mount), - # not a Shipgate defect: it was reaching the user as a Rich traceback - # and exit 1, which is neither the documented exit code nor something - # an agent can route on. - message = f"Could not write --out {out}: {exc}" - raise _config_error( - message, - next_action=message, - command="agents-shipgate audit --host --out ./host-audit.json", + # An unwritable --out reached the user as a Rich traceback and exit 1, + # which is neither the documented exit code nor something an agent can + # route on. + raise _io_error( + f"Could not write --out {out}: {exc}", + next_action=( + "Point --out at a writable file path, then re-run the audit." + ), ) from exc diff --git a/src/agents_shipgate/cli/install_hooks.py b/src/agents_shipgate/cli/install_hooks.py index 4f34d479..b8f47cd5 100644 --- a/src/agents_shipgate/cli/install_hooks.py +++ b/src/agents_shipgate/cli/install_hooks.py @@ -434,9 +434,6 @@ def _hook_script_text() -> str: VERIFY_TIMEOUT_SECONDS = 170 UNTRACKED_DIFF_CONTENT_LIMIT_BYTES = 131072 -# Mirror of agents_shipgate.cli.verify.git._MAX_IDENTITY_PATHS. Past this many -# changed paths the reuse digest is refused and the hook verifies for itself. -MAX_IDENTITY_PATHS = 500 _MAX_REMEMBERED_SURFACES = 256 _MAX_REMEMBERED_SESSIONS = 8 # Host permission modes that answer a hook's permission request without asking @@ -756,20 +753,6 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in ) base = "" - reused = _reusable_verify(root, args, base=base, head=head, ci_mode=ci_mode) - if reused is not None: - return _route_control( - decision=str(reused.get("decision") or "unknown"), - blockers=int(reused.get("blockers") or 0), - review_items=int(reused.get("review_items") or 0), - control=reused.get("control") or {}, - root=root, - args=args, - signature=signature, - base_note=base_note, - stop_hook_active=stop_hook_active, - ) - command = [ *_cli(), "verify", @@ -838,32 +821,10 @@ def _route_verify_result( base_note: str, stop_hook_active: bool, ) -> int: - release = verifier.get("release_decision") or {} - return _route_control( - decision=release.get("decision") or "unknown", - blockers=len(release.get("blockers") or []), - review_items=len(release.get("review_items") or []), - control=verifier.get("control") if isinstance(verifier.get("control"), dict) else {}, - root=root, - args=args, - signature=signature, - base_note=base_note, - stop_hook_active=stop_hook_active, - ) - - -def _route_control( - *, - decision: str, - blockers: int, - review_items: int, - control: dict[str, Any], - root: Path, - args: argparse.Namespace, - signature: str, - base_note: str, - stop_hook_active: bool, -) -> int: + 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 []) + 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}" @@ -1109,120 +1070,6 @@ def _diff_context(root: Path, revspec: str) -> tuple[list[str], str] | None: return paths, body.stdout -def _worktree_identity(root: Path) -> str | None: - """A digest of the working-tree state a verify run would read. - - Must stay byte-compatible with - ``agents_shipgate.cli.verify.git.worktree_identity`` — the CLI records a - finished run under the digest it computed, and this hook only reuses that - run when its own digest matches. A test compares the two implementations - on a live repository. If they ever drift, the digests stop matching and - the hook simply verifies for itself, which is the behavior reuse replaced. - - ``None`` means identity could not be established. It never means - "unchanged". - """ - - head = _run_git(root, ["rev-parse", "HEAD", "HEAD^{tree}"]) - if head is None or head.returncode != 0: - return None - changed = _run_git(root, ["diff", "HEAD", "--name-only"]) - untracked = _run_git(root, ["ls-files", "--others", "--exclude-standard"]) - if changed is None or untracked is None: - return None - if changed.returncode != 0 or untracked.returncode != 0: - return None - paths = sorted( - { - line.strip() - for line in (*changed.stdout.splitlines(), *untracked.stdout.splitlines()) - if line.strip() - } - ) - if len(paths) > MAX_IDENTITY_PATHS: - return None - present = [path for path in paths if (root / path).is_file()] - blobs: dict[str, str] = {} - if present: - hashed = _run_git(root, ["hash-object", "--", *present]) - if hashed is None or hashed.returncode != 0: - return None - lines = hashed.stdout.split() - if len(lines) != len(present): - return None - blobs = dict(zip(present, lines)) - payload = json.dumps( - { - # The commit, not only its tree: an empty commit leaves the tree - # identical while moving HEAD, and verify reads the commit date as - # its evaluation clock. - "head": head.stdout.split(), - "files": [[path, blobs.get(path, "absent")] for path in paths], - }, - sort_keys=True, - separators=(",", ":"), - ) - return hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _reusable_verify( - root: Path, - args: argparse.Namespace, - *, - base: str, - head: str, - ci_mode: str, -) -> dict | None: - """The agent's own verify run, when it provably still describes this state. - - A governed turn asks the coding agent to run verify and then runs an - identical verify here, which changes nothing and costs a second of every - turn. The CLI records its finished worktree runs next to this hook's own - signature cache; this reuses one only when every input matches, including - a content digest of the working tree. A commit, an edit, a different - config, or a moved base all fail the comparison and re-verify. - - The comparison is against the *effective* base, head and ci-mode — the - values this hook is about to invoke verify with after applying the - AGENTS_SHIPGATE_VERIFY_* overrides and dropping an unavailable base — not - the raw installed arguments. Comparing the raw arguments would accept a - record produced under a different question than the one being asked. - """ - - record = _read_state(root).get("last_verify") - if not isinstance(record, dict): - return None - if record.get("config") != args.config: - return None - if (record.get("ci_mode") or "") != (ci_mode or ""): - return None - if (record.get("base_ref") or "") != base: - return None - if (record.get("head_ref") or "") != (head or ""): - return None - recorded_base = record.get("base_commit") or "" - if base: - # Same spelling as the CLI's commit_sha, so a tag and the commit it - # points at cannot compare unequal for spurious reasons. - resolved = _run_git(root, ["rev-parse", "--verify", "--quiet", f"{base}^{{commit}}"]) - current = ( - resolved.stdout.strip() - if resolved is not None and resolved.returncode == 0 - else "" - ) - if not recorded_base or recorded_base != current: - return None - elif recorded_base: - return None - identity = _worktree_identity(root) - if identity is None or identity != record.get("identity"): - return None - control = record.get("control") - if not isinstance(control, dict) or not control.get("state"): - return None - return record - - def _state_path(root: Path) -> Path | None: completed = _run_git(root, ["rev-parse", "--git-path", "agents-shipgate-hooks-state.json"]) if completed is None or completed.returncode != 0: diff --git a/src/agents_shipgate/cli/preflight.py b/src/agents_shipgate/cli/preflight.py index c61be099..be633a88 100644 --- a/src/agents_shipgate/cli/preflight.py +++ b/src/agents_shipgate/cli/preflight.py @@ -2,6 +2,7 @@ import json import logging +import shlex import sys from pathlib import Path from typing import Any @@ -26,13 +27,39 @@ logger = logging.getLogger(__name__) +def _rerun_command(**flags: object) -> str | None: + """The failed invocation, spelled out so rerunning it means the same thing. + + Every error used to recommend a bare ``agents-shipgate preflight --json``, + which discards the workspace, config, plan, diff and capability request. An + agent that followed it evaluated the *current* repository with an empty + plan and got ``control.state=complete`` — a clean answer to a question + nobody asked. When the request cannot be reproduced from stdin ("-"), no + command is offered at all: a review action is honest, a wrong command is + not. + """ + + parts = ["agents-shipgate", "preflight"] + for name, value in flags.items(): + if value is None or value is False: + continue + if value is True: + parts.append(f"--{name.replace('_', '-')}") + continue + text = str(value) + if text == "-": + return None + parts.extend([f"--{name.replace('_', '-')}", shlex.quote(text)]) + return " ".join(parts) + + def _agent_mode_exit( error_kind: str, exc: BaseException, *, exit_code: int, next_action: str, - command: str = "agents-shipgate preflight --json", + command: str | None, ) -> typer.Exit: """Emit the structured agent-mode error line and return the exit to raise. @@ -40,16 +67,21 @@ def _agent_mode_exit( an id an agent cannot look up is no better than prose. """ + action = ( + NextAction( + kind="command", + command=command, + why=next_action, + expects="A preflight run of the same request that completes.", + ) + if command + else NextAction(kind="review", why=next_action) + ) emit_agent_mode_error_action( error_kind, message=str(exc), exit_code=exit_code, - action=NextAction( - kind="command", - command=command, - why=next_action, - expects="A preflight run that completes and returns its plan.", - ), + action=action, ) return typer.Exit(exit_code) @@ -105,6 +137,18 @@ def preflight( ) -> None: """Run the proactive static preflight contract for coding agents.""" + rerun = _rerun_command( + workspace=workspace, + config=config, + changed_files=changed_files, + diff=diff, + capability_request=capability_request, + plan=plan, + base_preflight=base_preflight, + host_baseline=host_baseline, + json=json_output, + ) + try: configure_logging(verbose=verbose) if plan is not None: @@ -149,8 +193,9 @@ def preflight( exit_code=2, next_action=( "Fix the manifest or flag value named in the error, then re-run " - "`agents-shipgate preflight`." + "the same preflight request." ), + command=rerun, ) from exc except InputParseError as exc: typer.echo(f"Input parsing error: {exc}", err=True) @@ -160,8 +205,9 @@ def preflight( exit_code=3, next_action=( "Correct the diff, changed-files list, or request JSON named in " - "the error, then re-run `agents-shipgate preflight`." + "the error, then re-run the same preflight request." ), + command=rerun, ) from exc except AgentsShipgateError as exc: typer.echo(f"Agents Shipgate error: {exc}", err=True) @@ -169,7 +215,8 @@ def preflight( "other_error", exc, exit_code=4, - next_action="Resolve the error above, then re-run `agents-shipgate preflight`.", + next_action="Resolve the error above, then re-run the same preflight request.", + command=rerun, ) from exc except OSError as exc: typer.echo(f"Input error: {exc}", err=True) @@ -179,8 +226,9 @@ def preflight( exit_code=3, next_action=( "Make the input file readable at the path given, then re-run " - "`agents-shipgate preflight`." + "the same preflight request." ), + command=rerun, ) from exc except Exception as exc: # noqa: BLE001 - agents must never see a bare traceback. # Every other command reports unexpected failures on the agent channel; @@ -199,6 +247,7 @@ def preflight( "Report this failure with the command line above; preflight " "could not complete." ), + command=None, ) from exc payload = result.model_dump(mode="json") diff --git a/src/agents_shipgate/cli/verify/fix_task.py b/src/agents_shipgate/cli/verify/fix_task.py index d41b7986..daf579db 100644 --- a/src/agents_shipgate/cli/verify/fix_task.py +++ b/src/agents_shipgate/cli/verify/fix_task.py @@ -13,6 +13,7 @@ from __future__ import annotations import shlex +from collections.abc import Sequence from agents_shipgate.ci.release_decision import ( _inventory_manifest_key, @@ -75,6 +76,7 @@ def build_fix_task( manifest_introduced: bool = False, config: str | None = None, worktree: bool = False, + rerun_options: Sequence[str] | None = None, ) -> VerifierFixTask | None: """Project the head scan onto a single repair task. @@ -91,7 +93,7 @@ def build_fix_task( return None verification_command = _verification_command( - base_ref, head_ref, config=config, worktree=worktree + base_ref, head_ref, config=config, worktree=worktree, options=rerun_options ) # No completed head decision (scan skipped/failed → ``unknown``) but the PR @@ -189,6 +191,7 @@ def build_fix_task( gating, verification_command=verification_command, manifest_introduced=manifest_introduced, + config=config, ), forbidden_repairs=_forbidden_repairs(gating), forbidden_shortcuts=list(FORBIDDEN_SHORTCUTS), @@ -406,6 +409,7 @@ def _human_repairs( *, verification_command: str, manifest_introduced: bool = False, + config: str | None = None, ) -> list[VerifierRepair]: decision = report.release_decision assert decision is not None @@ -416,7 +420,7 @@ def _human_repairs( id="adopt_shipgate_manifest", actor="human", kind="review_trust_root_change", - target="shipgate.yaml", + target=config or "shipgate.yaml", reason=( "This PR introduces the Shipgate manifest; a human must " "review it and merge the adoption." @@ -430,7 +434,7 @@ def _human_repairs( id="review_policy_weakening", actor="human", kind="review_policy_change", - target="shipgate.yaml", + target=config or "shipgate.yaml", reason="A human must approve release-policy weakening before merge.", ) ) @@ -569,6 +573,7 @@ def _verification_command( *, config: str | None = None, worktree: bool = False, + options: Sequence[str] | None = None, ) -> str: """The exact command that re-runs *this* verification. @@ -584,6 +589,10 @@ def _verification_command( - ``--head`` is omitted for a working-tree run. Passing ``HEAD`` switches verify to the committed tree, which for an uncommitted first adoption is a tree with no manifest in it — the command exits 2. + - ``options`` carries the rest of the evaluated request — policy packs, a + baseline, an explicit ``--no-base``, plugin and heuristic modes. Omitting + them produced a command that ran a *different* evaluation than the one + whose findings it was supposed to reproduce. Refs and paths come from CLI / GitHub inputs and a valid git ref may contain shell metacharacters, so every interpolated value is quoted. @@ -596,6 +605,7 @@ def _verification_command( parts.extend(["--base", shlex.quote(base_ref)]) if not worktree: parts.extend(["--head", shlex.quote(head_ref or "HEAD")]) + parts.extend(options or ()) parts.append("--json") return " ".join(parts) diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 6ffdbba5..391f59cb 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -1,7 +1,6 @@ from __future__ import annotations import hashlib -import json import os import re import subprocess @@ -368,101 +367,6 @@ def read_file_at_ref(workspace: Path, ref: str, path: Path) -> str | None: return None return result.stdout - -# Cap on how many changed paths a worktree identity will cover. Past this the -# `git hash-object` argv gets unwieldy, and a change set that large is not the -# one-second rerun this identity exists to skip. Refusing identity is always -# safe: the caller just re-verifies. -_MAX_IDENTITY_PATHS = 500 - - -def worktree_identity(workspace: Path) -> str | None: - """A digest of the working-tree state a verify run would read. - - HEAD's tree, plus the exact content of every tracked-and-changed and - untracked file. Equal digests mean two runs looked at the same repository - state, which is what lets an already-computed verdict be reported for a - later moment instead of recomputed. - - Deliberately built from content hashes rather than diff text: the diff a - consumer produces depends on how it was asked for it (path filters, - untracked handling, size limits), and an untracked file's *content* does - not appear in `git diff HEAD` at all — so a digest over diff text would - call two different worktrees identical. - - Returns ``None`` when identity cannot be established (no commit yet, a git - failure, or too many changed paths). Callers must re-verify on ``None``; - they must never treat it as "unchanged". - """ - - head = _run_git(workspace, ["rev-parse", "HEAD", "HEAD^{tree}"], check=False) - if head.returncode != 0: - return None - changed = _run_git(workspace, ["diff", "HEAD", "--name-only"], check=False) - untracked = _run_git( - workspace, ["ls-files", "--others", "--exclude-standard"], check=False - ) - if changed.returncode != 0 or untracked.returncode != 0: - return None - paths = sorted( - { - line.strip() - for line in (*changed.stdout.splitlines(), *untracked.stdout.splitlines()) - if line.strip() - } - ) - if len(paths) > _MAX_IDENTITY_PATHS: - return None - present = [path for path in paths if (workspace / path).is_file()] - blobs: dict[str, str] = {} - if present: - hashed = _run_git(workspace, ["hash-object", "--", *present], check=False) - if hashed.returncode != 0: - return None - lines = hashed.stdout.split() - if len(lines) != len(present): - return None - blobs = dict(zip(present, lines, strict=True)) - payload = json.dumps( - { - # The commit, not only its tree. An empty commit leaves the tree - # byte-identical while moving HEAD — and verify reads the commit: - # its date is the evaluation clock that expires overrides and - # acknowledgements, and its ancestry decides the base. - "head": head.stdout.split(), - # "absent" covers a deleted tracked file, which has no blob but is - # every bit a change. - "files": [[path, blobs.get(path, "absent")] for path in paths], - }, - sort_keys=True, - separators=(",", ":"), - ) - return hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def ignored_paths(workspace: Path, paths: list[str]) -> list[str] | None: - """Which of ``paths`` git ignores, or ``None`` when that cannot be decided. - - A worktree identity is built from HEAD's tree plus every changed and - untracked file, which covers everything git tracks or reports — but not an - ignored file. A declared tool source (an OpenAPI spec, an MCP export) can - be ignored and still be read by the scan, and it would then change without - moving the identity at all. Callers use this to refuse a reuse record - rather than to paper over it. - """ - - if not paths: - return [] - if len(paths) > _MAX_IDENTITY_PATHS: - return None - result = _run_git(workspace, ["check-ignore", "--", *paths], check=False) - if result.returncode == 1: # documented: nothing matched - return [] - if result.returncode != 0: - return None - return [line.strip() for line in result.stdout.splitlines() if line.strip()] - - def paths_named_at_ref( workspace: Path, ref: str, names: frozenset[str] ) -> list[str] | None: diff --git a/src/agents_shipgate/cli/verify/hook_state.py b/src/agents_shipgate/cli/verify/hook_state.py deleted file mode 100644 index ecdfc60c..00000000 --- a/src/agents_shipgate/cli/verify/hook_state.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Hand the installed Stop hook the verify run the agent already did. - -A governed turn verifies twice: the coding agent runs ``verify`` because the -previous result told it to, and then the Stop hook runs an identical ``verify`` -because it has no way to know that happened. The second run changes nothing and -costs the user a second or more of every turn. - -This records the finished run where the hook already keeps its own state — the -git directory, alongside ``last_verified_signature`` — so the hook can *report* -that result instead of recomputing it, but only when it can prove the -repository has not moved and that the run it is reporting is the run it would -otherwise have performed. - -Three rules this module exists to keep: - -- **Never the workspace.** An earlier attempt read ``verifier.json`` out of the - reports directory, which anything in the workspace can write — including the - agent whose work is being judged. The git directory is the same trust tier as - the signature cache the hook already keeps: forging it can only make an - advisory hook echo a forged state, exactly as forging - ``last_verified_signature`` already makes it skip verification. PR-time verify - and CI read none of this. -- **Never more trusted than fresh.** The record carries no verdict the hook - would not have gotten from a fresh run, and the hook routes it through the - same switch. On any mismatch — or any doubt — the hook re-verifies. -- **Only a run the hook could have produced.** The hook invokes verify with - workspace, config, base, head and ci-mode and nothing else. A run that used a - baseline, a diff reference, policy packs, an authorization file, a fail-on - set, or non-default plugin/heuristic modes answered a *different* question, - so it is never recorded. Same for a run whose inputs the hook's identity - cannot see (an ignored tool source) or whose worktree moved mid-scan. -""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -from typing import Any - -from agents_shipgate.cli.verify.git import ( - commit_sha, - git_path, - ignored_paths, - worktree_identity, -) - -STATE_FILENAME = "agents-shipgate-hooks-state.json" -RECORD_KEY = "last_verify" - - -def record_verify_for_hooks( - *, - git_root: Path, - config: str, - ci_mode: str, - base_ref: str | None, - base_commit_before: str | None, - head_ref: str | None, - identity_before: str | None, - input_paths: list[str], - input_set_id: str | None, - decision: str, - blockers: int, - review_items: int, - control: dict[str, Any], -) -> None: - """Record a completed default-shaped worktree verify for the Stop hook. - - ``identity_before`` is the worktree identity captured *before* the scan - started. Recomputing it here and requiring the two to match closes the - window where an edit lands while the scan is running: without it, the - verdict for the pre-edit tree would be filed under the post-edit state, and - the hook would report a result for work it never saw. ``base_commit_before`` - does the same for a base ref that advances mid-scan. - - Fail-soft by construction: any problem skips the record and the hook simply - runs its own verify, which is the behavior this replaces. - """ - - try: - if identity_before is None: - return - if worktree_identity(git_root) != identity_before: - return - if base_ref: - current_base = commit_sha(git_root, base_ref) - if not current_base or current_base != base_commit_before: - return - elif base_commit_before: - return - # An ignored input is read by the scan and invisible to the identity, - # so a repository that has one can never reuse. Refusing the record is - # the whole mitigation: the hook needs no knowledge of the manifest. - ignored = ignored_paths(git_root, input_paths) - if ignored is None or ignored: - return - record = { - "identity": identity_before, - "input_set_id": input_set_id, - "config": config, - "ci_mode": ci_mode, - "base_ref": base_ref or "", - "base_commit": base_commit_before or "", - "head_ref": head_ref or "", - "decision": decision, - "blockers": blockers, - "review_items": review_items, - "control": control, - } - state = _read_state(git_root) - state[RECORD_KEY] = record - _write_state(git_root, state) - except Exception: # noqa: BLE001 - an advisory optimization never fails a run. - return - - -def discard_hook_verify_record(git_root: Path) -> None: - """Drop any recorded run — used when this run must not be reusable.""" - - try: - state = _read_state(git_root) - if state.pop(RECORD_KEY, None) is not None: - _write_state(git_root, state) - except Exception: # noqa: BLE001 - advisory only. - return - - -def _state_file(git_root: Path) -> Path: - return git_path(git_root, STATE_FILENAME) - - -def _read_state(git_root: Path) -> dict[str, Any]: - path = _state_file(git_root) - if not path.is_file(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError): - return {} - return data if isinstance(data, dict) else {} - - -def _write_state(git_root: Path, data: dict[str, Any]) -> None: - # Merge-then-atomic-replace, matching the hook: the same file carries the - # hook's verification signature and the session's approved surfaces, and a - # torn advisory cache is worse than a stale one. - path = _state_file(git_root) - path.parent.mkdir(parents=True, exist_ok=True) - payload = json.dumps(data, indent=2, sort_keys=True) + "\n" - temp = path.with_name(f"{path.name}.{os.getpid()}.tmp") - temp.write_text(payload, encoding="utf-8") - os.replace(temp, path) - - -__all__ = [ - "RECORD_KEY", - "STATE_FILENAME", - "discard_hook_verify_record", - "record_verify_for_hooks", -] diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index 4c10cd86..43d6c230 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -102,9 +102,7 @@ resolve_source_head_identity, tree_sha, working_tree_context, - worktree_identity, ) -from .hook_state import discard_hook_verify_record, record_verify_for_hooks HEAD_FORMATS = ["markdown", "json", "sarif"] # Verify owns the PR artifact contract and writes packet.json only; the @@ -154,6 +152,23 @@ def run_verify( verify_run_path = out_dir / "verify-run.json" pr_comment_path = out_dir / "pr-comment.md" + rerun_options = _rerun_options( + git_root=git_root, + base=base, + auto_base=auto_base, + ci_mode=ci_mode, + fail_on=fail_on, + baseline_path=baseline_path, + baseline_mode=baseline_mode, + diff_from=diff_from, + policy_pack_paths=policy_pack_paths, + plugins_enabled=plugins_enabled, + strict_plugins=strict_plugins, + suggest_patches=suggest_patches, + no_heuristics=no_heuristics, + authorization=authorization, + ) + if not config_path.is_file(): trigger = evaluate( paths=[], @@ -193,6 +208,7 @@ def run_verify( ), ), worktree=not archive_head, + rerun_options=rerun_options, ) _remove_scan_artifacts(out_dir) _write_artifacts( @@ -254,6 +270,7 @@ def run_verify( why="Make the requested head ref available locally, then rerun verify.", ), worktree=not archive_head, + rerun_options=rerun_options, ) _remove_scan_artifacts(out_dir) _write_artifacts( @@ -339,6 +356,7 @@ def run_verify( out_dir=out_dir, ci_mode=ci_mode, worktree=not archive_head, + rerun_options=rerun_options, ) if diff_unavailable: @@ -360,6 +378,7 @@ def run_verify( out_dir=out_dir, ci_mode=ci_mode, worktree=not archive_head, + rerun_options=rerun_options, ) _write_artifacts( verifier, @@ -399,6 +418,7 @@ def run_verify( out_dir=out_dir, ci_mode=ci_mode, worktree=not archive_head, + rerun_options=rerun_options, ) _write_artifacts( verifier, @@ -441,28 +461,6 @@ def run_verify( ) base_notes.extend(cache_notes) - # Captured before the scan starts, so a worktree edit or a base ref that - # advances *while* the scan runs is detectable afterwards. Without this the - # verdict for the pre-edit tree would be filed under the post-edit state. - identity_before = worktree_identity(git_root) if not archive_head else None - base_commit_before = commit_sha(git_root, base) if base else None - # The Stop hook invokes verify with workspace/config/base/head/ci-mode and - # nothing else. A run carrying any other gate-affecting input answered a - # different question and must never stand in for the hook's own. - default_shaped = ( - baseline is None - and diff_from is None - and not policy_pack_paths - and authorization is None - and plugins_enabled is None - and not strict_plugins - and not no_heuristics - and not suggest_patches - and not fail_on - and baseline_mode == "new-findings" - ) - scan_input_paths: list[str] = [] - manifest_introduced = _manifest_introduced( git_root=git_root, config_relative=config_relative, @@ -590,6 +588,7 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: ci_mode=ci_mode, manifest_introduced=manifest_introduced, worktree=not archive_head, + rerun_options=rerun_options, ) try: try: @@ -624,7 +623,6 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: ), }, evaluation_date=verification_date, - scan_input_paths=scan_input_paths, ) except Exception: if scan_error is None: @@ -635,73 +633,9 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: finally: if head_tmp is not None: head_tmp.cleanup() - _record_or_discard_hook_verify( - git_root=git_root, - verifier=verifier, - head_status=head_status, - archive_head=archive_head, - config_relative=config_relative, - identity_before=identity_before, - base_commit_before=base_commit_before, - default_shaped=default_shaped, - scan_input_paths=scan_input_paths, - ) return verifier, report, head_exit_code -def _record_or_discard_hook_verify( - *, - git_root: Path, - verifier: VerifierArtifact, - head_status: str, - archive_head: bool, - config_relative: Path, - identity_before: str | None, - base_commit_before: str | None, - default_shaped: bool, - scan_input_paths: list[str], -) -> None: - """Offer this run to the Stop hook, or make sure nothing stale is offered. - - Discarding matters as much as recording: a failed or differently-shaped run - must not leave an earlier record in place for the hook to find, because the - repository has usually moved since that record was written and "no record" - is the safe state. - """ - - reusable = ( - head_status == "succeeded" - and not archive_head - and default_shaped - and verifier.control.state is not None - ) - if not reusable: - discard_hook_verify_record(git_root) - return - release = verifier.release_decision - record_verify_for_hooks( - git_root=git_root, - config=config_relative.as_posix(), - # The *effective* mode, not the flag: a forced --ci-mode rewrites the - # manifest's mode for the run, and the policy-weakening check compares - # that value against the base, so two runs under different effective - # modes can reach different findings. - ci_mode=verifier.mode, - base_ref=verifier.base_ref, - base_commit_before=base_commit_before, - head_ref=None, - identity_before=identity_before, - input_paths=scan_input_paths, - input_set_id=verifier.input_set_id, - decision=release.decision if release is not None else "unknown", - blockers=len(release.blockers) if release is not None else 0, - review_items=len(release.review_items) if release is not None else 0, - control=verifier.control.model_dump(mode="json"), - ) - - - - def _prepare_base_report( *, git_root: Path, @@ -1021,6 +955,63 @@ def _map_optional_tree_path( _MANIFEST_FILE_NAMES = frozenset({"shipgate.yaml"}) +def _rerun_options( + *, + git_root: Path, + base: str | None, + auto_base: bool, + ci_mode: str | None, + fail_on: list[str] | None, + baseline_path: Path | None, + baseline_mode: str, + diff_from: Path | None, + policy_pack_paths: list[Path] | None, + plugins_enabled: bool | None, + strict_plugins: bool, + suggest_patches: bool, + no_heuristics: bool, + authorization: Path | None, +) -> list[str]: + """The rest of this run's request, as flags a rerun must repeat. + + A rerun command that drops the policy packs, baseline, or heuristic mode + evaluates something other than the run whose findings it is meant to + reproduce — it can come back clean on inputs the real run never used. The + base is the subtle one: with no ``--base`` and auto-detection disabled, + omitting ``--no-base`` lets the rerun auto-detect a branch the evaluated + run never compared against. + """ + + options: list[str] = [] + if base is None and not auto_base: + options.append("--no-base") + if ci_mode: + options.extend(["--ci-mode", shlex.quote(ci_mode)]) + if fail_on: + options.extend(["--fail-on", shlex.quote(",".join(fail_on))]) + if baseline_path is not None: + options.extend(["--baseline", shlex.quote(_display_path(baseline_path, git_root))]) + if baseline_mode and baseline_mode != "new-findings": + options.extend(["--baseline-mode", shlex.quote(baseline_mode)]) + if diff_from is not None: + options.extend(["--diff-from", shlex.quote(_display_path(diff_from, git_root))]) + for pack in policy_pack_paths or []: + options.extend(["--policy-pack", shlex.quote(_display_path(pack, git_root))]) + if plugins_enabled is False: + options.append("--no-plugins") + if strict_plugins: + options.append("--strict-plugins") + if suggest_patches: + options.append("--suggest-patches") + if no_heuristics: + options.append("--no-heuristics") + if authorization is not None: + options.extend( + ["--authorization", shlex.quote(_display_path(authorization, git_root))] + ) + return options + + def _manifest_introduced( *, git_root: Path, @@ -1423,6 +1414,7 @@ def _build_verifier( first_next_action_override: AgentControlAction | None = None, manifest_introduced: bool = False, worktree: bool = False, + rerun_options: list[str] | None = None, ) -> VerifierArtifact: release_decision_model = report.release_decision if report is not None else None release_decision = ( @@ -1462,6 +1454,7 @@ def _build_verifier( manifest_introduced=manifest_introduced, config=_display_path(config_path, git_root), worktree=worktree, + rerun_options=rerun_options, ) ) can_merge = _can_merge_without_human( @@ -1832,17 +1825,7 @@ def _write_artifacts( authorization_path: Path | None = None, verification_options: dict[str, Any] | None = None, evaluation_date: str | None = None, - scan_input_paths: list[str] | None = None, ) -> None: - """Write the run's artifacts. - - ``scan_input_paths`` is an out-parameter: when a list is passed, the - workspace-relative path of every file the verification plan counted as an - input is appended to it. The Stop-hook reuse record needs that list to ask - git whether any input is ignored, and the plan is the only place that knows - it. - """ - verifier_path.parent.mkdir(parents=True, exist_ok=True) verifier_path.write_text( json.dumps(verifier.model_dump(mode="json"), indent=2), @@ -1991,12 +1974,6 @@ def _write_artifacts( "can_merge_without_human": verifier.can_merge_without_human, } ) - if scan_input_paths is not None: - scan_input_paths.extend( - blob.path - for blob in (plan.inputs.config, *plan.inputs.tool_sources) - if getattr(blob, "path", None) - ) verifier.request_id = plan.request_id verifier.subject_id = plan.subject.subject_id verifier.input_set_id = plan.inputs.input_set_id diff --git a/src/agents_shipgate/core/trust_roots.py b/src/agents_shipgate/core/trust_roots.py index e0800e38..a6014897 100644 --- a/src/agents_shipgate/core/trust_roots.py +++ b/src/agents_shipgate/core/trust_roots.py @@ -107,6 +107,33 @@ def trust_root_class_for(path: str) -> str | None: return None +def is_configured_manifest(config_path: object | None, path: str) -> bool: + """Whether a changed-file path is the manifest *this run* loaded as its gate. + + The table above only knows ``**/shipgate.yaml``. A repository pointed at + ``--config new-gate.yml`` therefore had no manifest trust root at all: the + file defining its gate could be added or rewritten without a finding, and + the release substrate carried nothing for the merge projection to fail + closed on. Whatever a run loaded as the gate *is* the gate. + + The comparison tolerates the two spellings that reach it — the absolute + resolved config path a scan carries, and the workspace-relative changed + path — while refusing a same-basename file in another directory. + """ + + if config_path is None: + return False + configured = str(config_path).replace("\\", "/").strip() + candidate = path.replace("\\", "/").strip() + if not configured or not candidate: + return False + return ( + candidate == configured + or configured.endswith(f"/{candidate}") + or candidate.endswith(f"/{configured}") + ) + + __all__ = [ "PROTECTED_FILE_EDITS", "TRUST_ROOT_SURFACES", diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index ac751013..467bcc51 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -291,7 +291,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="import:subprocess", - line=7, + line=6, snippet="import subprocess", rationale=( "Verify uses local git commands to resolve refs, collect diffs, " @@ -303,7 +303,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=767, + line=671, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " diff --git a/tests/test_agent_mode.py b/tests/test_agent_mode.py index 11c07073..93a0b2e0 100644 --- a/tests/test_agent_mode.py +++ b/tests/test_agent_mode.py @@ -467,10 +467,14 @@ def test_preflight_silent_without_agent_mode(tmp_path: Path) -> None: assert not [line for line in result.output.splitlines() if line.startswith("{")] -def test_audit_reports_an_unwritable_out_path_as_config_error( +def test_audit_reports_an_unwritable_out_path_as_other_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An unwritable --out was a Rich traceback and exit 1, not a contract.""" + """An unwritable --out was a Rich traceback and exit 1, not a contract. + + The catalog gives filesystem failures `other_error`/4; reporting them as + `config_error`/2 sends an agent back to re-read flags that were fine. + """ monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") blocker = tmp_path / "blocker" @@ -489,9 +493,10 @@ def test_audit_reports_an_unwritable_out_path_as_config_error( ], ) - assert result.exit_code == 2 + assert result.exit_code == 4 payload = _agent_mode_error(result) - assert payload["error"] == "config_error" + assert payload["error"] == "other_error" + assert payload["exit_code"] == 4 _assert_documented_envelope(payload) @@ -508,3 +513,76 @@ def test_preflight_reports_only_documented_error_kinds( assert result.exit_code != 0 _assert_documented_envelope(_agent_mode_error(result)) + + +def test_preflight_recovery_reruns_the_same_request( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A bare `preflight --json` answers a question nobody asked. + + It discards workspace, config, plan, diff and capability request, so + following it after a failed targeted run evaluates the current repository + with an empty plan and reports `control.state=complete`. + """ + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + (tmp_path / "shipgate.yaml").write_text( + 'version: "1"\nproject:\n name: [broken\n', encoding="utf-8" + ) + result = runner.invoke( + app, ["preflight", "--workspace", str(tmp_path), "--config", "shipgate.yaml"] + ) + + assert result.exit_code == 2 + action = _agent_mode_error(result)["next_actions"][0] + assert action["kind"] == "command" + assert str(tmp_path) in action["command"] + assert "--config shipgate.yaml" in action["command"] + + +def test_preflight_offers_no_command_it_cannot_reproduce( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A request read from stdin cannot be rerun; a review action is honest.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + (tmp_path / "shipgate.yaml").write_text( + 'version: "1"\nproject:\n name: [broken\n', encoding="utf-8" + ) + result = runner.invoke( + app, + ["preflight", "--workspace", str(tmp_path), "--plan", "-"], + input="{}\n", + ) + + assert result.exit_code != 0 + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + assert payload["next_actions"][0]["kind"] == "review" + + +def test_audit_baseline_directory_is_an_other_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """IsADirectoryError reached the user as a traceback and exit 1.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + (tmp_path / "as-a-dir").mkdir() + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--save-baseline", + "--baseline-file", + str(tmp_path / "as-a-dir"), + ], + ) + + assert result.exit_code == 4, result.output + payload = _agent_mode_error(result) + assert payload["error"] == "other_error" + assert payload["exit_code"] == 4 + _assert_documented_envelope(payload) diff --git a/tests/test_first_adoption.py b/tests/test_first_adoption.py index c343fc67..f98d173c 100644 --- a/tests/test_first_adoption.py +++ b/tests/test_first_adoption.py @@ -413,3 +413,95 @@ def test_an_adoption_that_also_edits_a_policy_pack_keeps_the_strict_wording(): findings = verify_policy.run(context) assert len(findings) == 1 assert findings[0].evidence["kind"] == "base_snapshot_unavailable" + + +def test_a_custom_named_manifest_is_still_a_trust_root(tmp_path): + """The gate is whatever file the run loaded as the gate. + + The trust-root table only knows `**/shipgate.yaml`, so a repository run + with `--config new-gate.yml` had no manifest trust root at all: its gate + could be introduced or rewritten with an empty release substrate behind an + adoption headline that claimed a human was required. + """ + + repo = _repo_adopting_shipgate(tmp_path) + sample = repo / "samples" / "support_refund_agent" + _git( + repo, + "mv", + "samples/support_refund_agent/shipgate.yaml", + "samples/support_refund_agent/new-gate.yml", + ) + _git(repo, "commit", "-m", "custom manifest name") + # Roll the manifest back out of the base so this reads as an adoption. + _git(repo, "reset", "--soft", "HEAD~2") + _git(repo, "reset") + (sample / "shipgate.yaml").unlink(missing_ok=True) + + verifier = _run_verify_worktree(repo, "samples/support_refund_agent/new-gate.yml") + + assert verifier.capability_review.trust_root_touched is True + assert verifier.can_merge_without_human is False + assert verifier.control.state != "complete" + + +def _run_verify_worktree(repo: Path, config: str): + verifier, _report, _exit = run_verify( + workspace=repo, + config=Path(config), + base=None, + head="HEAD", + archive_head=False, + out=repo / "agents-shipgate-reports", + ci_mode="advisory", + fail_on=None, + baseline=None, + baseline_mode="new-findings", + diff_from=None, + policy_packs=None, + plugins_enabled=False, + strict_plugins=False, + suggest_patches=False, + no_heuristics=False, + verbose=False, + ) + return verifier + + +def test_the_rerun_command_repeats_the_whole_request(tmp_path): + """A rerun that drops options evaluates a different question.""" + + repo = _repo_adopting_shipgate(tmp_path) + pack = repo / "extra-policy.yaml" + pack.write_text("version: 1\nrules: []\n", encoding="utf-8") + + verifier, _report, _exit = run_verify( + workspace=repo, + config=SAMPLE_CONFIG, + base=None, + head="HEAD", + archive_head=False, + out=repo / "agents-shipgate-reports", + ci_mode="strict", + fail_on=None, + baseline=None, + baseline_mode="new-findings", + diff_from=None, + policy_packs=None, + plugins_enabled=False, + strict_plugins=False, + suggest_patches=False, + no_heuristics=True, + verbose=False, + auto_base=False, + ) + + command = (verifier.fix_task or {}) and verifier.fix_task.verification_command + assert command is not None + # --no-base matters most: without it the rerun auto-detects a base the + # evaluated run never compared against. + assert "--no-base" in command + assert "--ci-mode strict" in command + assert "--no-heuristics" in command + assert "--no-plugins" in command + assert f"--config {SAMPLE_CONFIG.as_posix()}" in command diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index 20df3125..a70a3dad 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -1,6 +1,5 @@ from __future__ import annotations -import importlib.util import json import os import subprocess @@ -15,8 +14,6 @@ render_or_install_hooks, ) from agents_shipgate.cli.main import app -from agents_shipgate.cli.verify.git import worktree_identity -from agents_shipgate.cli.verify.hook_state import record_verify_for_hooks REPO_ROOT = Path(__file__).resolve().parent.parent runner = CliRunner() @@ -885,338 +882,3 @@ def test_rendered_script_glob_matcher_matches_canonical_globbing( pattern, probe, ) - - -# --- one verify per turn (#295) --------------------------------------------- - - -def _hook_module(tmp_path: Path): - """Import the *rendered* hook script, so tests exercise what ships.""" - - script = _render_hook_script(tmp_path) - spec = importlib.util.spec_from_file_location("rendered_shipgate_hook", script) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_hook_and_cli_compute_the_same_worktree_identity(tmp_path: Path) -> None: - """Reuse is only sound while these two agree; drift must fail loudly. - - They cannot share code — the hook is a standalone rendered script with no - Shipgate imports — so the guarantee is this comparison. - """ - - module = _hook_module(tmp_path) - _init_repo(tmp_path) - - def both() -> tuple[str | None, str | None]: - return worktree_identity(tmp_path), module._worktree_identity(tmp_path) - - clean_cli, clean_hook = both() - assert clean_cli is not None - assert clean_cli == clean_hook - - # A tracked edit moves it. - (tmp_path / "shipgate.yaml").write_text("version: '0.2'\n", encoding="utf-8") - edited_cli, edited_hook = both() - assert edited_cli == edited_hook - assert edited_cli != clean_cli - - # So does an untracked file — whose content never appears in `git diff`. - (tmp_path / "new_tool.py").write_text("x = 1\n", encoding="utf-8") - added_cli, added_hook = both() - assert added_cli == added_hook - assert added_cli != edited_cli - - (tmp_path / "new_tool.py").write_text("x = 2\n", encoding="utf-8") - changed_cli, changed_hook = both() - assert changed_cli == changed_hook - assert changed_cli != added_cli, "untracked content must be part of identity" - - # And so does committing: same worktree, different HEAD tree. - subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) - subprocess.run( - ["git", "commit", "-m", "commit the work"], - cwd=tmp_path, - check=True, - capture_output=True, - ) - committed_cli, committed_hook = both() - assert committed_cli == committed_hook - assert committed_cli != changed_cli - - -def _record(tmp_path: Path, **overrides) -> None: - base_ref = overrides.pop("base_ref", None) - payload = { - "git_root": tmp_path, - "config": "shipgate.yaml", - "ci_mode": "advisory", - "base_ref": base_ref, - "base_commit_before": _commit_sha(tmp_path, base_ref) if base_ref else None, - "head_ref": None, - # As the CLI does it: captured before the scan, re-checked at write - # time so an edit during the scan cannot bind an old verdict to a new - # state. - "identity_before": worktree_identity(tmp_path), - "input_paths": ["shipgate.yaml"], - "input_set_id": "sha256:test", - "decision": "review_required", - "blockers": 0, - "review_items": 1, - "control": { - "state": "agent_action_required", - "reason": "recorded by the agent's own verify", - "next_action": {"kind": "verify", "command": "agents-shipgate verify --json"}, - }, - } - payload.update(overrides) - record_verify_for_hooks(**payload) - - -def _commit_sha(repo: Path, ref: str) -> str: - return subprocess.run( - ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], - cwd=repo, - capture_output=True, - text=True, - check=True, - ).stdout.strip() - - -def _hook_state(repo: Path) -> dict: - path = repo / ".git" / "agents-shipgate-hooks-state.json" - return json.loads(path.read_text("utf-8")) if path.is_file() else {} - - -def _verify_calls(log: Path) -> list[list[str]]: - if not log.exists(): - return [] - entries = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()] - return [entry for entry in entries if entry and entry[0] == "verify"] - - -def _stop_hook_with_record(tmp_path: Path) -> tuple[subprocess.CompletedProcess[str], Path]: - 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}" - result = subprocess.run( - [sys.executable, str(tmp_path / HOOK_SCRIPT_RELATIVE_PATH), "verify"], - input=json.dumps({"cwd": str(tmp_path)}), - capture_output=True, - text=True, - env=env, - cwd=tmp_path, - check=False, - ) - return result, log - - -def _dirty_opted_in_repo(tmp_path: Path) -> None: - _stop_hook_workspace(tmp_path) - subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) - subprocess.run( - ["git", "commit", "-m", "prompts"], cwd=tmp_path, check=True, capture_output=True - ) - (tmp_path / "prompts" / "refund.md").write_text( - "require approval and log\n", encoding="utf-8" - ) - - -def test_stop_hook_reuses_the_verify_the_agent_already_ran(tmp_path: Path) -> None: - _dirty_opted_in_repo(tmp_path) - _record(tmp_path) - - result, log = _stop_hook_with_record(tmp_path) - - assert result.returncode == 0, result.stderr - assert _verify_calls(log) == [], "the recorded run must not be recomputed" - out = json.loads(result.stdout) - assert out["decision"] == "block" - assert "agents-shipgate verify --json" in out["reason"] - - -def test_committing_after_verify_forces_a_fresh_run(tmp_path: Path) -> None: - """The stale-pass repro that sank the first attempt at this.""" - - _dirty_opted_in_repo(tmp_path) - _record(tmp_path) - subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) - subprocess.run( - ["git", "commit", "-m", "commit the work"], - cwd=tmp_path, - check=True, - capture_output=True, - ) - (tmp_path / "prompts" / "refund.md").write_text("changed again\n", encoding="utf-8") - - result, log = _stop_hook_with_record(tmp_path) - - assert result.returncode == 0, result.stderr - assert len(_verify_calls(log)) == 1 - - -def test_editing_after_verify_forces_a_fresh_run(tmp_path: Path) -> None: - _dirty_opted_in_repo(tmp_path) - _record(tmp_path) - (tmp_path / "prompts" / "refund.md").write_text("edited again\n", encoding="utf-8") - - result, log = _stop_hook_with_record(tmp_path) - - assert result.returncode == 0, result.stderr - assert len(_verify_calls(log)) == 1 - - -def test_a_record_from_another_config_is_not_reused(tmp_path: Path) -> None: - _dirty_opted_in_repo(tmp_path) - _record(tmp_path, config="other.yaml") - - result, log = _stop_hook_with_record(tmp_path) - - assert result.returncode == 0, result.stderr - assert len(_verify_calls(log)) == 1 - - -def test_a_record_from_a_different_base_is_not_reused(tmp_path: Path) -> None: - _dirty_opted_in_repo(tmp_path) - # The hook drops an unavailable base, so a record made *with* one is a - # different comparison than the one the hook is about to perform. - _record(tmp_path, base_ref="HEAD") - - result, log = _stop_hook_with_record(tmp_path) - - assert result.returncode == 0, result.stderr - assert len(_verify_calls(log)) == 1 - - -def test_recording_preserves_the_sessions_approved_surfaces(tmp_path: Path) -> None: - _dirty_opted_in_repo(tmp_path) - state_path = tmp_path / ".git" / "agents-shipgate-hooks-state.json" - state_path.write_text( - json.dumps({"approved_surfaces": {"s1": ["CLAUDE.md"]}}), encoding="utf-8" - ) - - _record(tmp_path) - - state = json.loads(state_path.read_text(encoding="utf-8")) - assert state["approved_surfaces"] == {"s1": ["CLAUDE.md"]} - assert state["last_verify"]["decision"] == "review_required" - - -def test_a_base_that_moved_since_the_verify_is_not_reused(tmp_path: Path) -> None: - """Same base ref, different commit: a different comparison. - - The name matching is not enough — `origin/main` advancing between the - agent's verify and the Stop hook changes what the diff means. - """ - - _dirty_opted_in_repo(tmp_path) - subprocess.run( - ["git", "update-ref", "refs/remotes/origin/main", "HEAD"], - cwd=tmp_path, - check=True, - ) - _record(tmp_path, base_ref="origin/main") - - result, log = _stop_hook_with_record(tmp_path) - assert result.returncode == 0, result.stderr - assert _verify_calls(log) == [], "an unmoved base still reuses" - - subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) - subprocess.run( - ["git", "commit", "-m", "advance"], cwd=tmp_path, check=True, capture_output=True - ) - subprocess.run( - ["git", "update-ref", "refs/remotes/origin/main", "HEAD"], - cwd=tmp_path, - check=True, - ) - (tmp_path / "prompts" / "refund.md").write_text("more\n", encoding="utf-8") - - result, log = _stop_hook_with_record(tmp_path) - assert result.returncode == 0, result.stderr - assert len(_verify_calls(log)) == 1 - - -def test_an_empty_commit_invalidates_the_record(tmp_path: Path) -> None: - """Same tree, different commit — and verify reads the commit. - - Its date is the evaluation clock that expires overrides and - acknowledgements, so a digest over `HEAD^{tree}` alone would report a - verdict computed under a different clock. - """ - - _dirty_opted_in_repo(tmp_path) - before = worktree_identity(tmp_path) - subprocess.run( - ["git", "commit", "--allow-empty", "-m", "empty"], - cwd=tmp_path, - check=True, - capture_output=True, - ) - assert worktree_identity(tmp_path) != before - - -def test_an_ignored_scan_input_is_never_recorded(tmp_path: Path) -> None: - """An ignored tool source is read by the scan and invisible to identity. - - It can change without moving the digest by a single byte, so a repository - that has one must not get a reuse record at all. - """ - - _dirty_opted_in_repo(tmp_path) - (tmp_path / ".gitignore").write_text("generated/\n", encoding="utf-8") - (tmp_path / "generated").mkdir() - (tmp_path / "generated" / "openapi.json").write_text("{}", encoding="utf-8") - subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True) - - _record(tmp_path, input_paths=["shipgate.yaml", "generated/openapi.json"]) - - assert "last_verify" not in _hook_state(tmp_path) - - -def test_a_worktree_edit_during_the_scan_is_not_recorded(tmp_path: Path) -> None: - """The verdict for the pre-edit tree must not be filed under the new state.""" - - _dirty_opted_in_repo(tmp_path) - stale_identity = worktree_identity(tmp_path) - (tmp_path / "prompts" / "refund.md").write_text( - "edited while the scan ran\n", encoding="utf-8" - ) - - _record(tmp_path, identity_before=stale_identity) - - assert "last_verify" not in _hook_state(tmp_path) - - -def test_the_hook_compares_the_effective_ci_mode_not_the_installed_flag( - tmp_path: Path, -) -> None: - """`AGENTS_SHIPGATE_VERIFY_CI_MODE` changes the question being asked.""" - - _dirty_opted_in_repo(tmp_path) - _record(tmp_path) - - 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}" - env["AGENTS_SHIPGATE_VERIFY_CI_MODE"] = "strict" - - result = subprocess.run( - [sys.executable, str(tmp_path / HOOK_SCRIPT_RELATIVE_PATH), "verify"], - input=json.dumps({"cwd": str(tmp_path)}), - capture_output=True, - text=True, - env=env, - cwd=tmp_path, - check=False, - ) - - assert result.returncode == 0, result.stderr - assert len(_verify_calls(log)) == 1, "an advisory record cannot answer a strict run" From 71047f627482f2bd457bc41e4f79a54a26ecc50a Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Wed, 29 Jul 2026 11:10:04 -0700 Subject: [PATCH 07/12] Address round three: protect custom manifests everywhere they are judged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Finding evidence carried the resolved config path, which for a committed-head run is a freshly named verify-head archive. That value is hashed into the fingerprint, so two identical runs produced different fingerprints and report run ids. It now carries the normalized changed path. 2. The configured manifest was protected only in the verify checks. `check` returned allow with no violations and preflight reported no protected touch for a diff that rewrote a repository's own gate under a non-default name — and the untracked-content filter dropped it before the evaluator ever saw it. The identity is now threaded through diff collection, boundary classification, and preflight, and the resulting class is recorded on the boundary row so the graded agent route excludes it without every band call site needing the config. 3. Rerun commands carry the resolved --workspace and a non-default --out, so a rerun from elsewhere evaluates the same checkout and does not leave the requested artifact directory stale. `check` recovery is rebuilt from the failing request with only the invalid field corrected; a fixed command discarded actor, workspace, config, policy and diff context, answering a different boundary question. Preflight's verify command echoes the request as the caller spelled it rather than a resolved absolute path, which would embed one machine's checkout location in a portable artifact. 4. Adoption is no longer proved by two basenames. A base that keeps an operational manifest under another name deletes nothing and matches no name check; absence is now established by asking git for any tracked YAML carrying the manifest's required top-level keys. It over-matches by design — the cost of a false match is the plainer wording. 5. Adoption wording stands down when policy_weakened is set: introducing a manifest while editing an existing policy file was describing away the very finding that needed review, and dropping the review_policy_weakening repair with it. 6. The audit id now distinguishes the actor. Detection changed the label but not the digest — the central one omitted it, the legacy one hardcoded codex — so all three agents shared an audit_id, which is the attribution problem detection exists to solve. Codex ids are unchanged; the other goldens are regenerated, audit_id only. 7. Preflight offers a review action rather than replaying a request-shape conflict that can never satisfy its own expects, and the host-audit baseline reader recovers the I/O cause from __cause__ (the loader converts every read OSError to ValueError) while a genuinely missing baseline keeps its documented config_error/2 contract. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 45 ++- src/agents_shipgate/checks/verify.py | 7 +- src/agents_shipgate/cli/agent_result.py | 29 +- src/agents_shipgate/cli/check.py | 67 +++- src/agents_shipgate/cli/host_audit.py | 32 +- src/agents_shipgate/cli/preflight.py | 9 +- src/agents_shipgate/cli/verify/fix_task.py | 14 +- src/agents_shipgate/cli/verify/git.py | 39 +++ .../cli/verify/orchestrator.py | 50 ++- src/agents_shipgate/core/agent_boundary.py | 56 +++- src/agents_shipgate/core/codex_boundary.py | 29 +- src/agents_shipgate/core/preflight.py | 93 +++++- .../agents_requirement_removed.json | 272 ++++++++-------- .../codex_boundary_result/docs_only.json | 204 ++++++------ .../github_action_removed.json | 300 +++++++++--------- .../codex_boundary_result/malformed_toml.json | 278 ++++++++-------- .../mcp_auto_approve_write.json | 290 ++++++++--------- .../network_wildcard.json | 282 ++++++++-------- .../python_refactor.json | 188 +++++------ .../unknown_permission_key.json | 276 ++++++++-------- tests/test_adapter_static_only.py | 2 +- tests/test_agent_boundary.py | 46 +++ tests/test_agent_mode.py | 125 ++++++++ tests/test_first_adoption.py | 97 +++++- tests/test_fix_task_contract.py | 21 +- tests/test_preflight.py | 10 +- 26 files changed, 1734 insertions(+), 1127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c211c4d..828568cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ snapshot)", and — because a missing-manifest base was classified as a safe recovery — shipped no `fix_task` at all, so nothing named the act that would clear it. `verify` now proves adoption from git (the comparison base carries - no manifest under any name, so a *moved* manifest cannot pass itself off as a + no manifest under any name and no YAML that reads like one, so neither a + *moved* manifest nor a base that quietly keeps one can pass itself off as a first adoption) and says so: same check id, same `medium` severity, same `human_review_required` state, new evidence kind `manifest_introduced`, and a `fix_task` whose leading instruction is "review the generated shipgate.yaml @@ -24,7 +25,15 @@ substrate empty. With a clean scan that produced `passed` / `mergeable` / `complete` — beneath an adoption headline that said a human was required. Whatever a run loads as its gate is now classified as one, in both the - trust-root check and the policy fail-safe. + trust-root check and the policy fail-safe — and in `check` and `preflight`, + which classified it no better: a diff for a custom-named manifest returned + `allow` with no violations locally and no protected touch in preflight, and + both then recommended a verify command for the default `shipgate.yaml`. The + classification is recorded on the boundary row so a gate-governing surface + stays out of the graded agent route regardless of its name, and the evidence + carries the changed path rather than the resolved config path, which for a + committed-head run is a temporary archive location that would make two + identical runs produce different fingerprints. - **`check` detects which agent is running it.** `--agent` defaulted to `codex` and never consulted the harness variables Shipgate already reads to switch on agent mode, so every Claude Code and Cursor run recorded the wrong actor in @@ -49,20 +58,44 @@ an explicit `--no-base`), because a rerun that drops them evaluates a different question than the one whose findings it is meant to reproduce. The structured adoption repair now names the resolved config path instead of a - hardcoded `shipgate.yaml`. + hardcoded `shipgate.yaml`, and the command carries the resolved `--workspace` + and a non-default `--out`, so a rerun from another directory evaluates the + same checkout and writes to the same place. `check`'s recovery command is + rebuilt from the failing request with only the invalid field corrected — + a fixed command discarded actor, workspace, config, policy, and diff context + — and a request whose diff came from stdin gets a review action instead of a + command that cannot be replayed. - **Preflight recovery keeps the request it failed on.** Every preflight error recommended a bare `agents-shipgate preflight --json`, discarding workspace, config, plan, diff, and capability request: following it after a failed targeted run evaluated the current repository with an empty plan and returned `control.state=complete`. The recovery action now reproduces the actual - invocation, and offers no command at all when the request came from stdin and - cannot be reproduced. + invocation, and offers no command at all when the request came from stdin or + mixed `--plan` with the per-flag inputs — replaying a request-shape conflict + can never satisfy its own `expects`. - **Host-audit filesystem failures follow the catalog.** A `--baseline-file` naming a directory raised `IsADirectoryError` through typer as a traceback and exit 1. Filesystem failures on both `--baseline-file` and `--out` are now `other_error` with exit 4, as `docs/errors.json` specifies — they were briefly reported as `config_error`/2, which sends an agent back to re-read - flags that were fine. + flags that were fine. The baseline *reader* needed the same treatment through + its `__cause__`, since the loader converts every read `OSError` into + `ValueError`; a genuinely missing baseline keeps its documented + `config_error`/2 "record one first" contract. +- **The audit id distinguishes the actor.** Detecting the calling agent changed + the label in the result but not the digest — the central one omitted the + actor and the legacy one hardcoded `codex` — so identical evaluations by + Claude Code, Cursor, and Codex shared an `audit_id`, which is exactly the + attribution problem actor detection exists to solve. A codex run's id is + unchanged; the committed boundary goldens are regenerated for the other two. +- **Adoption wording stands down when something was genuinely weakened.** + Introducing the manifest while editing an existing policy file produced a + `base_snapshot_unavailable` finding under a headline saying there was no + prior gate to weaken, and dropped the `review_policy_weakening` repair. The + pure-adoption wording now requires that nothing else needing review changed. + The adoption proof itself also stopped resting on two basenames: a base that + simply keeps an operational manifest under another name deletes nothing and + matches no name check, so absence is now established by content. - **A way out of `insufficient_evidence` (#292).** An abstention was unactionable in practice: the decision engine generated the exact manifest diff --git a/src/agents_shipgate/checks/verify.py b/src/agents_shipgate/checks/verify.py index 81c533d9..5834db40 100644 --- a/src/agents_shipgate/checks/verify.py +++ b/src/agents_shipgate/checks/verify.py @@ -81,7 +81,12 @@ def _configured_manifest(context: ScanContext, path: str) -> tuple[str, str] | N config = getattr(context, "config_path", None) if not is_configured_manifest(config, path): return None - return "manifest", str(config).replace("\\", "/") + # Deliberately the changed path, not ``config_path``: a committed-head run + # loads its manifest from a freshly named ``agents-shipgate-verify-head-*`` + # archive, and this value lands in finding evidence, which is hashed into + # the fingerprint. Returning the resolved config path made two identical + # runs produce different fingerprints and report run ids. + return "manifest", path def _finding( diff --git a/src/agents_shipgate/cli/agent_result.py b/src/agents_shipgate/cli/agent_result.py index 3a0707a1..13b0ae7b 100644 --- a/src/agents_shipgate/cli/agent_result.py +++ b/src/agents_shipgate/cli/agent_result.py @@ -15,6 +15,7 @@ from agents_shipgate.core.boundary_registry import is_agent_boundary_path from agents_shipgate.core.codex_boundary import parse_unified_diff from agents_shipgate.core.errors import InputParseError +from agents_shipgate.core.trust_roots import is_configured_manifest from agents_shipgate.inputs.codex_plugin import resolve_local_codex_marketplace_roots from agents_shipgate.schemas.agent_boundary import AgentBoundaryResultV1 from agents_shipgate.schemas.agent_result import AgentResultV2 @@ -114,6 +115,7 @@ def _assessment_for_diff( ), input_mode=input_mode, # type: ignore[arg-type] input_issues=input_issues, + config_path=config_path, ) @@ -303,7 +305,16 @@ def git_boundary_change_set( workspace: Path, base: str | None, head: str | None, + config_path: Path | None = None, ) -> BoundaryChangeSet: + """Collect the diff ``check`` evaluates. + + ``config_path`` is threaded so an untracked manifest under a non-default + name reaches the evaluator at all: the untracked-content filter below only + admits recognized boundary or capability paths, so a repository run with + ``--config new-gate.yml`` produced an empty change set and ``allow``. + """ + workspace = workspace.resolve() if bool(base) != bool(head): raise RuntimeError( @@ -324,6 +335,7 @@ def git_boundary_change_set( workspace=workspace, changed_paths=changed_paths, diff_text=diff_text, + config_path=config_path, ) return BoundaryChangeSet( mode="git_range" if revspec else "worktree", @@ -354,13 +366,14 @@ def _append_untracked_diffs( workspace: Path, changed_paths: list[str], diff_text: str, + config_path: Path | None = None, ) -> tuple[str, list[BoundaryInputIssue]]: represented = {item.path for item in parse_unified_diff(diff_text) if item.path} appended: list[str] = [] issues: list[BoundaryInputIssue] = [] consumed = 0 for path in sorted(set(changed_paths) - represented): - if not _potential_boundary_or_capability_path(path): + if not _potential_boundary_or_capability_path(path, config_path): continue candidate = workspace / path text: str | None = None @@ -397,7 +410,7 @@ def _append_untracked_diffs( appended.append(_new_file_diff(path, "")) continue assert text is not None - if not _is_relevant_untracked(path, text): + if not _is_relevant_untracked(path, text, config_path): continue appended.append(_new_file_diff(path, text)) if not appended: @@ -438,8 +451,10 @@ def _new_file_diff(path: str, text: str) -> str: ) -def _potential_boundary_or_capability_path(path: str) -> bool: - if is_agent_boundary_path(path): +def _potential_boundary_or_capability_path( + path: str, config_path: Path | None = None +) -> bool: + if is_agent_boundary_path(path) or is_configured_manifest(config_path, path): return True result = evaluate_trigger( paths=[path], @@ -450,8 +465,10 @@ def _potential_boundary_or_capability_path(path: str) -> bool: return result_has_surface_class(result, SURFACE_CLASS_CAPABILITY) or path.endswith(".py") -def _is_relevant_untracked(path: str, text: str) -> bool: - if is_agent_boundary_path(path): +def _is_relevant_untracked( + path: str, text: str, config_path: Path | None = None +) -> bool: + if is_agent_boundary_path(path) or is_configured_manifest(config_path, path): return True result = evaluate_trigger( paths=[path], diff --git a/src/agents_shipgate/cli/check.py b/src/agents_shipgate/cli/check.py index 7738c594..4d6ac786 100644 --- a/src/agents_shipgate/cli/check.py +++ b/src/agents_shipgate/cli/check.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shlex import sys from pathlib import Path @@ -22,15 +23,58 @@ from agents_shipgate.schemas.diagnostics import NextAction -def _flag_error(message: str, *, command: str, expects: str) -> typer.Exit: +def _corrected_request( + *, + agent: str | None, + workspace: Path, + config: Path, + policy: Path | None, + diff: str | None, + base: str | None, + head: str | None, + format_: str, +) -> str | None: + """The same check request with only the invalid field corrected. + + A fixed ``agents-shipgate check --format agent-boundary-json`` discards + every other argument, so following the rank-1 action switched actor, + workspace, config, policy, and diff/range context — answering a different + boundary question than the one that failed. ``None`` when the request read + its diff from stdin and therefore cannot be replayed. + """ + + if diff == "-": + return None + parts = ["agents-shipgate", "check"] + if agent in {"codex", "claude-code", "cursor"}: + parts.extend(["--agent", agent]) + parts.extend(["--workspace", shlex.quote(str(workspace))]) + parts.extend(["--config", shlex.quote(str(config))]) + if policy is not None: + parts.extend(["--policy", shlex.quote(str(policy))]) + if diff: + parts.extend(["--diff", shlex.quote(diff)]) + elif base and head: + parts.extend(["--base", shlex.quote(base), "--head", shlex.quote(head)]) + valid_format = format_ if format_ == "codex-boundary-json" else "agent-boundary-json" + parts.extend(["--format", valid_format]) + return " ".join(parts) + + +def _flag_error(message: str, *, command: str | None, expects: str) -> typer.Exit: """Report flag misuse on both channels and return the exit to raise.""" typer.echo(message, err=True) + action = ( + NextAction(kind="command", command=command, why=message, expects=expects) + if command + else NextAction(kind="review", why=message, expects=expects) + ) emit_agent_mode_error_action( "config_error", message=message, exit_code=2, - action=NextAction(kind="command", command=command, why=message, expects=expects), + action=action, ) return typer.Exit(2) @@ -91,23 +135,35 @@ def check( # harness mislabels every row it writes. An explicit flag always wins. agent = agent or detect_actor() + def corrected(*, valid_agent: str | None) -> str | None: + return _corrected_request( + agent=valid_agent, + workspace=workspace, + config=config, + policy=policy, + diff=diff, + base=base, + head=head, + format_=format_, + ) + if agent not in {"codex", "claude-code", "cursor"}: raise _flag_error( "--agent must be one of: codex, claude-code, cursor.", - command="agents-shipgate check --agent codex", + command=corrected(valid_agent=detect_actor()), expects="An --agent value the boundary evaluator recognizes.", ) if format_ == "agent-json": raise _flag_error( "--format agent-json was removed in the 0.14.0 contract cleanup. " "Use --format agent-boundary-json.", - command="agents-shipgate check --format agent-boundary-json", + command=corrected(valid_agent=agent), expects="The current agent-boundary result contract.", ) if format_ not in {"agent-boundary-json", "codex-boundary-json"}: raise _flag_error( "--format must be 'agent-boundary-json' or 'codex-boundary-json'.", - command="agents-shipgate check --format agent-boundary-json", + command=corrected(valid_agent=agent), expects="The current agent-boundary result contract.", ) try: @@ -121,6 +177,7 @@ def check( workspace=workspace, base=base, head=head, + config_path=config, ) diff_text = change_set.diff_text input_issues = list(change_set.issues) diff --git a/src/agents_shipgate/cli/host_audit.py b/src/agents_shipgate/cli/host_audit.py index 36b7a0aa..fe25af62 100644 --- a/src/agents_shipgate/cli/host_audit.py +++ b/src/agents_shipgate/cli/host_audit.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import shlex from pathlib import Path import typer @@ -213,21 +214,36 @@ def audit( if drift: try: baseline = load_host_grants_baseline(resolved_baseline) - except OSError as exc: - raise _io_error( - f"Could not read the host-grants baseline {baseline_file}: {exc}", - next_action=( - "Point --baseline-file at a readable baseline file, then " - "re-run the audit." - ), - ) from exc except ValueError as exc: + # The loader converts every read OSError into ValueError, so the + # I/O cause has to be recovered from __cause__ — otherwise a + # directory passed as --baseline-file is reported as flag misuse + # and the recovery drops the drift request entirely. + # A *missing* baseline is the documented "record one first" case + # and keeps its config_error/2 contract; only a path that exists + # and cannot be read is a filesystem failure. + if isinstance(exc.__cause__, OSError) and not isinstance( + exc.__cause__, FileNotFoundError + ): + raise _io_error( + f"Could not read the host-grants baseline {baseline_file}: " + f"{exc.__cause__}", + next_action=( + "Point --baseline-file at a readable baseline file, " + "then re-run the audit." + ), + ) from exc raise _config_error( str(exc), next_action=( "Record a baseline with `agents-shipgate audit --host " "--save-baseline`, or point --baseline-file at a valid one." ), + command=( + "agents-shipgate audit --host --workspace " + f"{shlex.quote(str(workspace))} --save-baseline " + f"--baseline-file {shlex.quote(str(baseline_file))}" + ), ) from exc payload = build_host_drift_payload( baseline=baseline, diff --git a/src/agents_shipgate/cli/preflight.py b/src/agents_shipgate/cli/preflight.py index be633a88..4d27f87d 100644 --- a/src/agents_shipgate/cli/preflight.py +++ b/src/agents_shipgate/cli/preflight.py @@ -137,7 +137,14 @@ def preflight( ) -> None: """Run the proactive static preflight contract for coding agents.""" - rerun = _rerun_command( + # A plan combined with the per-flag inputs is a request-shape conflict: + # replaying it verbatim can never satisfy its own ``expects``. Offer a + # review action instead of a command that reproduces the mistake. + conflicting_request = plan is not None and any( + value is not None + for value in (changed_files, diff, capability_request, base_preflight) + ) + rerun = None if conflicting_request else _rerun_command( workspace=workspace, config=config, changed_files=changed_files, diff --git a/src/agents_shipgate/cli/verify/fix_task.py b/src/agents_shipgate/cli/verify/fix_task.py index daf579db..ccce98b0 100644 --- a/src/agents_shipgate/cli/verify/fix_task.py +++ b/src/agents_shipgate/cli/verify/fix_task.py @@ -266,15 +266,15 @@ def _adoption_instruction( ) -> str | None: """The one human act a first adoption needs, or ``None``. - Keyed on the adoption itself, not on ``policy_weakened``/ - ``trust_root_touched``: the first now reports the honest ``false`` for an - adoption, and borrowing the second would make the instruction disappear in - the one case it exists for. It replaces the generic instructions rather - than adding to them; the routing that produced them is untouched. + Keyed on the adoption itself rather than on ``trust_root_touched``, which + would make the instruction disappear in the one case it exists for. It + stands down when ``policy_weakened`` is set: that means an existing policy + file was also changed, so "nothing existing was weakened" is false and the + generic instructions — including the ``review_policy_weakening`` repair — + are the ones the reviewer needs. """ - del capability_review # Kept for call-site symmetry with the generic path. - if not manifest_introduced: + if not manifest_introduced or capability_review.policy_weakened: return None return ( "This PR adopts Agents Shipgate: the base carries no manifest, so " diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 391f59cb..2872c3e8 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -395,6 +395,45 @@ def paths_named_at_ref( _MANIFEST_SUFFIXES = (".yaml", ".yml") +def carries_manifest_like_yaml(workspace: Path, ref: str) -> bool | None: + """Whether ``ref`` contains any YAML that looks like a Shipgate manifest. + + A basename check cannot prove a base carries no gate: a manifest may be + called anything, so a base that keeps an operational ``old-gate.yml`` while + the head adds ``new-gate.yml`` passes every name test. This asks a content + question instead — any tracked YAML carrying the manifest's required + top-level keys. It over-matches by design: an unrelated YAML with + ``project:`` and ``agent:`` merely costs the adoption wording, which is the + safe direction. + + ``None`` means the search could not run; callers must treat that as "cannot + prove", never as absence. + """ + + result = _run_git( + workspace, + [ + "grep", + "--all-match", + "-l", + "-e", + "^project:", + "-e", + "^agent:", + ref, + "--", + "*.yaml", + "*.yml", + ], + check=False, + ) + if result.returncode == 1: # documented: nothing matched + return False + if result.returncode != 0: + return None + return bool(result.stdout.strip()) + + def removes_a_yaml_file(workspace: Path, base: str | None, head: str) -> bool | None: """Whether the evaluated diff deletes or renames away any YAML file. diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index 43d6c230..9043ffb9 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -87,6 +87,7 @@ from .git import ( active_replace_refs, archive_tree, + carries_manifest_like_yaml, commit_date, commit_sha, detect_default_base_with_notes, @@ -154,6 +155,8 @@ def run_verify( rerun_options = _rerun_options( git_root=git_root, + out_dir=out_dir, + pr_comment_style=pr_comment_style, base=base, auto_base=auto_base, ci_mode=ci_mode, @@ -958,6 +961,8 @@ def _map_optional_tree_path( def _rerun_options( *, git_root: Path, + out_dir: Path, + pr_comment_style: str, base: str | None, auto_base: bool, ci_mode: str | None, @@ -983,6 +988,15 @@ def _rerun_options( """ options: list[str] = [] + # The workspace is unconditional: run from anywhere else and a bare command + # either fails or evaluates a different checkout. + options.extend(["--workspace", shlex.quote(str(git_root))]) + if out_dir.resolve() != (git_root / DEFAULT_OUT_DIR).resolve(): + # A non-default artifact directory has to be repeated, or the rerun + # writes elsewhere and leaves the requested one stale. + options.extend(["--out", shlex.quote(_display_path(out_dir, git_root))]) + if pr_comment_style and pr_comment_style != "capability-review": + options.extend(["--pr-comment-style", shlex.quote(pr_comment_style)]) if base is None and not auto_base: options.append("--no-base") if ci_mode: @@ -1033,12 +1047,14 @@ def _manifest_introduced( while loosening it — also finds nothing at the configured path on the base, and would otherwise get to call itself a first adoption. - Two independent checks close that, because a name check alone cannot: a - repository may call its manifest anything, so ``old-gate.yml`` renamed to - ``new-gate.yml`` passes any name test. The base must carry no manifest - under the configured or default name, *and* the evaluated diff must not - delete or rename away any YAML file. Both are fail-closed: a git command - that cannot answer means "not an adoption", never "proven absent". + Three independent checks close that, because a name check alone cannot: a + repository may call its manifest anything, so both ``old-gate.yml`` renamed + to ``new-gate.yml`` and a base that simply *keeps* ``old-gate.yml`` pass + every name test. The base must carry no manifest under the configured or + default name, no tracked YAML that reads like a manifest at all, *and* the + evaluated diff must not delete or rename away any YAML file. All three are + fail-closed: a git command that cannot answer means "not an adoption", + never "proven absent". Unknown bases (``ref_missing``, ``archive_failed``) are never treated as adoptions: absence of evidence is not evidence of absence. @@ -1067,6 +1083,11 @@ def _manifest_introduced( existing = paths_named_at_ref(git_root, ref, frozenset(names)) if existing is None or existing: return False + # A name check cannot see a manifest called something else, so ask what the + # base actually contains. Both probes fail closed on an unreadable answer. + manifest_like = carries_manifest_like_yaml(git_root, ref) + if manifest_like is not False: + return False removed = removes_a_yaml_file( git_root, base if base_status == "missing_manifest" else None, head ) @@ -1133,14 +1154,17 @@ def _self_approval_note( coding agent cannot adopt a release policy on the repository's behalf), but a PR that adds the manifest to a base that had none weakens nothing, and saying it does is both wrong and the first thing every new adopter reads. - An adoption is its own case and must not depend on ``policy_weakened``: - that flag is now false during an adoption, because it answers "was the gate - weakened" for the registry, attestations, and the gate-bypass alarm. Firing - on ``manifest_introduced`` alone is what keeps every caller that reads this - as "a trust root is in play" — ``_can_merge_without_human`` above all — - fail-closed regardless of the other two flags. + An adoption fires on ``manifest_introduced`` rather than on + ``policy_weakened``, which is honestly false during an adoption — that + keeps every caller reading this as "a trust root is in play" fail-closed. + But a diff that introduces the manifest *and* weakens an existing policy + file is not a pure adoption: ``policy_weakened`` is then true, and saying + "there is no prior gate this change could weaken" would describe away the + very finding that needs review. The weakening wording wins. """ - if manifest_introduced: + if manifest_introduced and not ( + capability_review is not None and capability_review.policy_weakened + ): return ( "This PR introduces Agents Shipgate to this repository: the base " "carries no manifest, so there is no prior gate this change could " diff --git a/src/agents_shipgate/core/agent_boundary.py b/src/agents_shipgate/core/agent_boundary.py index 90d1d60d..ff8742fd 100644 --- a/src/agents_shipgate/core/agent_boundary.py +++ b/src/agents_shipgate/core/agent_boundary.py @@ -58,7 +58,10 @@ HostBoundarySnapshot, build_host_boundary_snapshot, ) -from agents_shipgate.core.trust_roots import trust_root_class_for +from agents_shipgate.core.trust_roots import ( + is_configured_manifest, + trust_root_class_for, +) from agents_shipgate.schemas.agent_boundary import ( AGENT_BOUNDARY_RESULT_SCHEMA_VERSION, AgentBoundaryResultV1, @@ -164,6 +167,7 @@ def evaluate_agent_boundary( input_mode: Literal["worktree", "git_range", "provided_diff"] = "provided_diff", input_issues: list[BoundaryInputIssue] | None = None, host_snapshot: HostBoundarySnapshot | None = None, + config_path: Path | None = None, ) -> AgentBoundaryAssessment: workspace = workspace.resolve() host_snapshot = host_snapshot or build_host_boundary_snapshot( @@ -176,7 +180,12 @@ def evaluate_agent_boundary( path for item in diff_files for path in (item.path, item.old_path) - if path and (path == item.path or is_agent_boundary_path(path)) + if path + and ( + path == item.path + or is_agent_boundary_path(path) + or is_configured_manifest(config_path, path) + ) } ) input_issues = [ @@ -232,7 +241,8 @@ def evaluate_agent_boundary( combined = _with_unclassified_protected_changes( changed_files=changed_files, violations=combined, - adoption_paths=_manifest_adoption_paths(diff_files), + adoption_paths=_manifest_adoption_paths(diff_files, config_path), + config_path=config_path, evaluated_paths={ item.path for item in diagnostics @@ -499,6 +509,7 @@ def _project_legacy( ), ) audit_id = _agent_boundary_audit_id( + actor=legacy.agent, changed_files=legacy.changed_files, fingerprints=fingerprints, policy_digest=policy_set.digest, @@ -542,12 +553,22 @@ def _project_legacy( def _agent_boundary_audit_id( *, + actor: str, changed_files: list[str], fingerprints: list[str], policy_digest: str, ) -> str: + """Identity of one audited evaluation. + + The actor belongs in it. The result records which agent was evaluated, so + two runs differing only by actor are two audit rows, not one — and without + it, detecting the actor changed the label while leaving every Claude Code + and Cursor run indistinguishable from a codex run in the audit trail. + """ + payload = { "schema": AGENT_BOUNDARY_RESULT_SCHEMA_VERSION, + "actor": actor, "changed_files": sorted(changed_files), "fingerprints": sorted(fingerprints), "policy_set_sha256": policy_digest, @@ -741,7 +762,9 @@ def _boundary_summary(decision: str, violations: list[AgentResultViolatedRule]) return f"{len(violations)} coding-agent boundary change(s) block local continuation." -def _manifest_adoption_paths(diff_files: list[DiffFile]) -> frozenset[str]: +def _manifest_adoption_paths( + diff_files: list[DiffFile], config_path: Path | None = None +) -> frozenset[str]: """Paths where this diff *introduces* a Shipgate manifest. "Adopting the gate" and "changing the gate" deserve different words, and a @@ -761,7 +784,11 @@ def _manifest_adoption_paths(diff_files: list[DiffFile]) -> frozenset[str]: item for item in diff_files for candidate in (item.new_path, item.old_path) - if candidate and trust_root_class_for(candidate.replace("\\", "/")) == "manifest" + if candidate + and ( + trust_root_class_for(candidate.replace("\\", "/")) == "manifest" + or is_configured_manifest(config_path, candidate) + ) ] if len(records) != 1: return frozenset() @@ -777,6 +804,7 @@ def _with_unclassified_protected_changes( violations: list[AgentResultViolatedRule], evaluated_paths: set[str], adoption_paths: frozenset[str] = frozenset(), + config_path: Path | None = None, ) -> list[AgentResultViolatedRule]: covered = {item.path for item in violations if item.path} additions: list[AgentResultViolatedRule] = [] @@ -785,7 +813,14 @@ def _with_unclassified_protected_changes( if ( normalized in covered or normalized in evaluated_paths - or not is_agent_boundary_path(normalized) + or not ( + is_agent_boundary_path(normalized) + # The manifest this invocation loaded is a protected surface + # whatever it is called: a repository run with + # ``--config new-gate.yml`` otherwise got ``allow`` and no + # violations for a diff that rewrote its own gate. + or is_configured_manifest(config_path, normalized) + ) ): continue kind = ( @@ -795,6 +830,12 @@ def _with_unclassified_protected_changes( ) rule = _GENERIC_RULES[kind] adopting = normalized in adoption_paths + # Recorded only for a manifest the path table cannot see, so existing + # rows keep their fingerprints. The band predicate reads it to keep a + # gate-governing surface out of the graded route. + configured_manifest = trust_root_class_for( + normalized + ) is None and is_configured_manifest(config_path, normalized) additions.append( AgentResultViolatedRule( id=rule.id, @@ -814,7 +855,8 @@ def _with_unclassified_protected_changes( else "static_requirements_changed" if kind == "STATIC-REQUIREMENTS-CHANGED" else "protected_surface_unclassified" - ) + ), + **({"trust_root_class": "manifest"} if configured_manifest else {}), }, recommendation=( "Review the generated shipgate.yaml and merge the adoption " diff --git a/src/agents_shipgate/core/codex_boundary.py b/src/agents_shipgate/core/codex_boundary.py index 043ef1f7..959e508f 100644 --- a/src/agents_shipgate/core/codex_boundary.py +++ b/src/agents_shipgate/core/codex_boundary.py @@ -605,6 +605,7 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None: human_review = _human_review_for(decision, violations, repair) finding_fingerprints = [_violation_fingerprint(item) for item in violations] audit_id = _audit_id( + agent=agent, changed_files=changed_files, diff_files=diff_files, policy=policy, @@ -712,7 +713,7 @@ def violations_within_agent_actionable_band( and item.evidence.get("kind") not in _PARSEABLE_EVIDENCE_KINDS ): return False - if _governs_the_gate(item.path): + if _governs_the_gate(item): return False if item.path and _touches_experimental_surface(item.path): # An experimental adapter's classification confidence is low — @@ -727,12 +728,22 @@ def _touches_experimental_surface(path: str) -> bool: ) -def _governs_the_gate(path: str | None) -> bool: - """Whether a changed path is one of the gate's own governing trust roots.""" +def _governs_the_gate(item: AgentResultViolatedRule) -> bool: + """Whether a violation is about one of the gate's own governing trust roots. - if not path: + Reads the classification recorded on the row before falling back to the + path table. A manifest under a non-default name is a gate-governing surface + that no glob recognizes, and the classifier that *did* recognize it — the + one holding the resolved ``--config`` — records the class here rather than + threading the config path through every band call site. + """ + + recorded = (item.evidence or {}).get("trust_root_class") + if isinstance(recorded, str) and recorded in BAND_EXCLUDED_TRUST_ROOT_CLASSES: + return True + if not item.path: return False - return trust_root_class_for(path) in BAND_EXCLUDED_TRUST_ROOT_CLASSES + return trust_root_class_for(item.path) in BAND_EXCLUDED_TRUST_ROOT_CLASSES def _pending_review_for( @@ -2060,6 +2071,7 @@ def _trace_for( def _audit_id( *, + agent: str, changed_files: list[str], diff_files: list[DiffFile], policy: CodexBoundaryPolicy, @@ -2068,7 +2080,12 @@ def _audit_id( ) -> str: payload = { "schema_version": CODEX_BOUNDARY_RESULT_SCHEMA_VERSION, - "agent": "codex", + # The evaluating agent is part of the audited identity: the result + # records it, and two runs that differ only by actor are two different + # audit rows. Hardcoding "codex" made them collide, which is precisely + # the attribution problem actor detection exists to fix. A codex run's + # id is unchanged. + "agent": agent, "changed_files": changed_files, "diff": [ { diff --git a/src/agents_shipgate/core/preflight.py b/src/agents_shipgate/core/preflight.py index 2312a1e3..c5aa9642 100644 --- a/src/agents_shipgate/core/preflight.py +++ b/src/agents_shipgate/core/preflight.py @@ -3,6 +3,7 @@ import hashlib import json import os +import shlex from dataclasses import dataclass from pathlib import Path from typing import Any @@ -28,6 +29,7 @@ from agents_shipgate.core.manifest_proposals import ( assess_coverage_increasing_tool_source_proposal, ) +from agents_shipgate.core.trust_roots import is_configured_manifest from agents_shipgate.schemas.agent_control import ( CodingAgentCommandAction, HumanControlAction, @@ -128,6 +130,35 @@ _VERIFY_COMMAND = ( "agents-shipgate verify --workspace . --config shipgate.yaml --ci-mode advisory --json" ) + + +def _verify_command(workspace: Path | None, config: Path | None) -> str: + """The verify invocation for *this* preflight's target. + + The constant above names the default workspace and manifest, so a preflight + run against a non-default manifest handed the reader a command pointing at + a different gate — or at no gate at all. + + Both values are echoed exactly as the caller spelled them, not resolved: a + resolved absolute path would be correct and useless, embedding one + machine's checkout location in an artifact meant to be read anywhere. + """ + + if workspace is None or config is None: + return _VERIFY_COMMAND + return " ".join( + [ + "agents-shipgate", + "verify", + "--workspace", + shlex.quote(workspace.as_posix()), + "--config", + shlex.quote(config.as_posix()), + "--ci-mode", + "advisory", + "--json", + ] + ) _SIGNAL_KIND_RANK = { "protected_surface_touch": 0, "host_grant_drift": 1, @@ -265,7 +296,8 @@ def build_preflight_result( ) for node in graph.nodes ] - touches = classify_protected_touches(changed) + verify_command = _verify_command(workspace, config) + touches = classify_protected_touches(changed, config_path) touches = _classify_proposal_safe_manifest_touch( workspace=root, config_path=config_path, @@ -304,10 +336,12 @@ def build_preflight_result( [ *signals_for_protected_touches(touches), *signals_for_host_grant_drift(host_grant_drift), - *signals_for_capability_requests(requests), + *signals_for_capability_requests(requests, verify_command), *least_privilege_signals(requests), *signals_for_host_permission_requests(host_requests), - *signals_for_policy_drift(policy_drift, trust_root_graph_diff), + *signals_for_policy_drift( + policy_drift, trust_root_graph_diff, verify_command + ), ] ) requires_human_review = requires_human_review or any( @@ -315,11 +349,11 @@ def build_preflight_result( ) requires_verify = bool(changed or requests or host_requests) if requires_verify and not any(signal.kind == "verify_required" for signal in signals): - signals = _sorted_signals([*signals, _verify_required_signal()]) + signals = _sorted_signals([*signals, _verify_required_signal(verify_command)]) - first_next_action = _first_next_action(signals=signals) + first_next_action = _first_next_action(signals=signals, verify_command=verify_command) allowed_next_commands = ( - [_VERIFY_COMMAND] + [verify_command] if first_next_action.actor == "coding_agent" and first_next_action.kind == "verify" else [] ) @@ -349,7 +383,7 @@ def build_preflight_result( notes=notes, signals=signals, requires_verify=requires_verify, - verification_command=_VERIFY_COMMAND if requires_verify else None, + verification_command=verify_command if requires_verify else None, allowed_next_commands=allowed_next_commands, plan_summary=_plan_summary( changed=changed, @@ -388,7 +422,16 @@ def build_trust_root_graph(workspace: Path) -> TrustRootGraphV1: def classify_protected_touches( changed_files: list[str], + config_path: Path | None = None, ) -> list[PreflightProtectedSurfaceTouch]: + """Classify changed paths against the protected-surface catalog. + + ``config_path`` covers the manifest this run was pointed at. The catalog + only patterns ``**/shipgate.yaml``, so a repository run with + ``--config new-gate.yml`` reported no protected touch and + ``requires_human_review=false`` for a diff that rewrote its own gate. + """ + touches: list[PreflightProtectedSurfaceTouch] = [] seen: set[str] = set() for raw in changed_files: @@ -396,7 +439,7 @@ def classify_protected_touches( if not path or path in seen: continue seen.add(path) - spec = _classify(path) + spec = _classify(path) or _configured_manifest_spec(config_path, path) if spec is None: continue touches.append( @@ -605,6 +648,7 @@ def _classify_proposal_safe_manifest_touch( def signals_for_capability_requests( requests: list[CapabilityRequestV1], + verify_command: str = _VERIFY_COMMAND, ) -> list[PreflightSignalV1]: signals: list[PreflightSignalV1] = [] for request in requests: @@ -628,7 +672,7 @@ def signals_for_capability_requests( "A coding agent must not invent approval, ownership, " "idempotency, audit, confirmation, runbook, or rollback evidence." ), - related_command="agents-shipgate verify --workspace . --config shipgate.yaml --ci-mode advisory --json", + related_command=verify_command, ) ) return signals @@ -727,6 +771,7 @@ def signals_for_host_permission_requests( def signals_for_policy_drift( policy_drift: PreflightDriftSummary | None, trust_root_graph_diff: PreflightDriftSummary | None, + verify_command: str = _VERIFY_COMMAND, ) -> list[PreflightSignalV1]: signals: list[PreflightSignalV1] = [] if policy_drift is not None and policy_drift.changed: @@ -740,7 +785,7 @@ def signals_for_policy_drift( path="shipgate.yaml", reason="Effective release policy hash differs from the supplied base preflight.", recommendation="Have a human review the policy change; preflight cannot prove it is a safe strengthening.", - related_command="agents-shipgate verify --workspace . --config shipgate.yaml --ci-mode advisory --json", + related_command=verify_command, ) ) if trust_root_graph_diff is not None and trust_root_graph_diff.changed: @@ -754,7 +799,7 @@ def signals_for_policy_drift( path=None, reason="Trust-root graph differs from the supplied base preflight.", recommendation="Have a human review added, removed, or modified trust roots before relying on this change.", - related_command="agents-shipgate verify --workspace . --config shipgate.yaml --ci-mode advisory --json", + related_command=verify_command, ) ) return signals @@ -839,6 +884,25 @@ def _classify(path: str) -> ProtectedSurfaceSpec | None: return None +def _configured_manifest_spec( + config_path: Path | None, path: str +) -> ProtectedSurfaceSpec | None: + """The manifest this run loaded, classified as one whatever it is called. + + ``pattern`` is the changed path rather than the resolved config path: it + lands in the preflight artifact, and an absolute path would vary by + checkout location. + """ + + if not is_configured_manifest(config_path, path): + return None + return ProtectedSurfaceSpec( + kind="manifest", + pattern=path, + scope_type=_scope_type_for_kind("manifest"), + ) + + def _normalize_changed_files(paths: list[str]) -> list[str]: return sorted({path.replace("\\", "/").strip() for path in paths if path.strip()}) @@ -1106,7 +1170,7 @@ def _sorted_signals(signals: list[PreflightSignalV1]) -> list[PreflightSignalV1] ) -def _verify_required_signal() -> PreflightSignalV1: +def _verify_required_signal(verify_command: str = _VERIFY_COMMAND) -> PreflightSignalV1: return PreflightSignalV1( id="verify_required:diff", kind="verify_required", @@ -1116,7 +1180,7 @@ def _verify_required_signal() -> PreflightSignalV1: path=None, reason="The plan includes files, capability requests, or host permission requests that require deterministic verification before completion.", recommendation="Run the verifier and read verifier.json plus report.json.release_decision.decision before reporting the work complete.", - related_command=_VERIFY_COMMAND, + related_command=verify_command, ) @@ -1209,6 +1273,7 @@ def _graph_drift( def _first_next_action( *, signals: list[PreflightSignalV1], + verify_command: str = _VERIFY_COMMAND, ) -> PreflightNextAction: human_signals = [signal for signal in signals if signal.actor == "human"] if human_signals: @@ -1228,7 +1293,7 @@ def _first_next_action( return PreflightNextAction( actor="coding_agent", kind="verify", - command=_VERIFY_COMMAND, + command=verify_command, why=verify_signal.reason, ) return PreflightNextAction( diff --git a/tests/golden/codex_boundary_result/agents_requirement_removed.json b/tests/golden/codex_boundary_result/agents_requirement_removed.json index e0050f25..1b9dbfd2 100644 --- a/tests/golden/codex_boundary_result/agents_requirement_removed.json +++ b/tests/golden/codex_boundary_result/agents_requirement_removed.json @@ -1,144 +1,144 @@ { - "schema_version": "shipgate.codex_boundary_result/v2", - "agent": "codex", - "tool": { - "name": "agents-shipgate", - "version": "0.16.0b7" + "schema_version": "shipgate.codex_boundary_result/v2", + "agent": "codex", + "tool": { + "name": "agents-shipgate", + "version": "0.16.0b7" + }, + "subject": { + "agent": "codex" + }, + "decision": "require_review", + "risk_level": "medium", + "audit_id": "agent_boundary_fd1d4bc908e27ffac7ef9e4b", + "policy_version": "1", + "summary": "1 coding-agent boundary change(s) require human review.", + "changed_files": [ + "AGENTS.md" + ], + "control": { + "state": "human_review_required", + "reason": "1 coding-agent boundary change(s) require human review.", + "completion_allowed": false, + "must_stop": true, + "verify_required": false, + "next_action": { + "actor": "human", + "kind": "review", + "command": null, + "expects": null, + "why": "AGENTS.md removed a Shipgate requirement" }, - "subject": { - "agent": "codex" + "allowed_next_commands": [], + "human_review": { + "required": true, + "why": "AGENTS.md removed a Shipgate requirement", + "required_reviewers": [ + "agent-platform" + ] }, - "decision": "require_review", - "risk_level": "medium", - "audit_id": "agent_boundary_c22c9f245720d8f6f31d67cd", - "policy_version": "1", - "summary": "1 coding-agent boundary change(s) require human review.", - "changed_files": [ - "AGENTS.md" + "stop_reason": "AGENTS.md removed a Shipgate requirement" + }, + "repair": { + "actor": "human", + "safe_to_attempt": false, + "instructions": [ + "Have a human confirm the agent instructions were not weakened." ], - "control": { - "state": "human_review_required", - "reason": "1 coding-agent boundary change(s) require human review.", - "completion_allowed": false, - "must_stop": true, - "verify_required": false, - "next_action": { - "actor": "human", - "kind": "review", - "command": null, - "expects": null, - "why": "AGENTS.md removed a Shipgate requirement" - }, - "allowed_next_commands": [], - "human_review": { - "required": true, - "why": "AGENTS.md removed a Shipgate requirement", - "required_reviewers": [ - "agent-platform" - ] - }, - "stop_reason": "AGENTS.md removed a Shipgate requirement" - }, - "repair": { - "actor": "human", - "safe_to_attempt": false, - "instructions": [ - "Have a human confirm the agent instructions were not weakened." - ], - "forbidden_shortcuts": [ - "Do not suppress the finding (checks.ignore in shipgate.yaml).", - "Do not lower severity or add a waiver just to pass the gate.", - "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", - "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." - ] + "forbidden_shortcuts": [ + "Do not suppress the finding (checks.ignore in shipgate.yaml).", + "Do not lower severity or add a waiver just to pass the gate.", + "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", + "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + ] + }, + "policy": { + "id": "agent-boundary", + "version": "1", + "source": "packaged_default", + "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", + "discovery": [ + "packaged_default:agent-boundary" + ] + }, + "violated_rules": [ + { + "id": "CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED", + "check_id": "SHIP-CODEX-BOUNDARY-AGENTS-SHIPGATE-REQUIREMENT-REMOVED", + "action": "require_review", + "risk_level": "medium", + "title": "AGENTS.md removed a Shipgate requirement", + "path": "AGENTS.md", + "evidence": { + "kind": "shipgate_instruction_removed", + "deleted": false, + "removed_shipgate_lines": true, + "softened_shipgate_lines": false, + "removed_requirement_lines": true, + "replacement_requirement_lines": false + }, + "recommendation": "Have a human confirm the agent instructions were not weakened." + } + ], + "affected_files": [ + { + "path": "AGENTS.md" + } + ], + "required_reviewers": [ + "agent-platform" + ], + "explanation": "1 coding-agent boundary change(s) require human review.", + "suggested_fixes": [ + "Have a human confirm the agent instructions were not weakened." + ], + "agent_repair_instructions": [ + "Have a human confirm the agent instructions were not weakened." + ], + "diagnostics": [], + "trace": [ + { + "step": "policy_discovery", + "summary": "Loaded 2 coding-agent boundary policy families." }, - "policy": { - "id": "agent-boundary", - "version": "1", - "source": "packaged_default", - "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", - "discovery": [ - "packaged_default:agent-boundary" - ] - }, - "violated_rules": [ - { - "id": "CODEX-AGENTS-SHIPGATE-REQUIREMENT-REMOVED", - "check_id": "SHIP-CODEX-BOUNDARY-AGENTS-SHIPGATE-REQUIREMENT-REMOVED", - "action": "require_review", - "risk_level": "medium", - "title": "AGENTS.md removed a Shipgate requirement", - "path": "AGENTS.md", - "evidence": { - "kind": "shipgate_instruction_removed", - "deleted": false, - "removed_shipgate_lines": true, - "softened_shipgate_lines": false, - "removed_requirement_lines": true, - "replacement_requirement_lines": false - }, - "recommendation": "Have a human confirm the agent instructions were not weakened." - } - ], - "affected_files": [ - { - "path": "AGENTS.md" - } - ], - "required_reviewers": [ - "agent-platform" - ], - "explanation": "1 coding-agent boundary change(s) require human review.", - "suggested_fixes": [ - "Have a human confirm the agent instructions were not weakened." - ], - "agent_repair_instructions": [ - "Have a human confirm the agent instructions were not weakened." + { + "step": "decision", + "summary": "Projected 1 violation(s) to require_review." + } + ], + "source_artifacts": {}, + "trigger": { + "schema_version": "0.2", + "should_run": true, + "run_shipgate": true, + "skip": false, + "force_run": false, + "dry_run_recommended": false, + "skip_reason": null, + "stop_conditions_fired": false, + "stop_conditions_evaluated": false, + "rationale": "1 run_shipgate rule(s) matched.", + "matched_rules": [ + { + "id": "TRIGGER-SHARED-HOST-BOUNDARY-CHANGED", + "action": "run_shipgate", + "surface_class": "host_boundary", + "rationale": "Workflow permissions, pull_request_target, and write-capable automation are shared coding-agent trust roots.", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" + } ], - "diagnostics": [], - "trace": [ - { - "step": "policy_discovery", - "summary": "Loaded 2 coding-agent boundary policy families." - }, - { - "step": "decision", - "summary": "Projected 1 violation(s) to require_review." - } - ], - "source_artifacts": {}, - "trigger": { - "schema_version": "0.2", - "should_run": true, - "run_shipgate": true, - "skip": false, - "force_run": false, - "dry_run_recommended": false, - "skip_reason": null, - "stop_conditions_fired": false, - "stop_conditions_evaluated": false, - "rationale": "1 run_shipgate rule(s) matched.", - "matched_rules": [ - { - "id": "TRIGGER-SHARED-HOST-BOUNDARY-CHANGED", - "action": "run_shipgate", - "surface_class": "host_boundary", - "rationale": "Workflow permissions, pull_request_target, and write-capable automation are shared coding-agent trust roots.", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" - } - ], - "changed_files": [ - "AGENTS.md" - ], - "diff_tokens": [], - "next_action": { - "kind": "command", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", - "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." - } - }, - "finding_fingerprints": [ - "fp_e3eff6c19a8ab16d" + "changed_files": [ + "AGENTS.md" ], - "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" + "diff_tokens": [], + "next_action": { + "kind": "command", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", + "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." + } + }, + "finding_fingerprints": [ + "fp_e3eff6c19a8ab16d" + ], + "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" } diff --git a/tests/golden/codex_boundary_result/docs_only.json b/tests/golden/codex_boundary_result/docs_only.json index 69783c03..d4f0136e 100644 --- a/tests/golden/codex_boundary_result/docs_only.json +++ b/tests/golden/codex_boundary_result/docs_only.json @@ -1,108 +1,108 @@ { - "schema_version": "shipgate.codex_boundary_result/v2", - "agent": "codex", - "tool": { - "name": "agents-shipgate", - "version": "0.16.0b7" + "schema_version": "shipgate.codex_boundary_result/v2", + "agent": "codex", + "tool": { + "name": "agents-shipgate", + "version": "0.16.0b7" + }, + "subject": { + "agent": "codex" + }, + "decision": "allow", + "risk_level": "none", + "audit_id": "agent_boundary_6992d494be4b9b5ad459b3e3", + "policy_version": "1", + "summary": "No recognized coding-agent boundary change requires action.", + "changed_files": [ + "docs/readme.md" + ], + "control": { + "state": "complete", + "reason": "No recognized coding-agent boundary change requires action.", + "completion_allowed": true, + "must_stop": false, + "verify_required": false, + "next_action": null, + "allowed_next_commands": [], + "human_review": { + "required": false, + "why": null, + "required_reviewers": [] }, - "subject": { - "agent": "codex" + "stop_reason": null + }, + "repair": { + "actor": "coding_agent", + "safe_to_attempt": false, + "instructions": [], + "forbidden_shortcuts": [ + "Do not suppress the finding (checks.ignore in shipgate.yaml).", + "Do not lower severity or add a waiver just to pass the gate.", + "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", + "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + ] + }, + "policy": { + "id": "agent-boundary", + "version": "1", + "source": "packaged_default", + "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", + "discovery": [ + "packaged_default:agent-boundary" + ] + }, + "violated_rules": [], + "affected_files": [ + { + "path": "docs/readme.md" + } + ], + "required_reviewers": [], + "explanation": "No recognized coding-agent boundary change requires action.", + "suggested_fixes": [], + "agent_repair_instructions": [], + "diagnostics": [], + "trace": [ + { + "step": "policy_discovery", + "summary": "Loaded 2 coding-agent boundary policy families." }, - "decision": "allow", - "risk_level": "none", - "audit_id": "agent_boundary_bcae9dc247f5dae731a41e8c", - "policy_version": "1", - "summary": "No recognized coding-agent boundary change requires action.", - "changed_files": [ - "docs/readme.md" + { + "step": "decision", + "summary": "Projected 0 violation(s) to allow." + } + ], + "source_artifacts": {}, + "trigger": { + "schema_version": "0.2", + "should_run": false, + "run_shipgate": false, + "skip": true, + "force_run": false, + "dry_run_recommended": false, + "skip_reason": "skip_rule", + "stop_conditions_fired": false, + "stop_conditions_evaluated": false, + "rationale": "skip_shipgate rule(s) matched (beats run_shipgate): TRIGGER-DOCS-ONLY-NEGATIVE.", + "matched_rules": [ + { + "id": "TRIGGER-DOCS-ONLY-NEGATIVE", + "action": "skip_shipgate", + "surface_class": "negative", + "rationale": "Docs-only OR tests-only PR; no tool surface impact. Covers the AGENTS.md row 'Pure read-only doc/test changes' \u2014 a tests/ directory edit that incidentally mentions `@tool` in a fixture or assertion no longer falsely triggers Shipgate.", + "command": null + } ], - "control": { - "state": "complete", - "reason": "No recognized coding-agent boundary change requires action.", - "completion_allowed": true, - "must_stop": false, - "verify_required": false, - "next_action": null, - "allowed_next_commands": [], - "human_review": { - "required": false, - "why": null, - "required_reviewers": [] - }, - "stop_reason": null - }, - "repair": { - "actor": "coding_agent", - "safe_to_attempt": false, - "instructions": [], - "forbidden_shortcuts": [ - "Do not suppress the finding (checks.ignore in shipgate.yaml).", - "Do not lower severity or add a waiver just to pass the gate.", - "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", - "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." - ] - }, - "policy": { - "id": "agent-boundary", - "version": "1", - "source": "packaged_default", - "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", - "discovery": [ - "packaged_default:agent-boundary" - ] - }, - "violated_rules": [], - "affected_files": [ - { - "path": "docs/readme.md" - } - ], - "required_reviewers": [], - "explanation": "No recognized coding-agent boundary change requires action.", - "suggested_fixes": [], - "agent_repair_instructions": [], - "diagnostics": [], - "trace": [ - { - "step": "policy_discovery", - "summary": "Loaded 2 coding-agent boundary policy families." - }, - { - "step": "decision", - "summary": "Projected 0 violation(s) to allow." - } + "changed_files": [ + "docs/readme.md" ], - "source_artifacts": {}, - "trigger": { - "schema_version": "0.2", - "should_run": false, - "run_shipgate": false, - "skip": true, - "force_run": false, - "dry_run_recommended": false, - "skip_reason": "skip_rule", - "stop_conditions_fired": false, - "stop_conditions_evaluated": false, - "rationale": "skip_shipgate rule(s) matched (beats run_shipgate): TRIGGER-DOCS-ONLY-NEGATIVE.", - "matched_rules": [ - { - "id": "TRIGGER-DOCS-ONLY-NEGATIVE", - "action": "skip_shipgate", - "surface_class": "negative", - "rationale": "Docs-only OR tests-only PR; no tool surface impact. Covers the AGENTS.md row 'Pure read-only doc/test changes' \u2014 a tests/ directory edit that incidentally mentions `@tool` in a fixture or assertion no longer falsely triggers Shipgate.", - "command": null - } - ], - "changed_files": [ - "docs/readme.md" - ], - "diff_tokens": [], - "next_action": { - "kind": "none", - "command": null, - "why": "skip_shipgate rule(s) matched (beats run_shipgate): TRIGGER-DOCS-ONLY-NEGATIVE." - } - }, - "finding_fingerprints": [], - "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" + "diff_tokens": [], + "next_action": { + "kind": "none", + "command": null, + "why": "skip_shipgate rule(s) matched (beats run_shipgate): TRIGGER-DOCS-ONLY-NEGATIVE." + } + }, + "finding_fingerprints": [], + "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" } diff --git a/tests/golden/codex_boundary_result/github_action_removed.json b/tests/golden/codex_boundary_result/github_action_removed.json index e0fe73c1..a364f1d5 100644 --- a/tests/golden/codex_boundary_result/github_action_removed.json +++ b/tests/golden/codex_boundary_result/github_action_removed.json @@ -1,158 +1,158 @@ { - "schema_version": "shipgate.codex_boundary_result/v2", - "agent": "codex", - "tool": { - "name": "agents-shipgate", - "version": "0.16.0b7" + "schema_version": "shipgate.codex_boundary_result/v2", + "agent": "codex", + "tool": { + "name": "agents-shipgate", + "version": "0.16.0b7" + }, + "subject": { + "agent": "codex" + }, + "decision": "block", + "risk_level": "critical", + "audit_id": "agent_boundary_4e81dd5f611f6d3d2e5149bd", + "policy_version": "1", + "summary": "1 coding-agent boundary change(s) block local continuation.", + "changed_files": [ + ".github/workflows/agents-shipgate.yml" + ], + "control": { + "state": "human_review_required", + "reason": "1 coding-agent boundary change(s) block local continuation.", + "completion_allowed": false, + "must_stop": true, + "verify_required": false, + "next_action": { + "actor": "human", + "kind": "stop", + "command": null, + "expects": null, + "why": "Shipgate GitHub Action no longer invokes the gate" }, - "subject": { - "agent": "codex" - }, - "decision": "block", - "risk_level": "critical", - "audit_id": "agent_boundary_488574aef143b8ccc5744839", - "policy_version": "1", - "summary": "1 coding-agent boundary change(s) block local continuation.", - "changed_files": [ - ".github/workflows/agents-shipgate.yml" - ], - "control": { - "state": "human_review_required", - "reason": "1 coding-agent boundary change(s) block local continuation.", - "completion_allowed": false, - "must_stop": true, - "verify_required": false, - "next_action": { - "actor": "human", - "kind": "stop", - "command": null, - "expects": null, - "why": "Shipgate GitHub Action no longer invokes the gate" - }, - "allowed_next_commands": [], - "human_review": { - "required": true, - "why": "Shipgate GitHub Action no longer invokes the gate", - "required_reviewers": [ - "agent-platform", - "security" - ] - }, - "stop_reason": "Shipgate GitHub Action no longer invokes the gate" - }, - "repair": { - "actor": "human", - "safe_to_attempt": false, - "instructions": [ - "Restore the Shipgate workflow or get human approval to remove it." - ], - "forbidden_shortcuts": [ - "Do not suppress the finding (checks.ignore in shipgate.yaml).", - "Do not lower severity or add a waiver just to pass the gate.", - "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", - "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." - ] - }, - "policy": { - "id": "agent-boundary", - "version": "1", - "source": "packaged_default", - "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", - "discovery": [ - "packaged_default:agent-boundary" - ] - }, - "violated_rules": [ - { - "id": "CODEX-CI-GATE-REMOVED", - "check_id": "SHIP-CODEX-BOUNDARY-CI-GATE-REMOVED", - "action": "block", - "risk_level": "critical", - "title": "Shipgate GitHub Action no longer invokes the gate", - "path": ".github/workflows/agents-shipgate.yml", - "evidence": { - "kind": "shipgate_ci_gate_removed", - "deleted": true, - "source": "diff_deleted_file", - "shipgate_invocation_present": false - }, - "recommendation": "Restore the Shipgate workflow or get human approval to remove it." - } - ], - "affected_files": [ - { - "path": ".github/workflows/agents-shipgate.yml" - } - ], - "required_reviewers": [ + "allowed_next_commands": [], + "human_review": { + "required": true, + "why": "Shipgate GitHub Action no longer invokes the gate", + "required_reviewers": [ "agent-platform", "security" + ] + }, + "stop_reason": "Shipgate GitHub Action no longer invokes the gate" + }, + "repair": { + "actor": "human", + "safe_to_attempt": false, + "instructions": [ + "Restore the Shipgate workflow or get human approval to remove it." ], - "explanation": "1 coding-agent boundary change(s) block local continuation.", - "suggested_fixes": [ - "Restore the Shipgate workflow or get human approval to remove it." - ], - "agent_repair_instructions": [ - "Restore the Shipgate workflow or get human approval to remove it." - ], - "diagnostics": [ - { - "level": "info", - "code": "content_source", - "message": "Evaluated coding-agent boundary file from diff_deleted_file.", - "path": ".github/workflows/agents-shipgate.yml" - } - ], - "trace": [ - { - "step": "policy_discovery", - "summary": "Loaded 2 coding-agent boundary policy families." - }, - { - "step": "decision", - "summary": "Projected 1 violation(s) to block." - } - ], - "source_artifacts": {}, - "trigger": { - "schema_version": "0.2", - "should_run": true, - "run_shipgate": true, - "skip": false, - "force_run": false, - "dry_run_recommended": false, - "skip_reason": null, - "stop_conditions_fired": false, - "stop_conditions_evaluated": false, - "rationale": "2 run_shipgate rule(s) matched.", - "matched_rules": [ - { - "id": "TRIGGER-SHARED-HOST-BOUNDARY-CHANGED", - "action": "run_shipgate", - "surface_class": "host_boundary", - "rationale": "Workflow permissions, pull_request_target, and write-capable automation are shared coding-agent trust roots.", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" - }, - { - "id": "TRIGGER-SHIPGATE-CI-WORKFLOW", - "action": "run_shipgate", - "surface_class": "governance", - "rationale": "CI gate changes touch the release-readiness path itself.", - "command": null - } - ], - "changed_files": [ - ".github/workflows/agents-shipgate.yml" - ], - "diff_tokens": [], - "next_action": { - "kind": "command", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", - "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." - } + "forbidden_shortcuts": [ + "Do not suppress the finding (checks.ignore in shipgate.yaml).", + "Do not lower severity or add a waiver just to pass the gate.", + "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", + "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + ] + }, + "policy": { + "id": "agent-boundary", + "version": "1", + "source": "packaged_default", + "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", + "discovery": [ + "packaged_default:agent-boundary" + ] + }, + "violated_rules": [ + { + "id": "CODEX-CI-GATE-REMOVED", + "check_id": "SHIP-CODEX-BOUNDARY-CI-GATE-REMOVED", + "action": "block", + "risk_level": "critical", + "title": "Shipgate GitHub Action no longer invokes the gate", + "path": ".github/workflows/agents-shipgate.yml", + "evidence": { + "kind": "shipgate_ci_gate_removed", + "deleted": true, + "source": "diff_deleted_file", + "shipgate_invocation_present": false + }, + "recommendation": "Restore the Shipgate workflow or get human approval to remove it." + } + ], + "affected_files": [ + { + "path": ".github/workflows/agents-shipgate.yml" + } + ], + "required_reviewers": [ + "agent-platform", + "security" + ], + "explanation": "1 coding-agent boundary change(s) block local continuation.", + "suggested_fixes": [ + "Restore the Shipgate workflow or get human approval to remove it." + ], + "agent_repair_instructions": [ + "Restore the Shipgate workflow or get human approval to remove it." + ], + "diagnostics": [ + { + "level": "info", + "code": "content_source", + "message": "Evaluated coding-agent boundary file from diff_deleted_file.", + "path": ".github/workflows/agents-shipgate.yml" + } + ], + "trace": [ + { + "step": "policy_discovery", + "summary": "Loaded 2 coding-agent boundary policy families." }, - "finding_fingerprints": [ - "fp_fe55c27fa455ddfd" + { + "step": "decision", + "summary": "Projected 1 violation(s) to block." + } + ], + "source_artifacts": {}, + "trigger": { + "schema_version": "0.2", + "should_run": true, + "run_shipgate": true, + "skip": false, + "force_run": false, + "dry_run_recommended": false, + "skip_reason": null, + "stop_conditions_fired": false, + "stop_conditions_evaluated": false, + "rationale": "2 run_shipgate rule(s) matched.", + "matched_rules": [ + { + "id": "TRIGGER-SHARED-HOST-BOUNDARY-CHANGED", + "action": "run_shipgate", + "surface_class": "host_boundary", + "rationale": "Workflow permissions, pull_request_target, and write-capable automation are shared coding-agent trust roots.", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" + }, + { + "id": "TRIGGER-SHIPGATE-CI-WORKFLOW", + "action": "run_shipgate", + "surface_class": "governance", + "rationale": "CI gate changes touch the release-readiness path itself.", + "command": null + } + ], + "changed_files": [ + ".github/workflows/agents-shipgate.yml" ], - "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" + "diff_tokens": [], + "next_action": { + "kind": "command", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", + "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." + } + }, + "finding_fingerprints": [ + "fp_fe55c27fa455ddfd" + ], + "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" } diff --git a/tests/golden/codex_boundary_result/malformed_toml.json b/tests/golden/codex_boundary_result/malformed_toml.json index 6e2f932f..583fa1cb 100644 --- a/tests/golden/codex_boundary_result/malformed_toml.json +++ b/tests/golden/codex_boundary_result/malformed_toml.json @@ -1,147 +1,147 @@ { - "schema_version": "shipgate.codex_boundary_result/v2", - "agent": "codex", - "tool": { - "name": "agents-shipgate", - "version": "0.16.0b7" + "schema_version": "shipgate.codex_boundary_result/v2", + "agent": "codex", + "tool": { + "name": "agents-shipgate", + "version": "0.16.0b7" + }, + "subject": { + "agent": "codex" + }, + "decision": "require_review", + "risk_level": "medium", + "audit_id": "agent_boundary_ab70411380f721e1dfcda658", + "policy_version": "1", + "summary": "1 coding-agent boundary change(s) require human review.", + "changed_files": [ + ".codex/config.toml" + ], + "control": { + "state": "human_review_required", + "reason": "1 coding-agent boundary change(s) require human review.", + "completion_allowed": false, + "must_stop": true, + "verify_required": false, + "next_action": { + "actor": "human", + "kind": "review", + "command": null, + "expects": null, + "why": "Codex config could not be parsed" }, - "subject": { - "agent": "codex" + "allowed_next_commands": [], + "human_review": { + "required": true, + "why": "Codex config could not be parsed", + "required_reviewers": [ + "agent-platform" + ] }, - "decision": "require_review", - "risk_level": "medium", - "audit_id": "agent_boundary_04887e3b07ba7c7961f61c2a", - "policy_version": "1", - "summary": "1 coding-agent boundary change(s) require human review.", - "changed_files": [ - ".codex/config.toml" + "stop_reason": "Codex config could not be parsed" + }, + "repair": { + "actor": "human", + "safe_to_attempt": false, + "instructions": [ + "Fix the TOML or have a human review the Codex boundary change." ], - "control": { - "state": "human_review_required", - "reason": "1 coding-agent boundary change(s) require human review.", - "completion_allowed": false, - "must_stop": true, - "verify_required": false, - "next_action": { - "actor": "human", - "kind": "review", - "command": null, - "expects": null, - "why": "Codex config could not be parsed" - }, - "allowed_next_commands": [], - "human_review": { - "required": true, - "why": "Codex config could not be parsed", - "required_reviewers": [ - "agent-platform" - ] - }, - "stop_reason": "Codex config could not be parsed" - }, - "repair": { - "actor": "human", - "safe_to_attempt": false, - "instructions": [ - "Fix the TOML or have a human review the Codex boundary change." - ], - "forbidden_shortcuts": [ - "Do not suppress the finding (checks.ignore in shipgate.yaml).", - "Do not lower severity or add a waiver just to pass the gate.", - "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", - "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." - ] - }, - "policy": { - "id": "agent-boundary", - "version": "1", - "source": "packaged_default", - "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", - "discovery": [ - "packaged_default:agent-boundary" - ] + "forbidden_shortcuts": [ + "Do not suppress the finding (checks.ignore in shipgate.yaml).", + "Do not lower severity or add a waiver just to pass the gate.", + "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", + "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + ] + }, + "policy": { + "id": "agent-boundary", + "version": "1", + "source": "packaged_default", + "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", + "discovery": [ + "packaged_default:agent-boundary" + ] + }, + "violated_rules": [ + { + "id": "CODEX-CONFIG-PARSE-FAILED", + "check_id": "SHIP-CODEX-BOUNDARY-CONFIG-PARSE-FAILED", + "action": "require_review", + "risk_level": "medium", + "title": "Codex config could not be parsed", + "path": ".codex/config.toml", + "evidence": { + "kind": "toml_parse_failed", + "parser": "TOMLDecodeError" + }, + "recommendation": "Fix the TOML or have a human review the Codex boundary change." + } + ], + "affected_files": [ + { + "path": ".codex/config.toml" + } + ], + "required_reviewers": [ + "agent-platform" + ], + "explanation": "1 coding-agent boundary change(s) require human review.", + "suggested_fixes": [ + "Fix the TOML or have a human review the Codex boundary change." + ], + "agent_repair_instructions": [ + "Fix the TOML or have a human review the Codex boundary change." + ], + "diagnostics": [ + { + "level": "info", + "code": "content_source", + "message": "Evaluated coding-agent boundary file from diff_new_file.", + "path": ".codex/config.toml" + } + ], + "trace": [ + { + "step": "policy_discovery", + "summary": "Loaded 2 coding-agent boundary policy families." }, - "violated_rules": [ - { - "id": "CODEX-CONFIG-PARSE-FAILED", - "check_id": "SHIP-CODEX-BOUNDARY-CONFIG-PARSE-FAILED", - "action": "require_review", - "risk_level": "medium", - "title": "Codex config could not be parsed", - "path": ".codex/config.toml", - "evidence": { - "kind": "toml_parse_failed", - "parser": "TOMLDecodeError" - }, - "recommendation": "Fix the TOML or have a human review the Codex boundary change." - } - ], - "affected_files": [ - { - "path": ".codex/config.toml" - } - ], - "required_reviewers": [ - "agent-platform" + { + "step": "decision", + "summary": "Projected 1 violation(s) to require_review." + } + ], + "source_artifacts": {}, + "trigger": { + "schema_version": "0.2", + "should_run": true, + "run_shipgate": true, + "skip": false, + "force_run": false, + "dry_run_recommended": false, + "skip_reason": null, + "stop_conditions_fired": false, + "stop_conditions_evaluated": false, + "rationale": "1 run_shipgate rule(s) matched.", + "matched_rules": [ + { + "id": "TRIGGER-CODEX-BOUNDARY-CONFIG-CHANGED", + "action": "run_shipgate", + "surface_class": "host_boundary", + "rationale": "Repo-local Codex config controls sandboxing, network access, MCP approvals, hooks, and permission profiles; changes need a local boundary check.", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" + } ], - "explanation": "1 coding-agent boundary change(s) require human review.", - "suggested_fixes": [ - "Fix the TOML or have a human review the Codex boundary change." - ], - "agent_repair_instructions": [ - "Fix the TOML or have a human review the Codex boundary change." - ], - "diagnostics": [ - { - "level": "info", - "code": "content_source", - "message": "Evaluated coding-agent boundary file from diff_new_file.", - "path": ".codex/config.toml" - } - ], - "trace": [ - { - "step": "policy_discovery", - "summary": "Loaded 2 coding-agent boundary policy families." - }, - { - "step": "decision", - "summary": "Projected 1 violation(s) to require_review." - } - ], - "source_artifacts": {}, - "trigger": { - "schema_version": "0.2", - "should_run": true, - "run_shipgate": true, - "skip": false, - "force_run": false, - "dry_run_recommended": false, - "skip_reason": null, - "stop_conditions_fired": false, - "stop_conditions_evaluated": false, - "rationale": "1 run_shipgate rule(s) matched.", - "matched_rules": [ - { - "id": "TRIGGER-CODEX-BOUNDARY-CONFIG-CHANGED", - "action": "run_shipgate", - "surface_class": "host_boundary", - "rationale": "Repo-local Codex config controls sandboxing, network access, MCP approvals, hooks, and permission profiles; changes need a local boundary check.", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" - } - ], - "changed_files": [ - ".codex/config.toml" - ], - "diff_tokens": [], - "next_action": { - "kind": "command", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", - "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." - } - }, - "finding_fingerprints": [ - "fp_b4b0f36a5cd21d78" + "changed_files": [ + ".codex/config.toml" ], - "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" + "diff_tokens": [], + "next_action": { + "kind": "command", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", + "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." + } + }, + "finding_fingerprints": [ + "fp_b4b0f36a5cd21d78" + ], + "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" } 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 df5067ab..ee03359f 100644 --- a/tests/golden/codex_boundary_result/mcp_auto_approve_write.json +++ b/tests/golden/codex_boundary_result/mcp_auto_approve_write.json @@ -1,155 +1,155 @@ { - "schema_version": "shipgate.codex_boundary_result/v2", - "agent": "codex", - "tool": { - "name": "agents-shipgate", - "version": "0.16.0b7" + "schema_version": "shipgate.codex_boundary_result/v2", + "agent": "codex", + "tool": { + "name": "agents-shipgate", + "version": "0.16.0b7" + }, + "subject": { + "agent": "codex" + }, + "decision": "block", + "risk_level": "critical", + "audit_id": "agent_boundary_8ab0716dccde23cdcf3bbc69", + "policy_version": "1", + "summary": "1 coding-agent boundary change(s) block local continuation.", + "changed_files": [ + ".codex/config.toml" + ], + "control": { + "state": "human_review_required", + "reason": "1 coding-agent boundary change(s) block local continuation.", + "completion_allowed": false, + "must_stop": true, + "verify_required": false, + "next_action": { + "actor": "human", + "kind": "stop", + "command": null, + "expects": null, + "why": "Codex MCP write tool is auto-approved" }, - "subject": { - "agent": "codex" + "allowed_next_commands": [], + "human_review": { + "required": true, + "why": "Codex MCP write tool is auto-approved", + "required_reviewers": [ + "agent-platform", + "security" + ] }, - "decision": "block", - "risk_level": "critical", - "audit_id": "agent_boundary_78c4542efc6c9ede23d89129", - "policy_version": "1", - "summary": "1 coding-agent boundary change(s) block local continuation.", - "changed_files": [ - ".codex/config.toml" + "stop_reason": "Codex MCP write tool is auto-approved" + }, + "repair": { + "actor": "human", + "safe_to_attempt": false, + "instructions": [ + "Do not auto-approve write/destructive MCP tools." ], - "control": { - "state": "human_review_required", - "reason": "1 coding-agent boundary change(s) block local continuation.", - "completion_allowed": false, - "must_stop": true, - "verify_required": false, - "next_action": { - "actor": "human", - "kind": "stop", - "command": null, - "expects": null, - "why": "Codex MCP write tool is auto-approved" - }, - "allowed_next_commands": [], - "human_review": { - "required": true, - "why": "Codex MCP write tool is auto-approved", - "required_reviewers": [ - "agent-platform", - "security" - ] - }, - "stop_reason": "Codex MCP write tool is auto-approved" - }, - "repair": { - "actor": "human", - "safe_to_attempt": false, - "instructions": [ - "Do not auto-approve write/destructive MCP tools." + "forbidden_shortcuts": [ + "Do not suppress the finding (checks.ignore in shipgate.yaml).", + "Do not lower severity or add a waiver just to pass the gate.", + "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", + "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + ] + }, + "policy": { + "id": "agent-boundary", + "version": "1", + "source": "packaged_default", + "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", + "discovery": [ + "packaged_default:agent-boundary" + ] + }, + "violated_rules": [ + { + "id": "CODEX-MCP-AUTO-APPROVE-WRITE", + "check_id": "SHIP-CODEX-BOUNDARY-MCP-AUTO-APPROVE-WRITE", + "action": "block", + "risk_level": "critical", + "title": "Codex MCP write tool is auto-approved", + "path": ".codex/config.toml", + "evidence": { + "kind": "mcp_default_auto_approve", + "server": "mcp_servers.filesystem", + "risky_tools": [ + "write_file" ], - "forbidden_shortcuts": [ - "Do not suppress the finding (checks.ignore in shipgate.yaml).", - "Do not lower severity or add a waiver just to pass the gate.", - "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", - "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + "tool_names": [ + "write_file" ] + }, + "recommendation": "Do not auto-approve write/destructive MCP tools." + } + ], + "affected_files": [ + { + "path": ".codex/config.toml" + } + ], + "required_reviewers": [ + "agent-platform", + "security" + ], + "explanation": "1 coding-agent boundary change(s) block local continuation.", + "suggested_fixes": [ + "Do not auto-approve write/destructive MCP tools." + ], + "agent_repair_instructions": [ + "Do not auto-approve write/destructive MCP tools." + ], + "diagnostics": [ + { + "level": "info", + "code": "content_source", + "message": "Evaluated coding-agent boundary file from diff_new_file.", + "path": ".codex/config.toml" + } + ], + "trace": [ + { + "step": "policy_discovery", + "summary": "Loaded 2 coding-agent boundary policy families." }, - "policy": { - "id": "agent-boundary", - "version": "1", - "source": "packaged_default", - "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", - "discovery": [ - "packaged_default:agent-boundary" - ] - }, - "violated_rules": [ - { - "id": "CODEX-MCP-AUTO-APPROVE-WRITE", - "check_id": "SHIP-CODEX-BOUNDARY-MCP-AUTO-APPROVE-WRITE", - "action": "block", - "risk_level": "critical", - "title": "Codex MCP write tool is auto-approved", - "path": ".codex/config.toml", - "evidence": { - "kind": "mcp_default_auto_approve", - "server": "mcp_servers.filesystem", - "risky_tools": [ - "write_file" - ], - "tool_names": [ - "write_file" - ] - }, - "recommendation": "Do not auto-approve write/destructive MCP tools." - } - ], - "affected_files": [ - { - "path": ".codex/config.toml" - } - ], - "required_reviewers": [ - "agent-platform", - "security" - ], - "explanation": "1 coding-agent boundary change(s) block local continuation.", - "suggested_fixes": [ - "Do not auto-approve write/destructive MCP tools." - ], - "agent_repair_instructions": [ - "Do not auto-approve write/destructive MCP tools." - ], - "diagnostics": [ - { - "level": "info", - "code": "content_source", - "message": "Evaluated coding-agent boundary file from diff_new_file.", - "path": ".codex/config.toml" - } - ], - "trace": [ - { - "step": "policy_discovery", - "summary": "Loaded 2 coding-agent boundary policy families." - }, - { - "step": "decision", - "summary": "Projected 1 violation(s) to block." - } + { + "step": "decision", + "summary": "Projected 1 violation(s) to block." + } + ], + "source_artifacts": {}, + "trigger": { + "schema_version": "0.2", + "should_run": true, + "run_shipgate": true, + "skip": false, + "force_run": false, + "dry_run_recommended": false, + "skip_reason": null, + "stop_conditions_fired": false, + "stop_conditions_evaluated": false, + "rationale": "1 run_shipgate rule(s) matched.", + "matched_rules": [ + { + "id": "TRIGGER-CODEX-BOUNDARY-CONFIG-CHANGED", + "action": "run_shipgate", + "surface_class": "host_boundary", + "rationale": "Repo-local Codex config controls sandboxing, network access, MCP approvals, hooks, and permission profiles; changes need a local boundary check.", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" + } ], - "source_artifacts": {}, - "trigger": { - "schema_version": "0.2", - "should_run": true, - "run_shipgate": true, - "skip": false, - "force_run": false, - "dry_run_recommended": false, - "skip_reason": null, - "stop_conditions_fired": false, - "stop_conditions_evaluated": false, - "rationale": "1 run_shipgate rule(s) matched.", - "matched_rules": [ - { - "id": "TRIGGER-CODEX-BOUNDARY-CONFIG-CHANGED", - "action": "run_shipgate", - "surface_class": "host_boundary", - "rationale": "Repo-local Codex config controls sandboxing, network access, MCP approvals, hooks, and permission profiles; changes need a local boundary check.", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" - } - ], - "changed_files": [ - ".codex/config.toml" - ], - "diff_tokens": [], - "next_action": { - "kind": "command", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", - "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." - } - }, - "finding_fingerprints": [ - "fp_71c94cd92ba68747" + "changed_files": [ + ".codex/config.toml" ], - "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" + "diff_tokens": [], + "next_action": { + "kind": "command", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", + "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." + } + }, + "finding_fingerprints": [ + "fp_71c94cd92ba68747" + ], + "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" } diff --git a/tests/golden/codex_boundary_result/network_wildcard.json b/tests/golden/codex_boundary_result/network_wildcard.json index 15ba82fb..0d5ff06f 100644 --- a/tests/golden/codex_boundary_result/network_wildcard.json +++ b/tests/golden/codex_boundary_result/network_wildcard.json @@ -1,149 +1,149 @@ { - "schema_version": "shipgate.codex_boundary_result/v2", - "agent": "codex", - "tool": { - "name": "agents-shipgate", - "version": "0.16.0b7" + "schema_version": "shipgate.codex_boundary_result/v2", + "agent": "codex", + "tool": { + "name": "agents-shipgate", + "version": "0.16.0b7" + }, + "subject": { + "agent": "codex" + }, + "decision": "require_review", + "risk_level": "high", + "audit_id": "agent_boundary_50543b9fbfaca23e2753012e", + "policy_version": "1", + "summary": "1 coding-agent boundary change(s) require human review.", + "changed_files": [ + ".codex/config.toml" + ], + "control": { + "state": "human_review_required", + "reason": "1 coding-agent boundary change(s) require human review.", + "completion_allowed": false, + "must_stop": true, + "verify_required": false, + "next_action": { + "actor": "human", + "kind": "review", + "command": null, + "expects": null, + "why": "Codex network profile allows a wildcard domain" }, - "subject": { - "agent": "codex" + "allowed_next_commands": [], + "human_review": { + "required": true, + "why": "Codex network profile allows a wildcard domain", + "required_reviewers": [ + "agent-platform" + ] }, - "decision": "require_review", - "risk_level": "high", - "audit_id": "agent_boundary_1697437c484c034da283fa0e", - "policy_version": "1", - "summary": "1 coding-agent boundary change(s) require human review.", - "changed_files": [ - ".codex/config.toml" + "stop_reason": "Codex network profile allows a wildcard domain" + }, + "repair": { + "actor": "human", + "safe_to_attempt": false, + "instructions": [ + "Replace wildcard network access with explicit allowed domains." ], - "control": { - "state": "human_review_required", - "reason": "1 coding-agent boundary change(s) require human review.", - "completion_allowed": false, - "must_stop": true, - "verify_required": false, - "next_action": { - "actor": "human", - "kind": "review", - "command": null, - "expects": null, - "why": "Codex network profile allows a wildcard domain" - }, - "allowed_next_commands": [], - "human_review": { - "required": true, - "why": "Codex network profile allows a wildcard domain", - "required_reviewers": [ - "agent-platform" - ] - }, - "stop_reason": "Codex network profile allows a wildcard domain" - }, - "repair": { - "actor": "human", - "safe_to_attempt": false, - "instructions": [ - "Replace wildcard network access with explicit allowed domains." - ], - "forbidden_shortcuts": [ - "Do not suppress the finding (checks.ignore in shipgate.yaml).", - "Do not lower severity or add a waiver just to pass the gate.", - "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", - "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." - ] - }, - "policy": { - "id": "agent-boundary", - "version": "1", - "source": "packaged_default", - "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", - "discovery": [ - "packaged_default:agent-boundary" - ] + "forbidden_shortcuts": [ + "Do not suppress the finding (checks.ignore in shipgate.yaml).", + "Do not lower severity or add a waiver just to pass the gate.", + "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", + "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + ] + }, + "policy": { + "id": "agent-boundary", + "version": "1", + "source": "packaged_default", + "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", + "discovery": [ + "packaged_default:agent-boundary" + ] + }, + "violated_rules": [ + { + "id": "CODEX-NETWORK-WILDCARD", + "check_id": "SHIP-CODEX-BOUNDARY-NETWORK-WILDCARD", + "action": "require_review", + "risk_level": "high", + "title": "Codex network profile allows a wildcard domain", + "path": ".codex/config.toml", + "evidence": { + "kind": "network_domain_wildcard", + "profile": "workspace", + "domain": "*", + "value": "allow" + }, + "recommendation": "Replace wildcard network access with explicit allowed domains." + } + ], + "affected_files": [ + { + "path": ".codex/config.toml" + } + ], + "required_reviewers": [ + "agent-platform" + ], + "explanation": "1 coding-agent boundary change(s) require human review.", + "suggested_fixes": [ + "Replace wildcard network access with explicit allowed domains." + ], + "agent_repair_instructions": [ + "Replace wildcard network access with explicit allowed domains." + ], + "diagnostics": [ + { + "level": "info", + "code": "content_source", + "message": "Evaluated coding-agent boundary file from diff_new_file.", + "path": ".codex/config.toml" + } + ], + "trace": [ + { + "step": "policy_discovery", + "summary": "Loaded 2 coding-agent boundary policy families." }, - "violated_rules": [ - { - "id": "CODEX-NETWORK-WILDCARD", - "check_id": "SHIP-CODEX-BOUNDARY-NETWORK-WILDCARD", - "action": "require_review", - "risk_level": "high", - "title": "Codex network profile allows a wildcard domain", - "path": ".codex/config.toml", - "evidence": { - "kind": "network_domain_wildcard", - "profile": "workspace", - "domain": "*", - "value": "allow" - }, - "recommendation": "Replace wildcard network access with explicit allowed domains." - } - ], - "affected_files": [ - { - "path": ".codex/config.toml" - } - ], - "required_reviewers": [ - "agent-platform" + { + "step": "decision", + "summary": "Projected 1 violation(s) to require_review." + } + ], + "source_artifacts": {}, + "trigger": { + "schema_version": "0.2", + "should_run": true, + "run_shipgate": true, + "skip": false, + "force_run": false, + "dry_run_recommended": false, + "skip_reason": null, + "stop_conditions_fired": false, + "stop_conditions_evaluated": false, + "rationale": "1 run_shipgate rule(s) matched.", + "matched_rules": [ + { + "id": "TRIGGER-CODEX-BOUNDARY-CONFIG-CHANGED", + "action": "run_shipgate", + "surface_class": "host_boundary", + "rationale": "Repo-local Codex config controls sandboxing, network access, MCP approvals, hooks, and permission profiles; changes need a local boundary check.", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" + } ], - "explanation": "1 coding-agent boundary change(s) require human review.", - "suggested_fixes": [ - "Replace wildcard network access with explicit allowed domains." - ], - "agent_repair_instructions": [ - "Replace wildcard network access with explicit allowed domains." - ], - "diagnostics": [ - { - "level": "info", - "code": "content_source", - "message": "Evaluated coding-agent boundary file from diff_new_file.", - "path": ".codex/config.toml" - } - ], - "trace": [ - { - "step": "policy_discovery", - "summary": "Loaded 2 coding-agent boundary policy families." - }, - { - "step": "decision", - "summary": "Projected 1 violation(s) to require_review." - } - ], - "source_artifacts": {}, - "trigger": { - "schema_version": "0.2", - "should_run": true, - "run_shipgate": true, - "skip": false, - "force_run": false, - "dry_run_recommended": false, - "skip_reason": null, - "stop_conditions_fired": false, - "stop_conditions_evaluated": false, - "rationale": "1 run_shipgate rule(s) matched.", - "matched_rules": [ - { - "id": "TRIGGER-CODEX-BOUNDARY-CONFIG-CHANGED", - "action": "run_shipgate", - "surface_class": "host_boundary", - "rationale": "Repo-local Codex config controls sandboxing, network access, MCP approvals, hooks, and permission profiles; changes need a local boundary check.", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" - } - ], - "changed_files": [ - ".codex/config.toml" - ], - "diff_tokens": [], - "next_action": { - "kind": "command", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", - "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." - } - }, - "finding_fingerprints": [ - "fp_97b6fa25c1f62073" + "changed_files": [ + ".codex/config.toml" ], - "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" + "diff_tokens": [], + "next_action": { + "kind": "command", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", + "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." + } + }, + "finding_fingerprints": [ + "fp_97b6fa25c1f62073" + ], + "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" } diff --git a/tests/golden/codex_boundary_result/python_refactor.json b/tests/golden/codex_boundary_result/python_refactor.json index a006a256..b5030429 100644 --- a/tests/golden/codex_boundary_result/python_refactor.json +++ b/tests/golden/codex_boundary_result/python_refactor.json @@ -1,100 +1,100 @@ { - "schema_version": "shipgate.codex_boundary_result/v2", - "agent": "codex", - "tool": { - "name": "agents-shipgate", - "version": "0.16.0b7" + "schema_version": "shipgate.codex_boundary_result/v2", + "agent": "codex", + "tool": { + "name": "agents-shipgate", + "version": "0.16.0b7" + }, + "subject": { + "agent": "codex" + }, + "decision": "allow", + "risk_level": "none", + "audit_id": "agent_boundary_1ebb48e94c2e807c806d1f20", + "policy_version": "1", + "summary": "No recognized coding-agent boundary change requires action.", + "changed_files": [ + "src/example.py" + ], + "control": { + "state": "complete", + "reason": "No recognized coding-agent boundary change requires action.", + "completion_allowed": true, + "must_stop": false, + "verify_required": false, + "next_action": null, + "allowed_next_commands": [], + "human_review": { + "required": false, + "why": null, + "required_reviewers": [] }, - "subject": { - "agent": "codex" + "stop_reason": null + }, + "repair": { + "actor": "coding_agent", + "safe_to_attempt": false, + "instructions": [], + "forbidden_shortcuts": [ + "Do not suppress the finding (checks.ignore in shipgate.yaml).", + "Do not lower severity or add a waiver just to pass the gate.", + "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", + "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + ] + }, + "policy": { + "id": "agent-boundary", + "version": "1", + "source": "packaged_default", + "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", + "discovery": [ + "packaged_default:agent-boundary" + ] + }, + "violated_rules": [], + "affected_files": [ + { + "path": "src/example.py" + } + ], + "required_reviewers": [], + "explanation": "No recognized coding-agent boundary change requires action.", + "suggested_fixes": [], + "agent_repair_instructions": [], + "diagnostics": [], + "trace": [ + { + "step": "policy_discovery", + "summary": "Loaded 2 coding-agent boundary policy families." }, - "decision": "allow", - "risk_level": "none", - "audit_id": "agent_boundary_ca11f1833eaccd04b795f492", - "policy_version": "1", - "summary": "No recognized coding-agent boundary change requires action.", + { + "step": "decision", + "summary": "Projected 0 violation(s) to allow." + } + ], + "source_artifacts": {}, + "trigger": { + "schema_version": "0.2", + "should_run": false, + "run_shipgate": false, + "skip": true, + "force_run": false, + "dry_run_recommended": false, + "skip_reason": "no_match", + "stop_conditions_fired": false, + "stop_conditions_evaluated": false, + "rationale": "No rules matched; nothing in this PR signals a tool-surface change.", + "matched_rules": [], "changed_files": [ - "src/example.py" + "src/example.py" ], - "control": { - "state": "complete", - "reason": "No recognized coding-agent boundary change requires action.", - "completion_allowed": true, - "must_stop": false, - "verify_required": false, - "next_action": null, - "allowed_next_commands": [], - "human_review": { - "required": false, - "why": null, - "required_reviewers": [] - }, - "stop_reason": null - }, - "repair": { - "actor": "coding_agent", - "safe_to_attempt": false, - "instructions": [], - "forbidden_shortcuts": [ - "Do not suppress the finding (checks.ignore in shipgate.yaml).", - "Do not lower severity or add a waiver just to pass the gate.", - "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", - "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." - ] - }, - "policy": { - "id": "agent-boundary", - "version": "1", - "source": "packaged_default", - "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", - "discovery": [ - "packaged_default:agent-boundary" - ] - }, - "violated_rules": [], - "affected_files": [ - { - "path": "src/example.py" - } - ], - "required_reviewers": [], - "explanation": "No recognized coding-agent boundary change requires action.", - "suggested_fixes": [], - "agent_repair_instructions": [], - "diagnostics": [], - "trace": [ - { - "step": "policy_discovery", - "summary": "Loaded 2 coding-agent boundary policy families." - }, - { - "step": "decision", - "summary": "Projected 0 violation(s) to allow." - } - ], - "source_artifacts": {}, - "trigger": { - "schema_version": "0.2", - "should_run": false, - "run_shipgate": false, - "skip": true, - "force_run": false, - "dry_run_recommended": false, - "skip_reason": "no_match", - "stop_conditions_fired": false, - "stop_conditions_evaluated": false, - "rationale": "No rules matched; nothing in this PR signals a tool-surface change.", - "matched_rules": [], - "changed_files": [ - "src/example.py" - ], - "diff_tokens": [], - "next_action": { - "kind": "none", - "command": null, - "why": "No rules matched; nothing in this PR signals a tool-surface change." - } - }, - "finding_fingerprints": [], - "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" + "diff_tokens": [], + "next_action": { + "kind": "none", + "command": null, + "why": "No rules matched; nothing in this PR signals a tool-surface change." + } + }, + "finding_fingerprints": [], + "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" } diff --git a/tests/golden/codex_boundary_result/unknown_permission_key.json b/tests/golden/codex_boundary_result/unknown_permission_key.json index 6c7e1ee7..ba3cb783 100644 --- a/tests/golden/codex_boundary_result/unknown_permission_key.json +++ b/tests/golden/codex_boundary_result/unknown_permission_key.json @@ -1,146 +1,146 @@ { - "schema_version": "shipgate.codex_boundary_result/v2", - "agent": "codex", - "tool": { - "name": "agents-shipgate", - "version": "0.16.0b7" + "schema_version": "shipgate.codex_boundary_result/v2", + "agent": "codex", + "tool": { + "name": "agents-shipgate", + "version": "0.16.0b7" + }, + "subject": { + "agent": "codex" + }, + "decision": "require_review", + "risk_level": "medium", + "audit_id": "agent_boundary_ce82234cfd7e3f18108d7bcc", + "policy_version": "1", + "summary": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", + "changed_files": [ + ".codex/config.toml" + ], + "control": { + "state": "agent_action_required", + "reason": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", + "completion_allowed": false, + "must_stop": false, + "verify_required": true, + "next_action": { + "actor": "coding_agent", + "kind": "verify", + "command": "agents-shipgate verify --json", + "expects": null, + "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." }, - "subject": { - "agent": "codex" - }, - "decision": "require_review", - "risk_level": "medium", - "audit_id": "agent_boundary_7908865cfe6b4fa73441f451", - "policy_version": "1", - "summary": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", - "changed_files": [ - ".codex/config.toml" + "allowed_next_commands": [ + "agents-shipgate verify --json" ], - "control": { - "state": "agent_action_required", - "reason": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", - "completion_allowed": false, - "must_stop": false, - "verify_required": true, - "next_action": { - "actor": "coding_agent", - "kind": "verify", - "command": "agents-shipgate verify --json", - "expects": null, - "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": [ - "agents-shipgate verify --json" - ], - "human_review": { - "required": false, - "why": null, - "required_reviewers": [] - }, - "stop_reason": null - }, - "repair": { - "actor": "human", - "safe_to_attempt": false, - "instructions": [ - "Review the unknown permission key before trusting the boundary." - ], - "forbidden_shortcuts": [ - "Do not suppress the finding (checks.ignore in shipgate.yaml).", - "Do not lower severity or add a waiver just to pass the gate.", - "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", - "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." - ] + "human_review": { + "required": false, + "why": null, + "required_reviewers": [] }, - "policy": { - "id": "agent-boundary", - "version": "1", - "source": "packaged_default", - "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", - "discovery": [ - "packaged_default:agent-boundary" - ] - }, - "violated_rules": [ - { - "id": "CODEX-UNKNOWN-PERMISSION-KEY", - "check_id": "SHIP-CODEX-BOUNDARY-UNKNOWN-PERMISSION-KEY", - "action": "require_review", - "risk_level": "medium", - "title": "Unknown Codex permission key", - "path": ".codex/config.toml", - "evidence": { - "kind": "unknown_permission_network_key", - "profile": "workspace", - "key": "surprise" - }, - "recommendation": "Review the unknown permission key before trusting the boundary." - } - ], - "affected_files": [ - { - "path": ".codex/config.toml" - } - ], - "required_reviewers": [], - "explanation": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", - "suggested_fixes": [ - "Review the unknown permission key before trusting the boundary." + "stop_reason": null + }, + "repair": { + "actor": "human", + "safe_to_attempt": false, + "instructions": [ + "Review the unknown permission key before trusting the boundary." ], - "agent_repair_instructions": [ - "Review the unknown permission key before trusting the boundary." - ], - "diagnostics": [ - { - "level": "info", - "code": "content_source", - "message": "Evaluated coding-agent boundary file from diff_new_file.", - "path": ".codex/config.toml" - } - ], - "trace": [ - { - "step": "policy_discovery", - "summary": "Loaded 2 coding-agent boundary policy families." - }, - { - "step": "decision", - "summary": "Projected 1 violation(s) to require_review." - } - ], - "source_artifacts": {}, - "trigger": { - "schema_version": "0.2", - "should_run": true, - "run_shipgate": true, - "skip": false, - "force_run": false, - "dry_run_recommended": false, - "skip_reason": null, - "stop_conditions_fired": false, - "stop_conditions_evaluated": false, - "rationale": "1 run_shipgate rule(s) matched.", - "matched_rules": [ - { - "id": "TRIGGER-CODEX-BOUNDARY-CONFIG-CHANGED", - "action": "run_shipgate", - "surface_class": "host_boundary", - "rationale": "Repo-local Codex config controls sandboxing, network access, MCP approvals, hooks, and permission profiles; changes need a local boundary check.", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" - } - ], - "changed_files": [ - ".codex/config.toml" - ], - "diff_tokens": [], - "next_action": { - "kind": "command", - "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", - "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." - } + "forbidden_shortcuts": [ + "Do not suppress the finding (checks.ignore in shipgate.yaml).", + "Do not lower severity or add a waiver just to pass the gate.", + "Do not invent or assume approval, idempotency, or audit evidence you cannot prove from the code.", + "Do not weaken the release policy, CI gate, or agent instructions that evaluate this change." + ] + }, + "policy": { + "id": "agent-boundary", + "version": "1", + "source": "packaged_default", + "snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a", + "discovery": [ + "packaged_default:agent-boundary" + ] + }, + "violated_rules": [ + { + "id": "CODEX-UNKNOWN-PERMISSION-KEY", + "check_id": "SHIP-CODEX-BOUNDARY-UNKNOWN-PERMISSION-KEY", + "action": "require_review", + "risk_level": "medium", + "title": "Unknown Codex permission key", + "path": ".codex/config.toml", + "evidence": { + "kind": "unknown_permission_network_key", + "profile": "workspace", + "key": "surprise" + }, + "recommendation": "Review the unknown permission key before trusting the boundary." + } + ], + "affected_files": [ + { + "path": ".codex/config.toml" + } + ], + "required_reviewers": [], + "explanation": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", + "suggested_fixes": [ + "Review the unknown permission key before trusting the boundary." + ], + "agent_repair_instructions": [ + "Review the unknown permission key before trusting the boundary." + ], + "diagnostics": [ + { + "level": "info", + "code": "content_source", + "message": "Evaluated coding-agent boundary file from diff_new_file.", + "path": ".codex/config.toml" + } + ], + "trace": [ + { + "step": "policy_discovery", + "summary": "Loaded 2 coding-agent boundary policy families." }, - "finding_fingerprints": [ - "fp_b75e128442b5e376" + { + "step": "decision", + "summary": "Projected 1 violation(s) to require_review." + } + ], + "source_artifacts": {}, + "trigger": { + "schema_version": "0.2", + "should_run": true, + "run_shipgate": true, + "skip": false, + "force_run": false, + "dry_run_recommended": false, + "skip_reason": null, + "stop_conditions_fired": false, + "stop_conditions_evaluated": false, + "rationale": "1 run_shipgate rule(s) matched.", + "matched_rules": [ + { + "id": "TRIGGER-CODEX-BOUNDARY-CONFIG-CHANGED", + "action": "run_shipgate", + "surface_class": "host_boundary", + "rationale": "Repo-local Codex config controls sandboxing, network access, MCP approvals, hooks, and permission profiles; changes need a local boundary check.", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json" + } + ], + "changed_files": [ + ".codex/config.toml" ], - "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" + "diff_tokens": [], + "next_action": { + "kind": "command", + "command": "shipgate check --agent codex --workspace . --format agent-boundary-json", + "why": "This change looks agent-related; start with verify preview and adopt Shipgate if the preview routes setup." + } + }, + "finding_fingerprints": [ + "fp_b75e128442b5e376" + ], + "policy_snapshot_sha256": "5ad04b64bfccf46ef42e7fe3602a5ead6b50bce8f31a51b6d3b43910e493260a" } diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index 467bcc51..9dc570ff 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -303,7 +303,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=671, + line=710, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " diff --git a/tests/test_agent_boundary.py b/tests/test_agent_boundary.py index 63819898..b53a4f58 100644 --- a/tests/test_agent_boundary.py +++ b/tests/test_agent_boundary.py @@ -785,3 +785,49 @@ def test_composite_manifest_diff_never_reads_as_an_adoption(tmp_path: Path) -> N row.evidence.get("kind") != "manifest_introduced" for row in result.violated_rules ) + + +def test_a_custom_named_manifest_is_protected_by_check(tmp_path: Path) -> None: + """`check` classified only `**/shipgate.yaml`. + + A repository run with `--config new-gate.yml` got `allow` with no + violations for a diff that rewrote its own gate. + """ + + target = tmp_path / "new-gate.yml" + target.write_text(_MANIFEST, encoding="utf-8") + result = build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=_change_diff("new-gate.yml", _MANIFEST, _MANIFEST + "ci:\n mode: advisory\n"), + config=Path("new-gate.yml"), + policy=None, + input_mode="worktree", + ) + + rows = [item for item in result.violated_rules if item.path == "new-gate.yml"] + assert rows, result.violated_rules + assert rows[0].evidence.get("trust_root_class") == "manifest" + # A gate-governing surface must not ride the graded agent route. + assert result.decision == "require_review" + assert result.control.state == "human_review_required" + assert result.control.must_stop is True + + +def test_the_default_manifest_keeps_its_classification(tmp_path: Path) -> None: + """The configured-manifest path must not shadow the table's own class.""" + + target = tmp_path / "shipgate.yaml" + target.write_text(_MANIFEST, encoding="utf-8") + result = build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=_change_diff("shipgate.yaml", _MANIFEST, _MANIFEST + "ci:\n mode: advisory\n"), + config=Path("shipgate.yaml"), + policy=None, + input_mode="worktree", + ) + + rows = [item for item in result.violated_rules if item.path == "shipgate.yaml"] + assert rows + assert result.control.state == "human_review_required" diff --git a/tests/test_agent_mode.py b/tests/test_agent_mode.py index 93a0b2e0..7b260704 100644 --- a/tests/test_agent_mode.py +++ b/tests/test_agent_mode.py @@ -586,3 +586,128 @@ def test_audit_baseline_directory_is_an_other_error( assert payload["error"] == "other_error" assert payload["exit_code"] == 4 _assert_documented_envelope(payload) + + +def test_check_recovery_keeps_the_rest_of_the_request( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A fixed command discards every valid argument around the bad one. + + Following it switched actor, workspace, config, and policy — answering a + different boundary question than the one that failed. + """ + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + result = runner.invoke( + app, + [ + "check", + "--agent", + "cursor", + "--workspace", + str(tmp_path), + "--config", + "new-gate.yml", + "--format", + "nope", + ], + ) + + assert result.exit_code == 2 + command = _agent_mode_error(result)["next_actions"][0]["command"] + assert "--agent cursor" in command + assert str(tmp_path) in command + assert "--config new-gate.yml" in command + assert "--format agent-boundary-json" in command + + +def test_check_offers_no_command_for_a_stdin_diff( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + result = runner.invoke( + app, ["check", "--diff", "-", "--format", "nope"], input="" + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + assert payload["next_actions"][0]["kind"] == "review" + + +def test_preflight_offers_no_command_for_conflicting_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A replay of a request-shape conflict can never satisfy its own expects.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + (tmp_path / "plan.json").write_text("{}", encoding="utf-8") + (tmp_path / "changed.txt").write_text("README.md\n", encoding="utf-8") + result = runner.invoke( + app, + [ + "preflight", + "--workspace", + str(tmp_path), + "--plan", + str(tmp_path / "plan.json"), + "--changed-files", + str(tmp_path / "changed.txt"), + ], + ) + + assert result.exit_code != 0 + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + assert payload["next_actions"][0]["kind"] == "review" + + +def test_audit_missing_baseline_stays_a_config_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """"Record one first" is a request problem, not a filesystem failure.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--drift", + "--baseline-file", + str(tmp_path / "absent.json"), + ], + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + assert payload["error"] == "config_error" + assert "--save-baseline" in payload["next_actions"][0]["command"] + + +def test_audit_unreadable_baseline_is_a_filesystem_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The loader turns every read OSError into ValueError; the cause matters.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + (tmp_path / "as-a-dir").mkdir() + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--drift", + "--baseline-file", + str(tmp_path / "as-a-dir"), + ], + ) + + assert result.exit_code == 4 + payload = _agent_mode_error(result) + assert payload["error"] == "other_error" + _assert_documented_envelope(payload) diff --git a/tests/test_first_adoption.py b/tests/test_first_adoption.py index f98d173c..5f9c70f0 100644 --- a/tests/test_first_adoption.py +++ b/tests/test_first_adoption.py @@ -212,9 +212,11 @@ def _review(*, policy_weakened=False, trust_root_touched=False): def test_adoption_headline_replaces_the_weakening_claim(): - review = _review(policy_weakened=True, trust_root_touched=True) + review = _review(trust_root_touched=True) adopting = _self_approval_note(review, manifest_introduced=True) - modifying = _self_approval_note(review, manifest_introduced=False) + modifying = _self_approval_note( + _review(policy_weakened=True), manifest_introduced=False + ) assert adopting is not None and modifying is not None assert "introduces Agents Shipgate" in adopting @@ -241,6 +243,22 @@ def test_adoption_raises_the_prohibition_on_its_own(): assert _self_approval_note(review, manifest_introduced=True) is not None +def test_a_weakened_policy_beats_the_adoption_wording(): + """Introducing the manifest while weakening an existing policy file. + + "There is no prior gate this change could weaken" would describe away the + very finding that needs review. + """ + + mixed = _self_approval_note( + _review(policy_weakened=True, trust_root_touched=True), + manifest_introduced=True, + ) + assert mixed is not None + assert "weakens the release policy" in mixed + assert "introduces Agents Shipgate" not in mixed + + def test_non_adoption_wording_is_unchanged(): assert _self_approval_note(None, manifest_introduced=False) is None assert _self_approval_note(_review(), manifest_introduced=False) is None @@ -278,10 +296,10 @@ def test_adoption_reports_no_policy_weakening_to_machine_consumers(tmp_path): # --- end to end -------------------------------------------------------------- -def _run_verify(repo: Path, *, base: str | None, head: str): +def _run_verify(repo: Path, *, base: str | None, head: str, config: Path | None = None): verifier, _report, _exit = run_verify( workspace=repo, - config=SAMPLE_CONFIG, + config=config or SAMPLE_CONFIG, base=base, head=head, archive_head=True, @@ -505,3 +523,74 @@ def test_the_rerun_command_repeats_the_whole_request(tmp_path): assert "--no-heuristics" in command assert "--no-plugins" in command assert f"--config {SAMPLE_CONFIG.as_posix()}" in command + + +def test_custom_manifest_evidence_is_deterministic(tmp_path): + """Evidence is hashed into the fingerprint, so it cannot carry a temp path. + + A committed-head run loads its manifest from a freshly named + `agents-shipgate-verify-head-*` archive; returning that resolved path made + two identical runs produce different fingerprints and report run ids. + """ + + repo = _repo_adopting_shipgate(tmp_path) + _git( + repo, + "mv", + "samples/support_refund_agent/shipgate.yaml", + "samples/support_refund_agent/new-gate.yml", + ) + _git(repo, "commit", "-m", "custom manifest name") + + config = Path("samples/support_refund_agent/new-gate.yml") + first = _run_verify(repo, base="HEAD~1", head="HEAD", config=config) + second = _run_verify(repo, base="HEAD~1", head="HEAD", config=config) + + payloads = [ + json.loads((repo / "agents-shipgate-reports" / "report.json").read_text("utf-8")) + ] + assert first.request_id == second.request_id + evidence = [ + finding["evidence"] + for finding in payloads[0]["findings"] + if finding["check_id"] == "SHIP-VERIFY-TRUST-ROOT-TOUCHED" + ] + assert evidence, payloads[0]["findings"] + for item in evidence: + assert "agents-shipgate-verify-head-" not in json.dumps(item) + + +def test_a_base_that_keeps_another_manifest_is_not_an_adoption(tmp_path): + """Absence has to be established, not inferred from two basenames. + + A base that retains an operational `old-gate.yml` deletes nothing and + matches no name check, so only a content probe separates it from a genuine + first adoption. + """ + + repo = _repo_adopting_shipgate(tmp_path) + sample = repo / "samples" / "support_refund_agent" + _git( + repo, + "mv", + "samples/support_refund_agent/shipgate.yaml", + "samples/support_refund_agent/old-gate.yml", + ) + _git(repo, "commit", "-m", "base keeps a custom manifest") + (sample / "new-gate.yml").write_text( + (sample / "old-gate.yml").read_text("utf-8"), encoding="utf-8" + ) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "add a second manifest without removing the first") + + assert ( + _manifest_introduced( + git_root=repo, + config_relative=Path("samples/support_refund_agent/new-gate.yml"), + base_status="missing_manifest", + base="HEAD~1", + head="HEAD", + changed_files=["samples/support_refund_agent/new-gate.yml"], + ) + is False + ) diff --git a/tests/test_fix_task_contract.py b/tests/test_fix_task_contract.py index d05cfa3c..736b8330 100644 --- a/tests/test_fix_task_contract.py +++ b/tests/test_fix_task_contract.py @@ -750,7 +750,7 @@ def test_first_adoption_replaces_the_weakening_wording() -> None: task = build_fix_task( _trust_root_report(), merge_verdict="human_review_required", - capability_review=_review(policy_weakened=True, trust_root_touched=True), + capability_review=_review(trust_root_touched=True), base_ref="origin/main", head_ref="HEAD", manifest_introduced=True, @@ -767,6 +767,25 @@ def test_first_adoption_replaces_the_weakening_wording() -> None: assert not {"review_policy_weakening", "review_trust_root"} & repair_ids +def test_an_adoption_that_also_weakens_policy_keeps_the_weakening_repair() -> None: + """`review_policy_weakening` must not vanish behind adoption wording.""" + + task = build_fix_task( + _trust_root_report(), + merge_verdict="human_review_required", + capability_review=_review(policy_weakened=True, trust_root_touched=True), + base_ref="origin/main", + head_ref="HEAD", + manifest_introduced=True, + ) + + assert task is not None + joined = " ".join(task.instructions) + assert "cannot self-approve" in joined + assert "nothing existing was weakened" not in joined + assert "review_policy_weakening" in {r.id for r in task.allowed_repairs} + + def test_adoption_escalates_without_borrowing_another_flag() -> None: """An adoption is an authority decision in its own right. diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 54d1da3b..da1a9eca 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -2,6 +2,7 @@ import difflib import json +import shlex import sys from pathlib import Path @@ -135,8 +136,12 @@ def test_preflight_allows_exact_append_only_builtin_source_proposal( assert result.control.state == "agent_action_required" assert result.control.must_stop is False assert result.control.next_action.kind == "verify" + # The command echoes the request as it was made, so a run against another + # checkout or a non-default manifest does not hand the reader a command + # pointing at a different gate. assert result.control.allowed_next_commands == [ - "agents-shipgate verify --workspace . --config shipgate.yaml --ci-mode advisory --json" + f"agents-shipgate verify --workspace {shlex.quote(str(root))} " + "--config shipgate.yaml --ci-mode advisory --json" ] signal = next(item for item in result.signals if item.kind == "protected_surface_touch") assert signal.actor == "coding_agent" @@ -678,7 +683,8 @@ def test_cli_preflight_plan_stdin_routes_clean_docs_to_verify(tmp_path: Path) -> assert payload["requires_human_review"] is False assert payload["first_next_action"]["kind"] == "verify" assert payload["allowed_next_commands"] == [ - "agents-shipgate verify --workspace . --config shipgate.yaml --ci-mode advisory --json" + f"agents-shipgate verify --workspace {shlex.quote(str(root))} " + "--config shipgate.yaml --ci-mode advisory --json" ] assert payload["control"]["state"] == "agent_action_required" assert payload["control"]["completion_allowed"] is False From 105c85484e72cf2e7a06fa871d6ec94c4c006c04 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Wed, 29 Jul 2026 12:45:56 -0700 Subject: [PATCH 08/12] Address round four: bind every emitted command to its own target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. A malformed, unknown-schema, or integrity-failed host-grants baseline recommended --save-baseline against the same path. Following that recovery overwrote the failed artifact with the current grants, acknowledging them unreviewed and destroying the evidence a human needed to see. Those route to review now; only a genuinely absent baseline gets a record command, and it carries the requested --scope. 2. The retained-manifest probe grepped for unquoted `project:`/`agent:`. A manifest with quoted keys loads fine and was invisible to it, so the base looked empty and the run claimed a first adoption. Candidates are parsed now, bounded by count and size, failing closed on anything unreadable. (The git pathspec I first used silently matched nothing, which would have made the probe answer "no manifest" for every tree — filtering happens in Python.) 3. `check` authorized a bare `agents-shipgate verify --json`, dropping the workspace and manifest it had just evaluated. The command is threaded through the boundary control projection, and the config is rendered the way verify resolves it — relative to the repository root — which is the same defect preflight had for nested manifests. Both now share one builder. 4. Configured-manifest identity is compared on normalized, containment-checked paths. `docs/x/../manifest.yaml` returned allow with zero violations against the normalized changed path. Callers that know their workspace pass it, making the comparison exact; the fallback over-matches, which adds a finding rather than dropping one. 5. Preflight kept only the destination of a rename, so renaming the configured manifest to an unrecognized name produced no protected touch and an agent-action route. Both sides of every record are classified. 6. `check` recovery is one quoted serializer for every path. The diff-input failure still used the legacy builder, which joined user-controlled paths and refs unquoted into a published *authorized* command and hardcoded the workspace. A one-sided ref range keeps its documented "omit both and check the working tree" repair on that path and only that path; elsewhere dropping a requested range silently answers a different question, so it returns review. 7. Existing codex audit ids no longer rotate: the actor is folded into the digest only when it is not the default, so every id issued before detection existed keeps its value. My previous changelog claimed this while the diff did the opposite. 8. `assessment_for_scan_context` passes the configured manifest, so the local check and full verify stop publishing different evidence for the same diff. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 33 +++++-- src/agents_shipgate/cli/agent_result.py | 2 + src/agents_shipgate/cli/check.py | 90 +++++++++++++++---- src/agents_shipgate/cli/host_audit.py | 40 ++++++--- src/agents_shipgate/cli/verify/git.py | 85 +++++++++++------- src/agents_shipgate/core/agent_boundary.py | 56 +++++++++--- src/agents_shipgate/core/agent_controls.py | 78 +++++++++++++++- src/agents_shipgate/core/codex_boundary.py | 44 +++++++-- src/agents_shipgate/core/preflight.py | 72 ++++++++------- src/agents_shipgate/core/trust_roots.py | 66 ++++++++++---- .../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 | 6 +- tests/test_adapter_static_only.py | 2 +- tests/test_agent_boundary.py | 75 ++++++++++++++++ tests/test_codex_boundary_check.py | 20 ++++- tests/test_first_adoption.py | 50 +++++++++++ tests/test_preflight.py | 28 +++--- 23 files changed, 605 insertions(+), 156 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 828568cc..3d1e2e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ snapshot)", and — because a missing-manifest base was classified as a safe recovery — shipped no `fix_task` at all, so nothing named the act that would clear it. `verify` now proves adoption from git (the comparison base carries - no manifest under any name and no YAML that reads like one, so neither a - *moved* manifest nor a base that quietly keeps one can pass itself off as a - first adoption) and says so: same check id, same `medium` severity, same + no manifest under any name and no YAML that *parses* as one — a text probe + missed a valid manifest with quoted keys — so neither a *moved* manifest nor + a base that quietly keeps one can pass itself off as a first adoption) and says so: same check id, same `medium` severity, same `human_review_required` state, new evidence kind `manifest_introduced`, and a `fix_task` whose leading instruction is "review the generated shipgate.yaml and merge the adoption through a human-reviewed PR". `check` gets the same @@ -27,8 +27,13 @@ Whatever a run loads as its gate is now classified as one, in both the trust-root check and the policy fail-safe — and in `check` and `preflight`, which classified it no better: a diff for a custom-named manifest returned - `allow` with no violations locally and no protected touch in preflight, and - both then recommended a verify command for the default `shipgate.yaml`. The + `allow` with no violations locally and no protected touch in preflight — the + local check dropped it from the diff entirely before any evaluator saw it — + and both then recommended a verify command for the default `shipgate.yaml`. + Identity is compared on normalized, containment-checked paths, so an + equivalent spelling (`docs/x/../manifest.yaml`) cannot slip past, and + preflight classifies the *source* side of a rename, which is where the gate + sits when a diff moves it out from under itself. The classification is recorded on the boundary row so a gate-governing surface stays out of the graded agent route regardless of its name, and the evidence carries the changed path rather than the resolved config path, which for a @@ -72,7 +77,20 @@ `control.state=complete`. The recovery action now reproduces the actual invocation, and offers no command at all when the request came from stdin or mixed `--plan` with the per-flag inputs — replaying a request-shape conflict - can never satisfy its own `expects`. + can never satisfy its own `expects`. `check`'s recovery is one quoted + serializer for every path, including diff-input failures, which previously + joined user-controlled paths and refs unquoted into a published *authorized* + command; commands the CLI emits for its own targets now name the workspace + and the manifest, with the config rendered the way `verify` resolves it — + relative to the repository root — so a nested manifest is no longer verified + against the root gate. +- **A failed baseline is never recovered by overwriting it.** A malformed, + unknown-schema, or integrity-failed host-grants baseline recommended + `--save-baseline` against the same path, which replaced the failed artifact + with the *current* grants — acknowledging them unreviewed and destroying the + evidence a human needed. Those now route to review; only a genuinely absent + baseline gets a record command, and it carries the `--scope` it was asked + for. - **Host-audit filesystem failures follow the catalog.** A `--baseline-file` naming a directory raised `IsADirectoryError` through typer as a traceback and exit 1. Filesystem failures on both `--baseline-file` and `--out` are now @@ -87,7 +105,8 @@ actor and the legacy one hardcoded `codex` — so identical evaluations by Claude Code, Cursor, and Codex shared an `audit_id`, which is exactly the attribution problem actor detection exists to solve. A codex run's id is - unchanged; the committed boundary goldens are regenerated for the other two. + unchanged — the actor is folded in only for a non-default one, so every id + issued before detection existed (all of them codex runs) keeps its value. - **Adoption wording stands down when something was genuinely weakened.** Introducing the manifest while editing an existing policy file produced a `base_snapshot_unavailable` finding under a headline saying there was no diff --git a/src/agents_shipgate/cli/agent_result.py b/src/agents_shipgate/cli/agent_result.py index 13b0ae7b..e3288a49 100644 --- a/src/agents_shipgate/cli/agent_result.py +++ b/src/agents_shipgate/cli/agent_result.py @@ -84,6 +84,7 @@ def _assessment_for_diff( input_mode: str, input_issues: list[BoundaryInputIssue] | None, ): + workspace_as_given = workspace workspace = workspace.resolve() diff_files = parse_unified_diff(diff_text) changed_files = sorted({item.path for item in diff_files if item.path}) @@ -116,6 +117,7 @@ def _assessment_for_diff( input_mode=input_mode, # type: ignore[arg-type] input_issues=input_issues, config_path=config_path, + requested_workspace=workspace_as_given, ) diff --git a/src/agents_shipgate/cli/check.py b/src/agents_shipgate/cli/check.py index 4d6ac786..ec2616b6 100644 --- a/src/agents_shipgate/cli/check.py +++ b/src/agents_shipgate/cli/check.py @@ -18,10 +18,19 @@ ) from agents_shipgate.core.agent_control import derive_agent_control from agents_shipgate.schemas.agent_boundary import AgentBoundaryResultV1 -from agents_shipgate.schemas.agent_control import CodingAgentCommandAction +from agents_shipgate.schemas.agent_control import ( + CodingAgentCommandAction, + HumanControlAction, +) from agents_shipgate.schemas.codex_boundary_result import CodexBoundaryResultV2 from agents_shipgate.schemas.diagnostics import NextAction +_AMBIGUOUS_DIFF_INPUT_WHY = ( + "The failing check request cannot be replayed as-is — its diff came from " + "stdin, or only one of --base/--head was given. Decide the intended input " + "and re-run the check yourself." +) + def _corrected_request( *, @@ -33,18 +42,34 @@ def _corrected_request( base: str | None, head: str | None, format_: str, + allow_ref_fallback: bool = False, ) -> str | None: """The same check request with only the invalid field corrected. A fixed ``agents-shipgate check --format agent-boundary-json`` discards every other argument, so following the rank-1 action switched actor, workspace, config, policy, and diff/range context — answering a different - boundary question than the one that failed. ``None`` when the request read - its diff from stdin and therefore cannot be replayed. + boundary question than the one that failed. + + Every interpolated value is shell-quoted. These are user-controlled paths + and refs, and this string is published as an *authorized* command: a + semicolon in ``--diff`` would otherwise become a command boundary in it. + + A half-specified ref range has two repairs — supply the missing ref, or + drop both and check the working tree. ``allow_ref_fallback`` picks the + second, which is the documented recovery when the diff input itself could + not be resolved. Everywhere else the choice is not ours to make: silently + dropping a range the caller asked for answers a different question, so the + caller gets ``None`` and a review action. + + ``None`` also when the diff was read from stdin and cannot be replayed. """ if diff == "-": return None + one_sided_range = not diff and bool(base) != bool(head) + if one_sided_range and not allow_ref_fallback: + return None parts = ["agents-shipgate", "check"] if agent in {"codex", "claude-code", "cursor"}: parts.extend(["--agent", agent]) @@ -56,6 +81,8 @@ def _corrected_request( parts.extend(["--diff", shlex.quote(diff)]) elif base and head: parts.extend(["--base", shlex.quote(base), "--head", shlex.quote(head)]) + # A one-sided range under the fallback emits neither ref: that is the + # "omit both for local uncommitted changes" recovery the instructions name. valid_format = format_ if format_ == "codex-boundary-json" else "agent-boundary-json" parts.extend(["--format", valid_format]) return " ".join(parts) @@ -185,6 +212,9 @@ def corrected(*, valid_agent: str | None) -> str | None: result = _diff_input_error_result( agent=agent, workspace=workspace, + config=config, + policy=policy, + format_=format_, diff=diff, base=base, head=head, @@ -218,12 +248,25 @@ def _diff_input_error_result( *, agent: str, workspace: Path, + config: Path, + policy: Path | None, + format_: str, diff: str | None, base: str | None, head: str | None, error: str, ) -> CodexBoundaryResultV2: - command = _rerun_command(agent=agent, diff=diff, base=base, head=head) + command = _corrected_request( + agent=agent, + workspace=workspace, + config=config, + policy=policy, + diff=diff, + base=base, + head=head, + format_=format_, + allow_ref_fallback=True, + ) summary = "Agents Shipgate could not resolve the diff input for local agent control." return CodexBoundaryResultV2( agent=agent, @@ -240,27 +283,40 @@ def _diff_input_error_result( policy_version="unresolved", summary=summary, changed_files=[], - control=derive_agent_control( - reason=summary, - next_action=CodingAgentCommandAction( - kind="repair", - command=command, - why=( - "Fix the diff input, make the requested git refs available, or omit " - "--base/--head for local uncommitted changes; then rerun shipgate check." + control=( + derive_agent_control( + reason=summary, + next_action=CodingAgentCommandAction( + kind="repair", + command=command, + why=( + "Fix the diff input, make the requested git refs available, or omit " + "--base/--head for local uncommitted changes; then rerun shipgate check." + ), + ), + allowed_next_commands=[command], + ) + if command + else derive_agent_control( + reason=summary, + next_action=HumanControlAction( + kind="review", + why=_AMBIGUOUS_DIFF_INPUT_WHY, ), - ), - allowed_next_commands=[command], + human_review_required=True, + human_review_why=_AMBIGUOUS_DIFF_INPUT_WHY, + stop_reason=_AMBIGUOUS_DIFF_INPUT_WHY, + ) ), repair={ - "actor": "coding_agent", - "safe_to_attempt": True, + "actor": "coding_agent" if command else "human", + "safe_to_attempt": bool(command), "instructions": [ f"Resolve diff input error: {error}", "Provide both --base and --head for committed refs, or omit both for local work.", "If --diff names a file, make sure the file exists and contains a unified diff.", ], - "command": command, + **({"command": command} if command else {}), "forbidden_shortcuts": [ "Do not claim completion without a successful shipgate check rerun.", "Do not infer a Shipgate decision from prose or a failed command.", diff --git a/src/agents_shipgate/cli/host_audit.py b/src/agents_shipgate/cli/host_audit.py index fe25af62..6219e271 100644 --- a/src/agents_shipgate/cli/host_audit.py +++ b/src/agents_shipgate/cli/host_audit.py @@ -52,7 +52,7 @@ def _config_error( message: str, *, next_action: str, - command: str = "agents-shipgate audit --host", + command: str | None = "agents-shipgate audit --host", ) -> typer.Exit: """Report flag misuse on both channels and return the exit to raise. @@ -64,16 +64,21 @@ def _config_error( """ typer.echo(message, err=True) - emit_agent_mode_error_action( - "config_error", - message=message, - exit_code=2, - action=NextAction( + action = ( + NextAction( kind="command", command=command, why=next_action, expects="A host-capability audit that completes.", - ), + ) + if command + else NextAction(kind="review", why=next_action) + ) + emit_agent_mode_error_action( + "config_error", + message=message, + exit_code=2, + action=action, ) return typer.Exit(2) @@ -233,16 +238,31 @@ def audit( "then re-run the audit." ), ) from exc + # A baseline that exists but does not load — malformed, unknown + # schema, integrity-failed — must never be recovered by writing + # over it. Recommending --save-baseline there replaced the failed + # artifact with the *current* grants, silently acknowledging them + # and destroying the evidence a human needed to look at. Only a + # genuinely absent baseline can be recorded without losing + # anything, and that command carries the scope it was asked for. + missing = isinstance(exc.__cause__, FileNotFoundError) raise _config_error( str(exc), next_action=( "Record a baseline with `agents-shipgate audit --host " - "--save-baseline`, or point --baseline-file at a valid one." + "--save-baseline`." + if missing + else "Inspect the existing baseline file and repair or " + "replace it deliberately; do not overwrite it with the " + "current grants, which would acknowledge them unreviewed." ), command=( "agents-shipgate audit --host --workspace " - f"{shlex.quote(str(workspace))} --save-baseline " - f"--baseline-file {shlex.quote(str(baseline_file))}" + f"{shlex.quote(str(workspace))} --scope {shlex.quote(scope)} " + f"--save-baseline --baseline-file " + f"{shlex.quote(str(baseline_file))}" + if missing + else None ), ) from exc payload = build_host_drift_payload( diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 2872c3e8..226c4481 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -11,6 +11,8 @@ from typing import Any, Literal from urllib.parse import urlsplit +import yaml + from agents_shipgate.core.errors import ConfigError from agents_shipgate.schemas.human_authorization import canonical_https_git_endpoint @@ -395,43 +397,66 @@ def paths_named_at_ref( _MANIFEST_SUFFIXES = (".yaml", ".yml") +# Bounds on the retained-manifest probe. A tree with more candidate YAML files +# than this, or a candidate larger than this, is not worth reading to decide a +# wording question — the probe reports "cannot prove" and the plainer copy wins. +_MAX_MANIFEST_CANDIDATES = 400 +_MAX_MANIFEST_BYTES = 512 * 1024 + +# The keys every Shipgate manifest must carry. Matching on parsed structure +# rather than on raw text is what makes quoted keys, differing indentation, and +# flow mappings all read the same. +_MANIFEST_REQUIRED_KEYS = frozenset({"project", "agent"}) + + def carries_manifest_like_yaml(workspace: Path, ref: str) -> bool | None: - """Whether ``ref`` contains any YAML that looks like a Shipgate manifest. + """Whether ``ref`` contains any YAML that parses as a Shipgate manifest. A basename check cannot prove a base carries no gate: a manifest may be called anything, so a base that keeps an operational ``old-gate.yml`` while - the head adds ``new-gate.yml`` passes every name test. This asks a content - question instead — any tracked YAML carrying the manifest's required - top-level keys. It over-matches by design: an unrelated YAML with - ``project:`` and ``agent:`` merely costs the adoption wording, which is the - safe direction. - - ``None`` means the search could not run; callers must treat that as "cannot - prove", never as absence. + the head adds ``new-gate.yml`` passes every name test. + + The candidates are *parsed*, not grepped. A text probe for ``^project:`` + misses a valid manifest whose keys are quoted, indented, or written in flow + style — and a manifest that loads fine while the probe says "absent" is the + fail-open this exists to prevent. Over-matching is deliberate: an unrelated + YAML carrying both keys merely costs the adoption wording. + + ``None`` means the answer could not be established — an unreadable tree, an + unparseable candidate, or more candidates than the bounds above allow. + Callers must treat it as "cannot prove", never as absence. """ - result = _run_git( - workspace, - [ - "grep", - "--all-match", - "-l", - "-e", - "^project:", - "-e", - "^agent:", - ref, - "--", - "*.yaml", - "*.yml", - ], - check=False, - ) - if result.returncode == 1: # documented: nothing matched - return False - if result.returncode != 0: + listing = _run_git(workspace, ["ls-tree", "-r", "--name-only", ref], check=False) + if listing.returncode != 0: + return None + # Filtered here rather than with a pathspec: `git ls-tree -- '*.yml'` does + # not glob the way it reads — it matches nothing, which would make this + # probe silently answer "no manifest" for every tree. + candidates = [ + line.strip() + for line in listing.stdout.splitlines() + if line.strip().lower().endswith(_MANIFEST_SUFFIXES) + ] + if len(candidates) > _MAX_MANIFEST_CANDIDATES: return None - return bool(result.stdout.strip()) + for candidate in candidates: + blob = _run_git(workspace, ["show", f"{ref}:{candidate}"], check=False) + if blob.returncode != 0: + return None + text = blob.stdout + if len(text) > _MAX_MANIFEST_BYTES: + return None + try: + document = yaml.safe_load(text) + except yaml.YAMLError: + # Unparseable YAML cannot be ruled out as a manifest. + return None + if isinstance(document, dict) and _MANIFEST_REQUIRED_KEYS <= { + str(key) for key in document + }: + return True + return False def removes_a_yaml_file(workspace: Path, base: str | None, head: str) -> bool | None: diff --git a/src/agents_shipgate/core/agent_boundary.py b/src/agents_shipgate/core/agent_boundary.py index ff8742fd..f89c178c 100644 --- a/src/agents_shipgate/core/agent_boundary.py +++ b/src/agents_shipgate/core/agent_boundary.py @@ -44,6 +44,7 @@ _violation_fingerprint, evaluate_codex_boundary_result, load_codex_boundary_policy, + verify_command_for, violations_within_agent_actionable_band, ) from agents_shipgate.core.host_boundary import ( @@ -75,6 +76,9 @@ ) from agents_shipgate.schemas.codex_boundary_result import CodexBoundaryResultV2 +# The actor every pre-detection audit id implicitly described. +_LEGACY_AUDIT_ACTOR = "codex" + UNIFIED_POLICY_PATH = Path("policies/agent-boundary.shipgate.yaml") LEGACY_CODEX_POLICY_PATH = Path("policies/codex-boundary.shipgate.yaml") @@ -82,6 +86,7 @@ @dataclass(frozen=True) class AgentBoundaryAssessment: actor: str + verify_command: str input_mode: Literal["worktree", "git_range", "provided_diff"] scope: Literal["repository"] input_coverage: Literal["complete", "partial", "unknown"] @@ -168,7 +173,11 @@ def evaluate_agent_boundary( input_issues: list[BoundaryInputIssue] | None = None, host_snapshot: HostBoundarySnapshot | None = None, config_path: Path | None = None, + requested_workspace: Path | None = None, ) -> AgentBoundaryAssessment: + # The command this assessment authorizes must evaluate the target that was + # actually checked, not the default manifest in the current directory. + verify_command = verify_command_for(requested_workspace, config_path) workspace = workspace.resolve() host_snapshot = host_snapshot or build_host_boundary_snapshot( workspace, @@ -184,7 +193,7 @@ def evaluate_agent_boundary( and ( path == item.path or is_agent_boundary_path(path) - or is_configured_manifest(config_path, path) + or is_configured_manifest(config_path, path, workspace=workspace) ) } ) @@ -213,6 +222,7 @@ def evaluate_agent_boundary( diff_files_override=diff_files, resolved_text_cache=resolved_text_cache, static_read_cache=host_snapshot.cache, + verify_command=verify_command, ) host_violations, host_diagnostics = evaluate_host_boundary( workspace=workspace, @@ -241,8 +251,9 @@ def evaluate_agent_boundary( combined = _with_unclassified_protected_changes( changed_files=changed_files, violations=combined, - adoption_paths=_manifest_adoption_paths(diff_files, config_path), + adoption_paths=_manifest_adoption_paths(diff_files, config_path, workspace), config_path=config_path, + workspace=workspace, evaluated_paths={ item.path for item in diagnostics @@ -352,6 +363,7 @@ def evaluate_agent_boundary( diagnostics = _sanitize_diagnostics(diagnostics) projected = _project_legacy( + verify_command=verify_command, legacy=legacy, violations=combined, diagnostics=diagnostics, @@ -379,6 +391,7 @@ def evaluate_agent_boundary( ) return AgentBoundaryAssessment( actor=actor, + verify_command=verify_command, input_mode=input_mode, scope="repository", input_coverage=input_coverage, @@ -450,6 +463,10 @@ def assessment_for_scan_context(context) -> AgentBoundaryAssessment: diff_text=diff_text, trigger=verification.trigger_result, input_mode="provided_diff", + # Without this a custom-named manifest produced the protected-surface + # boundary finding under local `check` but not under full `verify`, so + # the two public surfaces published different evidence for one diff. + config_path=Path(context.config_path), ) return context.agent_boundary @@ -461,6 +478,7 @@ def _project_legacy( diagnostics: list[AgentResultDiagnostic], policy_set: _PolicySet, release_decision: dict[str, Any] | None, + verify_command: str | None = None, ) -> CodexBoundaryResultV2: needs_reprojection = violations != legacy.violated_rules or bool(policy_set.issues) if needs_reprojection: @@ -471,6 +489,7 @@ def _project_legacy( summary = _boundary_summary(decision, violations) first_action = _next_action_for(decision, violations, repair) control = _control_for_result( + verify_command=verify_command, decision=decision, summary=summary, first_next_action=first_action, @@ -560,15 +579,23 @@ def _agent_boundary_audit_id( ) -> str: """Identity of one audited evaluation. - The actor belongs in it. The result records which agent was evaluated, so - two runs differing only by actor are two audit rows, not one — and without - it, detecting the actor changed the label while leaving every Claude Code - and Cursor run indistinguishable from a codex run in the audit trail. + The actor belongs in it: the result records which agent was evaluated, so + two runs differing only by actor are two audit rows, not one — without it, + detecting the actor changed the label while leaving every Claude Code and + Cursor run indistinguishable from a codex run in the audit trail. + + It is folded in only for a non-default actor. Every id issued before actor + detection existed was a codex run, and rotating those would break stored + references for a change that tells them nothing new. """ payload = { "schema": AGENT_BOUNDARY_RESULT_SCHEMA_VERSION, - "actor": actor, + # Added only for a non-default actor, so every id issued before actor + # detection existed keeps its value. Rotating established codex ids + # would break anyone who stored one, and the identity contract is not + # versioned separately from the schema. + **({"actor": actor} if actor != _LEGACY_AUDIT_ACTOR else {}), "changed_files": sorted(changed_files), "fingerprints": sorted(fingerprints), "policy_set_sha256": policy_digest, @@ -763,7 +790,9 @@ def _boundary_summary(decision: str, violations: list[AgentResultViolatedRule]) def _manifest_adoption_paths( - diff_files: list[DiffFile], config_path: Path | None = None + diff_files: list[DiffFile], + config_path: Path | None = None, + workspace: Path | None = None, ) -> frozenset[str]: """Paths where this diff *introduces* a Shipgate manifest. @@ -787,7 +816,7 @@ def _manifest_adoption_paths( if candidate and ( trust_root_class_for(candidate.replace("\\", "/")) == "manifest" - or is_configured_manifest(config_path, candidate) + or is_configured_manifest(config_path, candidate, workspace=workspace) ) ] if len(records) != 1: @@ -805,6 +834,7 @@ def _with_unclassified_protected_changes( evaluated_paths: set[str], adoption_paths: frozenset[str] = frozenset(), config_path: Path | None = None, + workspace: Path | None = None, ) -> list[AgentResultViolatedRule]: covered = {item.path for item in violations if item.path} additions: list[AgentResultViolatedRule] = [] @@ -819,7 +849,9 @@ def _with_unclassified_protected_changes( # whatever it is called: a repository run with # ``--config new-gate.yml`` otherwise got ``allow`` and no # violations for a diff that rewrote its own gate. - or is_configured_manifest(config_path, normalized) + or is_configured_manifest( + config_path, normalized, workspace=workspace + ) ) ): continue @@ -835,7 +867,9 @@ def _with_unclassified_protected_changes( # gate-governing surface out of the graded route. configured_manifest = trust_root_class_for( normalized - ) is None and is_configured_manifest(config_path, normalized) + ) is None and is_configured_manifest( + config_path, normalized, workspace=workspace + ) additions.append( AgentResultViolatedRule( id=rule.id, diff --git a/src/agents_shipgate/core/agent_controls.py b/src/agents_shipgate/core/agent_controls.py index bbc44b28..0d1b0e00 100644 --- a/src/agents_shipgate/core/agent_controls.py +++ b/src/agents_shipgate/core/agent_controls.py @@ -1,5 +1,8 @@ from __future__ import annotations +import shlex +from pathlib import Path + # Reward-hacking moves that are never acceptable for an autonomous coding # agent. These strings are shared by verify and preflight surfaces. FORBIDDEN_SHORTCUTS: tuple[str, ...] = ( @@ -11,4 +14,77 @@ "evaluate this change.", ) -__all__ = ["FORBIDDEN_SHORTCUTS"] +DEFAULT_VERIFY_COMMAND = "agents-shipgate verify --json" + + +def git_root_for(path: Path) -> Path | None: + """The nearest ancestor containing ``.git``, or ``None``. + + A pure filesystem walk: this runs inside `check` and `preflight`, neither + of which should spawn git just to phrase a command. + """ + + try: + current = path.resolve() + except OSError: + return None + for candidate in (current, *current.parents): + if (candidate / ".git").exists(): + return candidate + return None + + +def verify_command_for( + workspace: Path | None, + config: Path | None, + *, + extra: tuple[str, ...] = (), +) -> str: + """The verify invocation that evaluates the given target. + + Two things make this less obvious than it looks: + + ``verify`` resolves a relative ``--config`` against the repository root, + not against ``--workspace``. Echoing the caller's own relative spelling + therefore silently verified the *root* gate when the request named a + nested one — the command succeeded and reported on the wrong manifest. The + config is emitted relative to the git root when one can be found, and + absolute otherwise, so it always names the file that was actually checked. + + The workspace is emitted as given: it is the anchor the caller already + proved resolvable from where they are standing. + """ + + if workspace is None and config is None: + return DEFAULT_VERIFY_COMMAND + parts = ["agents-shipgate", "verify"] + if workspace is not None: + parts.extend(["--workspace", shlex.quote(str(workspace))]) + if config is not None: + parts.extend(["--config", shlex.quote(_config_for_verify(workspace, config))]) + parts.extend(extra) + parts.append("--json") + return " ".join(parts) + + +def _config_for_verify(workspace: Path | None, config: Path) -> str: + absolute = config if config.is_absolute() else (workspace or Path(".")) / config + try: + absolute = absolute.resolve() + except OSError: + return config.as_posix() + root = git_root_for(absolute.parent) + if root is not None: + try: + return absolute.relative_to(root).as_posix() + except ValueError: + pass + return absolute.as_posix() + + +__all__ = [ + "DEFAULT_VERIFY_COMMAND", + "FORBIDDEN_SHORTCUTS", + "git_root_for", + "verify_command_for", +] diff --git a/src/agents_shipgate/core/codex_boundary.py b/src/agents_shipgate/core/codex_boundary.py index 959e508f..ea05b551 100644 --- a/src/agents_shipgate/core/codex_boundary.py +++ b/src/agents_shipgate/core/codex_boundary.py @@ -12,6 +12,9 @@ import yaml from agents_shipgate.core.agent_control import derive_agent_control +from agents_shipgate.core.agent_controls import ( + verify_command_for as _shared_verify_command_for, +) # The shared unified-diff plumbing moved to # ``agents_shipgate.core.boundary_diff``. These re-exports preserve the @@ -440,6 +443,7 @@ def evaluate_codex_boundary_result( diff_files_override: list[DiffFile] | None = None, resolved_text_cache: dict[str, ResolvedFileText] | None = None, static_read_cache: Any | None = None, + verify_command: str | None = None, ) -> AgentResultV2: """Return the local Codex boundary-result projection for a unified diff. @@ -464,6 +468,7 @@ def evaluate_codex_boundary_result( # Keep this local diff projector aligned with # agents_shipgate.ci.agent_result.build_agent_result; both produce # boundary-result routing fields for different substrates. + resolved_verify_command = verify_command or _VERIFY_COMMAND workspace = workspace.resolve() diff_files = ( diff_files_override @@ -624,17 +629,17 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None: [ _DETECT_COMMAND, "Add the surface to shipgate.yaml tool_sources", - _VERIFY_COMMAND, + resolved_verify_command, ] if is_adopted_repo - else [_VERIFY_PREVIEW_COMMAND, _VERIFY_COMMAND] + else [_VERIFY_PREVIEW_COMMAND, resolved_verify_command] ) elif coverage_gap: first_next_action = _coverage_next_action() summary = _coverage_summary(coverage_surfaces) diagnostics = [*diagnostics, _coverage_diagnostic(coverage_surfaces)] trace = [*_trace_for(policy, decision, violations), _coverage_trace(coverage_surfaces)] - suggested_fixes = [_VERIFY_COMMAND] + suggested_fixes = [resolved_verify_command] else: first_next_action = _next_action_for(decision, violations, repair) summary = _summary_for(decision, violations) @@ -645,6 +650,7 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None: ) verify_required = bool(undeclared_gap or coverage_gap or trigger_verify_required) control = _control_for_result( + verify_command=resolved_verify_command, decision=decision, summary=summary, first_next_action=first_next_action, @@ -782,9 +788,14 @@ def _control_for_result( coverage_gap: bool, trigger_verify_required: bool, violations: Sequence[AgentResultViolatedRule] = (), + verify_command: str | None = None, ): """Translate boundary facts into the one shared operational projector.""" + # Resolved here rather than as a default: the constant is defined further + # down the module. + verify_command = verify_command or _VERIFY_COMMAND + # 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 @@ -798,7 +809,7 @@ def _control_for_result( reason=summary, next_action=CodingAgentCommandAction( kind="verify", - command=_VERIFY_COMMAND, + 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 " @@ -806,7 +817,7 @@ def _control_for_result( ), ), verify_required=True, - allowed_next_commands=[_VERIFY_COMMAND], + allowed_next_commands=[verify_command], ) if decision in {"require_review", "block"} and not repair.safe_to_attempt: @@ -841,14 +852,14 @@ def _control_for_result( ) if verify_required: - command = first_next_action.command or _VERIFY_COMMAND + command = first_next_action.command or verify_command if undeclared_gap: kind = "discover" if command == _DETECT_COMMAND else "configure" elif coverage_gap or trigger_verify_required: kind = "verify" # Advisory warnings may have a non-verification next action; the # outstanding manifest trigger is authoritative here. - command = _VERIFY_COMMAND + command = verify_command else: # pragma: no cover - defensive exhaustiveness. kind = "verify" why = ( @@ -1784,6 +1795,20 @@ def _risk_for(violations: list[AgentResultViolatedRule]) -> AgentResultRiskLevel _DETECT_COMMAND = "shipgate detect --workspace . --json" +def verify_command_for(workspace: object | None, config: object | None) -> str: + """The verify invocation that evaluates *this* check's target. + + The bare default drops both the workspace and the manifest, so a check run + against another checkout or a non-default manifest authorized a command + that verifies the default gate somewhere else. + """ + + return _shared_verify_command_for( + Path(str(workspace)) if workspace is not None else None, + Path(str(config)) if config is not None else None, + ) + + def _undeclared_next_action(*, manifest_present: bool) -> AgentResultNextAction: if manifest_present: return AgentResultNextAction( @@ -2083,8 +2108,9 @@ def _audit_id( # The evaluating agent is part of the audited identity: the result # records it, and two runs that differ only by actor are two different # audit rows. Hardcoding "codex" made them collide, which is precisely - # the attribution problem actor detection exists to fix. A codex run's - # id is unchanged. + # the attribution problem actor detection exists to fix. This one + # always carried the literal "codex", so emitting the real agent leaves + # codex ids byte-identical. "agent": agent, "changed_files": changed_files, "diff": [ diff --git a/src/agents_shipgate/core/preflight.py b/src/agents_shipgate/core/preflight.py index c5aa9642..7c6f401d 100644 --- a/src/agents_shipgate/core/preflight.py +++ b/src/agents_shipgate/core/preflight.py @@ -3,7 +3,6 @@ import hashlib import json import os -import shlex from dataclasses import dataclass from pathlib import Path from typing import Any @@ -13,7 +12,12 @@ from agents_shipgate.checks.verify import TRUST_ROOT_SURFACES from agents_shipgate.config.loader import load_manifest from agents_shipgate.core.agent_control import derive_agent_control -from agents_shipgate.core.agent_controls import FORBIDDEN_SHORTCUTS +from agents_shipgate.core.agent_controls import ( + FORBIDDEN_SHORTCUTS, +) +from agents_shipgate.core.agent_controls import ( + verify_command_for as _shared_verify_command_for, +) from agents_shipgate.core.boundary_diff import parse_unified_diff from agents_shipgate.core.errors import ConfigError, InputParseError from agents_shipgate.core.globbing import glob_match @@ -132,33 +136,6 @@ ) -def _verify_command(workspace: Path | None, config: Path | None) -> str: - """The verify invocation for *this* preflight's target. - - The constant above names the default workspace and manifest, so a preflight - run against a non-default manifest handed the reader a command pointing at - a different gate — or at no gate at all. - - Both values are echoed exactly as the caller spelled them, not resolved: a - resolved absolute path would be correct and useless, embedding one - machine's checkout location in an artifact meant to be read anywhere. - """ - - if workspace is None or config is None: - return _VERIFY_COMMAND - return " ".join( - [ - "agents-shipgate", - "verify", - "--workspace", - shlex.quote(workspace.as_posix()), - "--config", - shlex.quote(config.as_posix()), - "--ci-mode", - "advisory", - "--json", - ] - ) _SIGNAL_KIND_RANK = { "protected_surface_touch": 0, "host_grant_drift": 1, @@ -191,6 +168,14 @@ def _verify_command(workspace: Path | None, config: Path | None) -> str: ) +def _verify_command(workspace: Path | None, config: Path | None) -> str: + """The verify invocation for *this* preflight's target.""" + + if workspace is None or config is None: + return _VERIFY_COMMAND + return _shared_verify_command_for(workspace, config, extra=("--ci-mode", "advisory")) + + @dataclass(frozen=True) class ProtectedSurfaceSpec: kind: str @@ -297,7 +282,7 @@ def build_preflight_result( for node in graph.nodes ] verify_command = _verify_command(workspace, config) - touches = classify_protected_touches(changed, config_path) + touches = classify_protected_touches(changed, config_path, root) touches = _classify_proposal_safe_manifest_touch( workspace=root, config_path=config_path, @@ -423,6 +408,7 @@ def build_trust_root_graph(workspace: Path) -> TrustRootGraphV1: def classify_protected_touches( changed_files: list[str], config_path: Path | None = None, + workspace: Path | None = None, ) -> list[PreflightProtectedSurfaceTouch]: """Classify changed paths against the protected-surface catalog. @@ -439,7 +425,9 @@ def classify_protected_touches( if not path or path in seen: continue seen.add(path) - spec = _classify(path) or _configured_manifest_spec(config_path, path) + spec = _classify(path) or _configured_manifest_spec( + config_path, path, workspace + ) if spec is None: continue touches.append( @@ -885,7 +873,7 @@ def _classify(path: str) -> ProtectedSurfaceSpec | None: def _configured_manifest_spec( - config_path: Path | None, path: str + config_path: Path | None, path: str, workspace: Path | None = None ) -> ProtectedSurfaceSpec | None: """The manifest this run loaded, classified as one whatever it is called. @@ -894,7 +882,7 @@ def _configured_manifest_spec( checkout location. """ - if not is_configured_manifest(config_path, path): + if not is_configured_manifest(config_path, path, workspace=workspace): return None return ProtectedSurfaceSpec( kind="manifest", @@ -991,7 +979,23 @@ def _coerce_plan( def _changed_files_from_diff_text(diff_text: str) -> list[str]: - return sorted({item.path for item in parse_unified_diff(diff_text) if item.path}) + """Every path the diff touches, including the source side of a rename. + + Keeping only the destination hid the case that matters most: renaming the + configured manifest to an unrecognized name left the protected path in + ``old_path`` only, so preflight reported no protected touch, + ``requires_human_review=false``, and an agent-action route for a diff that + moved the gate out from under itself. + """ + + return sorted( + { + path + for item in parse_unified_diff(diff_text) + for path in (item.new_path, item.old_path) + if path + } + ) def _coerce_capability_requests( diff --git a/src/agents_shipgate/core/trust_roots.py b/src/agents_shipgate/core/trust_roots.py index a6014897..dd28d244 100644 --- a/src/agents_shipgate/core/trust_roots.py +++ b/src/agents_shipgate/core/trust_roots.py @@ -11,6 +11,9 @@ from __future__ import annotations +import posixpath +from pathlib import PurePosixPath + from agents_shipgate.core.boundary_registry import BOUNDARY_ADAPTERS from agents_shipgate.core.globbing import glob_match @@ -107,35 +110,68 @@ def trust_root_class_for(path: str) -> str | None: return None -def is_configured_manifest(config_path: object | None, path: str) -> bool: +def _normalized(value: object) -> str: + """A path with separators unified and ``.``/``..`` segments collapsed.""" + + text = str(value).replace("\\", "/").strip() + if not text: + return "" + return PurePosixPath(posixpath.normpath(text)).as_posix() + + +def is_configured_manifest( + config_path: object | None, + path: str, + *, + workspace: object | None = None, +) -> bool: """Whether a changed-file path is the manifest *this run* loaded as its gate. - The table above only knows ``**/shipgate.yaml``. A repository pointed at - ``--config new-gate.yml`` therefore had no manifest trust root at all: the - file defining its gate could be added or rewritten without a finding, and - the release substrate carried nothing for the merge projection to fail + The trust-root table only knows ``**/shipgate.yaml``. A repository pointed + at ``--config new-gate.yml`` therefore had no manifest trust root at all: + the file defining its gate could be added or rewritten without a finding, + and the release substrate carried nothing for the merge projection to fail closed on. Whatever a run loaded as the gate *is* the gate. - The comparison tolerates the two spellings that reach it — the absolute - resolved config path a scan carries, and the workspace-relative changed - path — while refusing a same-basename file in another directory. + Both sides are normalized before comparison, so an equivalent spelling — + ``docs/engineering/../manifest.yaml`` for ``docs/manifest.yaml`` — cannot + slip past by looking textually different from the changed path. + + ``workspace`` makes the comparison exact by resolving both sides against + it. Without it the fallback matches on whole path components, which can + over-match a same-named file in another directory; that direction adds a + trust-root finding rather than dropping one, so the fallback is safe, just + less precise. Callers that know their workspace should pass it. """ if config_path is None: return False - configured = str(config_path).replace("\\", "/").strip() - candidate = path.replace("\\", "/").strip() + configured = _normalized(config_path) + candidate = _normalized(path) if not configured or not candidate: return False - return ( - candidate == configured - or configured.endswith(f"/{candidate}") - or candidate.endswith(f"/{configured}") - ) + if candidate == configured: + return True + if workspace is not None: + root = _normalized(workspace) + if root: + absolute_config = ( + configured + if posixpath.isabs(configured) + else _normalized(posixpath.join(root, configured)) + ) + absolute_candidate = ( + candidate + if posixpath.isabs(candidate) + else _normalized(posixpath.join(root, candidate)) + ) + return absolute_config == absolute_candidate + return configured.endswith(f"/{candidate}") or candidate.endswith(f"/{configured}") __all__ = [ "PROTECTED_FILE_EDITS", + "is_configured_manifest", "TRUST_ROOT_SURFACES", "trust_root_class_for", ] diff --git a/tests/golden/codex_boundary_result/agents_requirement_removed.json b/tests/golden/codex_boundary_result/agents_requirement_removed.json index 1b9dbfd2..e0253f78 100644 --- a/tests/golden/codex_boundary_result/agents_requirement_removed.json +++ b/tests/golden/codex_boundary_result/agents_requirement_removed.json @@ -10,7 +10,7 @@ }, "decision": "require_review", "risk_level": "medium", - "audit_id": "agent_boundary_fd1d4bc908e27ffac7ef9e4b", + "audit_id": "agent_boundary_c22c9f245720d8f6f31d67cd", "policy_version": "1", "summary": "1 coding-agent boundary change(s) require human review.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/docs_only.json b/tests/golden/codex_boundary_result/docs_only.json index d4f0136e..486db986 100644 --- a/tests/golden/codex_boundary_result/docs_only.json +++ b/tests/golden/codex_boundary_result/docs_only.json @@ -10,7 +10,7 @@ }, "decision": "allow", "risk_level": "none", - "audit_id": "agent_boundary_6992d494be4b9b5ad459b3e3", + "audit_id": "agent_boundary_bcae9dc247f5dae731a41e8c", "policy_version": "1", "summary": "No recognized coding-agent boundary change requires action.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/github_action_removed.json b/tests/golden/codex_boundary_result/github_action_removed.json index a364f1d5..4f4f6d28 100644 --- a/tests/golden/codex_boundary_result/github_action_removed.json +++ b/tests/golden/codex_boundary_result/github_action_removed.json @@ -10,7 +10,7 @@ }, "decision": "block", "risk_level": "critical", - "audit_id": "agent_boundary_4e81dd5f611f6d3d2e5149bd", + "audit_id": "agent_boundary_488574aef143b8ccc5744839", "policy_version": "1", "summary": "1 coding-agent boundary change(s) block local continuation.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/malformed_toml.json b/tests/golden/codex_boundary_result/malformed_toml.json index 583fa1cb..0792318a 100644 --- a/tests/golden/codex_boundary_result/malformed_toml.json +++ b/tests/golden/codex_boundary_result/malformed_toml.json @@ -10,7 +10,7 @@ }, "decision": "require_review", "risk_level": "medium", - "audit_id": "agent_boundary_ab70411380f721e1dfcda658", + "audit_id": "agent_boundary_04887e3b07ba7c7961f61c2a", "policy_version": "1", "summary": "1 coding-agent boundary change(s) require human review.", "changed_files": [ 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 ee03359f..0a019b14 100644 --- a/tests/golden/codex_boundary_result/mcp_auto_approve_write.json +++ b/tests/golden/codex_boundary_result/mcp_auto_approve_write.json @@ -10,7 +10,7 @@ }, "decision": "block", "risk_level": "critical", - "audit_id": "agent_boundary_8ab0716dccde23cdcf3bbc69", + "audit_id": "agent_boundary_78c4542efc6c9ede23d89129", "policy_version": "1", "summary": "1 coding-agent boundary change(s) block local continuation.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/network_wildcard.json b/tests/golden/codex_boundary_result/network_wildcard.json index 0d5ff06f..fe60982d 100644 --- a/tests/golden/codex_boundary_result/network_wildcard.json +++ b/tests/golden/codex_boundary_result/network_wildcard.json @@ -10,7 +10,7 @@ }, "decision": "require_review", "risk_level": "high", - "audit_id": "agent_boundary_50543b9fbfaca23e2753012e", + "audit_id": "agent_boundary_1697437c484c034da283fa0e", "policy_version": "1", "summary": "1 coding-agent boundary change(s) require human review.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/python_refactor.json b/tests/golden/codex_boundary_result/python_refactor.json index b5030429..fb39415b 100644 --- a/tests/golden/codex_boundary_result/python_refactor.json +++ b/tests/golden/codex_boundary_result/python_refactor.json @@ -10,7 +10,7 @@ }, "decision": "allow", "risk_level": "none", - "audit_id": "agent_boundary_1ebb48e94c2e807c806d1f20", + "audit_id": "agent_boundary_ca11f1833eaccd04b795f492", "policy_version": "1", "summary": "No recognized coding-agent boundary change requires action.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/unknown_permission_key.json b/tests/golden/codex_boundary_result/unknown_permission_key.json index ba3cb783..ba71d3ef 100644 --- a/tests/golden/codex_boundary_result/unknown_permission_key.json +++ b/tests/golden/codex_boundary_result/unknown_permission_key.json @@ -10,7 +10,7 @@ }, "decision": "require_review", "risk_level": "medium", - "audit_id": "agent_boundary_ce82234cfd7e3f18108d7bcc", + "audit_id": "agent_boundary_7908865cfe6b4fa73441f451", "policy_version": "1", "summary": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", "changed_files": [ @@ -25,12 +25,12 @@ "next_action": { "actor": "coding_agent", "kind": "verify", - "command": "agents-shipgate verify --json", + "command": "agents-shipgate verify --workspace --config /shipgate.yaml --json", "expects": null, "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": [ - "agents-shipgate verify --json" + "agents-shipgate verify --workspace --config /shipgate.yaml --json" ], "human_review": { "required": false, diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index 9dc570ff..10a1a1e0 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -303,7 +303,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=710, + line=735, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " diff --git a/tests/test_agent_boundary.py b/tests/test_agent_boundary.py index b53a4f58..35ab1b00 100644 --- a/tests/test_agent_boundary.py +++ b/tests/test_agent_boundary.py @@ -831,3 +831,78 @@ def test_the_default_manifest_keeps_its_classification(tmp_path: Path) -> None: rows = [item for item in result.violated_rules if item.path == "shipgate.yaml"] assert rows assert result.control.state == "human_review_required" + + +def test_check_authorizes_a_verify_command_for_its_own_target(tmp_path: Path) -> None: + """A bare `agents-shipgate verify --json` verifies the wrong gate. + + An ordinary force-run checked with a non-default manifest authorized a + command that drops both workspace and config. + """ + + (tmp_path / "new-gate.yml").write_text(_MANIFEST, encoding="utf-8") + result = build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=_change_diff("README.md", "hello\n", "hello\nworld\n"), + config=Path("new-gate.yml"), + policy=None, + input_mode="worktree", + ) + + for command in [ + *result.control.allowed_next_commands, + *( [result.control.next_action.command] if result.control.next_action.command else [] ), + ]: + if command.startswith("agents-shipgate verify"): + assert str(tmp_path) in command + assert "new-gate.yml" in command + + +def test_existing_codex_audit_ids_do_not_rotate(tmp_path: Path) -> None: + """Actor entered the digest; ids issued before it existed must not move. + + Every one of those was a codex run, so rotating them would break stored + references to say nothing new. + """ + + (tmp_path / "shipgate.yaml").write_text(_MANIFEST, encoding="utf-8") + diff = _change_diff("shipgate.yaml", _MANIFEST, _MANIFEST + "ci:\n mode: advisory\n") + ids = { + actor: build_agent_boundary_result( + agent=actor, + workspace=tmp_path, + diff_text=diff, + config=Path("shipgate.yaml"), + policy=None, + input_mode="worktree", + ).audit_id + for actor in ("codex", "claude-code", "cursor") + } + + assert len(set(ids.values())) == 3, ids + + # The codex digest must equal the pre-actor payload's digest, recomputed + # here from the shape that shipped before actor detection existed. + import hashlib + + from agents_shipgate.core.agent_boundary import _agent_boundary_audit_id + from agents_shipgate.schemas.agent_boundary import ( + AGENT_BOUNDARY_RESULT_SCHEMA_VERSION, + ) + + legacy_payload = { + "schema": AGENT_BOUNDARY_RESULT_SCHEMA_VERSION, + "changed_files": ["x"], + "fingerprints": ["fp"], + "policy_set_sha256": "d", + } + legacy_digest = hashlib.sha256( + json.dumps(legacy_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest()[:24] + assert _agent_boundary_audit_id( + actor="codex", changed_files=["x"], fingerprints=["fp"], policy_digest="d" + ) == f"agent_boundary_{legacy_digest}" + assert _agent_boundary_audit_id( + actor="claude-code", changed_files=["x"], fingerprints=["fp"], policy_digest="d" + ) != f"agent_boundary_{legacy_digest}" diff --git a/tests/test_codex_boundary_check.py b/tests/test_codex_boundary_check.py index f210d0f4..336b48d7 100644 --- a/tests/test_codex_boundary_check.py +++ b/tests/test_codex_boundary_check.py @@ -61,6 +61,19 @@ } +def _normalize_workspace(payload: object, workspace: Path) -> object: + """Replace this run's workspace path with a stable placeholder. + + Both spellings: macOS resolves ``/var/folders/...`` to ``/private/var/...``, + and the emitted command carries the resolved form. + """ + + raw = json.dumps(payload) + for spelling in (str(workspace.resolve()), str(workspace)): + raw = raw.replace(spelling, "") + return json.loads(raw) + + 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, expected_state) in CASES.items(): @@ -81,7 +94,12 @@ def test_codex_check_boundary_json_golden_outputs(tmp_path: Path) -> None: assert result.stderr == "" payload = json.loads(result.output) validator.validate(payload) - assert payload == json.loads((GOLDEN / f"{case}.json").read_text(encoding="utf-8")) + # The authorized verify command names this invocation's own workspace + # and manifest, so the golden pins its shape rather than one machine's + # temporary directory. + assert _normalize_workspace(payload, tmp_path) == json.loads( + (GOLDEN / f"{case}.json").read_text(encoding="utf-8") + ) assert payload["decision"] == decision assert [item["id"] for item in payload["violated_rules"]] == rule_ids control = _control(payload) diff --git a/tests/test_first_adoption.py b/tests/test_first_adoption.py index 5f9c70f0..5ee7e081 100644 --- a/tests/test_first_adoption.py +++ b/tests/test_first_adoption.py @@ -594,3 +594,53 @@ def test_a_base_that_keeps_another_manifest_is_not_an_adoption(tmp_path): ) is False ) + + +def test_a_quoted_key_manifest_on_the_base_blocks_the_adoption_claim(tmp_path): + """A text probe for `^project:` misses a manifest that loads fine. + + Quoted keys, flow style, and indentation all parse to the same document, + so the retained-manifest check has to parse rather than grep. + """ + + import yaml + + repo = _repo_adopting_shipgate(tmp_path) + sample = repo / "samples" / "support_refund_agent" + document = yaml.safe_load((sample / "shipgate.yaml").read_text("utf-8")) + (sample / "shipgate.yaml").unlink() + (sample / "old-gate.yml").write_text( + yaml.safe_dump(document, default_style='"'), encoding="utf-8" + ) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "base keeps a quoted-key manifest") + (sample / "new-gate.yml").write_text( + (sample / "old-gate.yml").read_text("utf-8"), encoding="utf-8" + ) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "add a second manifest") + + assert ( + _manifest_introduced( + git_root=repo, + config_relative=Path("samples/support_refund_agent/new-gate.yml"), + base_status="missing_manifest", + base="HEAD~1", + head="HEAD", + changed_files=["samples/support_refund_agent/new-gate.yml"], + ) + is False + ) + + +def test_equivalent_config_spellings_resolve_to_the_same_manifest(): + """Raw suffix comparison was bypassable by an equivalent path spelling.""" + + from agents_shipgate.core.trust_roots import is_configured_manifest + + assert is_configured_manifest("docs/engineering/../manifest.yaml", "docs/manifest.yaml") + assert is_configured_manifest("/repo/sub/gate.yml", "sub/gate.yml", workspace="/repo") + # With a workspace the comparison is exact, so a same-named file elsewhere + # is not the gate. + assert not is_configured_manifest("/repo/x/sub/gate.yml", "sub/gate.yml", workspace="/repo") + assert not is_configured_manifest("/repo/new-gate.yml", "README.md") diff --git a/tests/test_preflight.py b/tests/test_preflight.py index da1a9eca..5c364ae6 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -33,6 +33,18 @@ runner = CliRunner() +def _assert_verify_command(command: str, workspace: Path, config: str) -> None: + """The emitted verify command must target the preflight's own request.""" + + assert command.startswith("agents-shipgate verify ") + assert f"--workspace {shlex.quote(str(workspace))}" in command + assert command.endswith("--ci-mode advisory --json") + rendered = command.split("--config ", 1)[1].split(" ", 1)[0] + assert Path(rendered).name == Path(config).name + if not Path(rendered).is_absolute(): + assert rendered == config + + def _workspace(tmp_path: Path) -> Path: root = tmp_path / "repo" root.mkdir() @@ -136,13 +148,12 @@ def test_preflight_allows_exact_append_only_builtin_source_proposal( assert result.control.state == "agent_action_required" assert result.control.must_stop is False assert result.control.next_action.kind == "verify" - # The command echoes the request as it was made, so a run against another + # The command names the request's own target, so a run against another # checkout or a non-default manifest does not hand the reader a command - # pointing at a different gate. - assert result.control.allowed_next_commands == [ - f"agents-shipgate verify --workspace {shlex.quote(str(root))} " - "--config shipgate.yaml --ci-mode advisory --json" - ] + # pointing at a different gate. The config is rendered the way `verify` + # resolves it — relative to the repository root, absolute when there is no + # repository — so its exact spelling depends on the fixture. + _assert_verify_command(result.control.allowed_next_commands[0], root, "shipgate.yaml") signal = next(item for item in result.signals if item.kind == "protected_surface_touch") assert signal.actor == "coding_agent" assert "not approved" in signal.reason @@ -682,10 +693,7 @@ def test_cli_preflight_plan_stdin_routes_clean_docs_to_verify(tmp_path: Path) -> assert payload["preflight_schema_version"] == "0.3" assert payload["requires_human_review"] is False assert payload["first_next_action"]["kind"] == "verify" - assert payload["allowed_next_commands"] == [ - f"agents-shipgate verify --workspace {shlex.quote(str(root))} " - "--config shipgate.yaml --ci-mode advisory --json" - ] + _assert_verify_command(payload["allowed_next_commands"][0], root, "shipgate.yaml") assert payload["control"]["state"] == "agent_action_required" assert payload["control"]["completion_allowed"] is False assert payload["control"]["must_stop"] is False From d19538faa51620ebe0815c79549ef4ea7351d2f3 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Wed, 29 Jul 2026 14:15:32 -0700 Subject: [PATCH 09/12] Fix adoption control and baseline safety edge cases --- docs/mcp-governance.md | 4 +- src/agents_shipgate/cli/check.py | 66 +++-- src/agents_shipgate/cli/host_audit.py | 263 +++++++++++++++++- src/agents_shipgate/cli/verify/git.py | 30 +- .../cli/verify/orchestrator.py | 1 + src/agents_shipgate/core/agent_boundary.py | 43 ++- src/agents_shipgate/core/agent_controls.py | 16 +- src/agents_shipgate/core/host_grants.py | 15 +- src/agents_shipgate/core/preflight.py | 22 +- src/agents_shipgate/core/trust_roots.py | 55 +++- src/agents_shipgate/schemas/verification.py | 5 + tests/test_adapter_static_only.py | 2 +- tests/test_agent_controls.py | 38 +++ tests/test_agent_mode.py | 212 +++++++++++++- tests/test_agent_protocol.py | 87 +++++- tests/test_codex_boundary_check.py | 13 +- tests/test_first_adoption.py | 39 +++ tests/test_host_audit.py | 226 ++++++++++++++- tests/test_org_governance.py | 2 + tests/test_preflight.py | 64 ++++- tests/test_trust_root.py | 30 ++ tests/test_verify.py | 59 +++- 22 files changed, 1182 insertions(+), 110 deletions(-) create mode 100644 tests/test_agent_controls.py diff --git a/docs/mcp-governance.md b/docs/mcp-governance.md index 85692580..73a79594 100644 --- a/docs/mcp-governance.md +++ b/docs/mcp-governance.md @@ -144,7 +144,9 @@ policy change. The stored `inventory_sha256` is verified on every `--drift` load — a hand-edited or corrupted baseline fails closed instead of silently reporting no drift. A v0.1 baseline, scope mismatch, or incomplete comparison is reported as `comparison_status="incomparable"`; advisory mode -exits 0 and `--fail-on-drift` exits 20 with an exact re-export command. +exits 0 and `--fail-on-drift` exits 20. An incomparable result exposes no +runnable recovery command: review the existing baseline and move, remove, or +repair it before explicitly accepting the current host grants. MCP server and hook entries carry a `config_sha256` over their full configuration. Inside `env`/`headers`, only values under diff --git a/src/agents_shipgate/cli/check.py b/src/agents_shipgate/cli/check.py index ec2616b6..bdfc4b1f 100644 --- a/src/agents_shipgate/cli/check.py +++ b/src/agents_shipgate/cli/check.py @@ -42,7 +42,6 @@ def _corrected_request( base: str | None, head: str | None, format_: str, - allow_ref_fallback: bool = False, ) -> str | None: """The same check request with only the invalid field corrected. @@ -56,19 +55,23 @@ def _corrected_request( semicolon in ``--diff`` would otherwise become a command boundary in it. A half-specified ref range has two repairs — supply the missing ref, or - drop both and check the working tree. ``allow_ref_fallback`` picks the - second, which is the documented recovery when the diff input itself could - not be resolved. Everywhere else the choice is not ours to make: silently - dropping a range the caller asked for answers a different question, so the - caller gets ``None`` and a review action. + drop both and check the working tree. The choice is not ours to make: + silently dropping a range the caller asked for answers a different + question, so the caller gets ``None`` and a review action. ``None`` also when the diff was read from stdin and cannot be replayed. """ - if diff == "-": + has_diff = diff is not None + has_base = base is not None + has_head = head is not None + if diff in {"", "-"}: return None - one_sided_range = not diff and bool(base) != bool(head) - if one_sided_range and not allow_ref_fallback: + if has_diff and (has_base or has_head): + return None + if has_base != has_head: + return None + if has_base and (not base or not head): return None parts = ["agents-shipgate", "check"] if agent in {"codex", "claude-code", "cursor"}: @@ -77,12 +80,12 @@ def _corrected_request( parts.extend(["--config", shlex.quote(str(config))]) if policy is not None: parts.extend(["--policy", shlex.quote(str(policy))]) - if diff: + if has_diff: + assert diff is not None parts.extend(["--diff", shlex.quote(diff)]) - elif base and head: + elif has_base and has_head: + assert base is not None and head is not None parts.extend(["--base", shlex.quote(base), "--head", shlex.quote(head)]) - # A one-sided range under the fallback emits neither ref: that is the - # "omit both for local uncommitted changes" recovery the instructions name. valid_format = format_ if format_ == "codex-boundary-json" else "agent-boundary-json" parts.extend(["--format", valid_format]) return " ".join(parts) @@ -174,6 +177,26 @@ def corrected(*, valid_agent: str | None) -> str | None: format_=format_, ) + if diff is not None and (base is not None or head is not None): + raise _flag_error( + "--diff cannot be combined with --base or --head.", + command=None, + expects="Choose one complete diff input: --diff, both refs, or the worktree.", + ) + if diff == "": + raise _flag_error( + "--diff must name a file or '-' for stdin; it cannot be empty.", + command=None, + expects="A non-empty diff path, stdin, a complete ref range, or the worktree.", + ) + if (base is None) != (head is None) or ( + base is not None and (not base or not head) + ): + raise _flag_error( + "--base and --head must be provided together and cannot be empty.", + command=None, + expects="Both non-empty refs, or neither for local worktree changes.", + ) if agent not in {"codex", "claude-code", "cursor"}: raise _flag_error( "--agent must be one of: codex, claude-code, cursor.", @@ -265,7 +288,6 @@ def _diff_input_error_result( base=base, head=head, format_=format_, - allow_ref_fallback=True, ) summary = "Agents Shipgate could not resolve the diff input for local agent control." return CodexBoundaryResultV2( @@ -345,22 +367,6 @@ def _diff_input_error_result( ) -def _rerun_command( - *, - agent: str, - diff: str | None, - base: str | None, - head: str | None, -) -> str: - parts = ["shipgate", "check", "--agent", agent, "--workspace", "."] - if diff: - parts.extend(["--diff", diff]) - elif base and head: - parts.extend(["--base", base, "--head", head]) - parts.extend(["--format", "agent-boundary-json"]) - return " ".join(parts) - - def _neutral_diff_input_error( legacy: CodexBoundaryResultV2, *, diff --git a/src/agents_shipgate/cli/host_audit.py b/src/agents_shipgate/cli/host_audit.py index 6219e271..ae5df7bc 100644 --- a/src/agents_shipgate/cli/host_audit.py +++ b/src/agents_shipgate/cli/host_audit.py @@ -3,7 +3,10 @@ from __future__ import annotations import json +import os import shlex +import stat +import tempfile from pathlib import Path import typer @@ -13,6 +16,7 @@ DEFAULT_BASELINE_FILE, HOST_GRANTS_INVENTORY_SCHEMA_VERSION, HOST_GRANTS_SCHEMA_VERSION, + INCOMPARABLE_BASELINE_REVIEW, build_host_drift_payload, build_host_grants_baseline, diff_host_grants, @@ -28,6 +32,8 @@ ) from agents_shipgate.schemas.diagnostics import NextAction +_BaselineFileState = tuple[os.stat_result, str] + def _io_error(message: str, *, next_action: str) -> typer.Exit: """Report a filesystem failure on both channels. @@ -169,6 +175,11 @@ def audit( ) if save_baseline: + write_target = _baseline_write_target( + workspace=workspace, + baseline_file=baseline_file, + ) + existing = _refuse_invalid_baseline_overwrite(write_target) try: payload = build_host_grants_baseline(inventory) except ValueError as exc: @@ -181,14 +192,15 @@ def audit( ) from exc text = json.dumps(payload, indent=2, sort_keys=True) + "\n" try: - if resolved_baseline.is_file() and resolved_baseline.read_text( - encoding="utf-8" - ) == text: + if existing is not None and existing[1] == text: status = "unchanged" else: - status = "updated" if resolved_baseline.is_file() else "created" - resolved_baseline.parent.mkdir(parents=True, exist_ok=True) - resolved_baseline.write_text(text, encoding="utf-8") + _atomic_write_baseline( + write_target, + text, + expected=existing, + ) + status = "updated" if existing is not None else "created" except OSError as exc: # --baseline-file naming a directory raised IsADirectoryError # straight through typer: a traceback and exit 1. @@ -245,12 +257,29 @@ def audit( # and destroying the evidence a human needed to look at. Only a # genuinely absent baseline can be recorded without losing # anything, and that command carries the scope it was asked for. - missing = isinstance(exc.__cause__, FileNotFoundError) + missing = isinstance( + exc.__cause__, FileNotFoundError + ) and not resolved_baseline.is_symlink() + if missing: + _baseline_write_target( + workspace=workspace, + baseline_file=baseline_file, + ) raise _config_error( - str(exc), + ( + _missing_baseline_error_message( + exc, + baseline_file=baseline_file, + ) + if missing + else _existing_baseline_error_message( + exc, + baseline_file=baseline_file, + ) + ), next_action=( - "Record a baseline with `agents-shipgate audit --host " - "--save-baseline`." + "Record the first baseline for this exact workspace, " + "scope, and target." if missing else "Inspect the existing baseline file and repair or " "replace it deliberately; do not overwrite it with the " @@ -290,6 +319,220 @@ def audit( typer.echo(render_host_audit_markdown(inventory), nl=False) +def _baseline_write_target(*, workspace: Path, baseline_file: Path) -> Path: + workspace_root = workspace.resolve() + raw_target = ( + baseline_file + if baseline_file.is_absolute() + else workspace_root / baseline_file + ) + target = Path(os.path.abspath(raw_target)) + if ( + not baseline_file.is_absolute() + and target != workspace_root + and workspace_root not in target.parents + ): + raise _unsafe_baseline_path( + target, + "a relative --baseline-file must stay inside --workspace", + ) + _reject_baseline_symlinks(target) + return target + + +def _reject_baseline_symlinks(target: Path) -> None: + try: + redirected = target.resolve() != target + except (OSError, RuntimeError) as exc: + raise _unsafe_baseline_path(target, f"could not resolve the path: {exc}") from exc + if redirected: + raise _unsafe_baseline_path(target, "the path contains a symbolic link") + + +def _unsafe_baseline_path(path: Path, reason: str) -> typer.Exit: + return _config_error( + f"Refusing to write host-grants baseline {path}: {reason}.", + next_action=( + "Choose a regular, non-linked baseline path. Relative baseline " + "paths must remain inside the requested workspace." + ), + command=None, + ) + + +def _file_identity(value: os.stat_result) -> tuple[int, int, int, int, int, int]: + return ( + value.st_dev, value.st_ino, value.st_size, + value.st_mtime_ns, value.st_nlink, value.st_mode, + ) + + +def _refuse_invalid_baseline_overwrite( + baseline_file: Path, +) -> _BaselineFileState | None: + try: + before = baseline_file.lstat() + except FileNotFoundError: + return None + except OSError as exc: + raise _unsafe_baseline_path( + baseline_file, + f"could not inspect the existing target: {exc}", + ) from exc + if stat.S_ISDIR(before.st_mode): + return None + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + raise _unsafe_baseline_path( + baseline_file, + "the existing target is not a single-link regular file", + ) + try: + baseline = load_host_grants_baseline(baseline_file) + except ValueError as exc: + raise _config_error( + _existing_baseline_error_message(exc, baseline_file=baseline_file), + next_action=INCOMPARABLE_BASELINE_REVIEW, + command=None, + ) from exc + if ( + baseline.get("host_grants_schema_version") != HOST_GRANTS_SCHEMA_VERSION + or baseline.get("_load_error") + ): + reason = str(baseline.get("_load_error") or "unsupported_baseline_schema") + raise _config_error( + f"Refusing to overwrite existing host-grants baseline " + f"{baseline_file}: {reason}. The file was left unchanged.", + next_action=INCOMPARABLE_BASELINE_REVIEW, + command=None, + ) + try: + text = baseline_file.read_text(encoding="utf-8") + after = baseline_file.lstat() + except OSError as exc: + raise _unsafe_baseline_path( + baseline_file, + f"the existing target changed or became unreadable: {exc}", + ) from exc + if _file_identity(before) != _file_identity(after): + raise _unsafe_baseline_path( + baseline_file, + "the existing target changed while it was being validated", + ) + return after, text + + +def _atomic_write_baseline( + target: Path, + text: str, + *, + expected: _BaselineFileState | None, +) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + _reject_baseline_symlinks(target) + expected_stat = expected[0] if expected is not None else None + + def ensure_unchanged() -> None: + try: + current = target.lstat() + except FileNotFoundError: + current = None + if expected_stat is None: + if current is None: + return + if stat.S_ISDIR(current.st_mode): + raise IsADirectoryError(f"{target} is a directory") + raise _unsafe_baseline_path( + target, + "the target appeared while the baseline was being prepared", + ) + if current is not None and stat.S_ISDIR(current.st_mode): + raise IsADirectoryError(f"{target} is a directory") + if current is None or _file_identity(current) != _file_identity(expected_stat): + raise _unsafe_baseline_path( + target, + "the target changed while the baseline was being prepared", + ) + + ensure_unchanged() + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + delete=False, + prefix=f".{target.name}.", + suffix=".tmp", + dir=target.parent, + ) as handle: + temp_path = Path(handle.name) + temp_path.chmod( + stat.S_IMODE(expected_stat.st_mode) + if expected_stat is not None + else 0o644 + ) + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + _reject_baseline_symlinks(target) + ensure_unchanged() + os.replace(temp_path, target) + temp_path = None + _fsync_directory(target.parent) + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +def _fsync_directory(directory: Path) -> None: + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + try: + descriptor = os.open(directory, flags) + except OSError: + return + try: + os.fsync(descriptor) + except OSError: + pass + finally: + os.close(descriptor) + + +def _baseline_error_detail(exc: ValueError) -> str: + detail = str(exc) + for marker in ( + " After human review, re-record it:", + " Re-record it:", + " Record one first:", + ): + if marker in detail: + detail = detail.split(marker, 1)[0] + break + return detail.rstrip().rstrip(".") + + +def _missing_baseline_error_message( + exc: ValueError, + *, + baseline_file: Path, +) -> str: + """Describe absence without repeating the loader's unscoped command.""" + + return ( + f"{_baseline_error_detail(exc)}. No baseline was written to " + f"{baseline_file}." + ) + + +def _existing_baseline_error_message(exc: ValueError, *, baseline_file: Path) -> str: + """Preserve the loader diagnosis without its destructive rerun advice.""" + + return ( + f"Refusing to overwrite existing host-grants baseline " + f"{baseline_file}: {_baseline_error_detail(exc)}. " + "The file was left unchanged." + ) + + def _write_json_out(out: Path | None, payload: dict) -> None: if out is None: return diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 226c4481..970fa838 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -441,15 +441,37 @@ def carries_manifest_like_yaml(workspace: Path, ref: str) -> bool | None: if len(candidates) > _MAX_MANIFEST_CANDIDATES: return None for candidate in candidates: - blob = _run_git(workspace, ["show", f"{ref}:{candidate}"], check=False) + object_name = f"{ref}:{candidate}" + size = _run_git( + workspace, + ["cat-file", "-s", object_name], + check=False, + ) + if size.returncode != 0: + return None + try: + byte_count = int(size.stdout.strip()) + except (TypeError, ValueError): + return None + if byte_count > _MAX_MANIFEST_BYTES: + return None + blob = _run_git( + workspace, + ["show", object_name], + check=False, + text=False, + ) if blob.returncode != 0: return None - text = blob.stdout - if len(text) > _MAX_MANIFEST_BYTES: + if not isinstance(blob.stdout, bytes) or len(blob.stdout) != byte_count: + return None + try: + text = blob.stdout.decode("utf-8") + except UnicodeDecodeError: return None try: document = yaml.safe_load(text) - except yaml.YAMLError: + except (RecursionError, yaml.YAMLError): # Unparseable YAML cannot be ruled out as a manifest. return None if isinstance(document, dict) and _MANIFEST_REQUIRED_KEYS <= { diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index 9043ffb9..d450bb2f 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -534,6 +534,7 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: diff_text=diff_text, diff_text_available=bool(diff_text), trigger_result=trigger, + configured_manifest_path=config_relative.as_posix(), manifest_introduced=manifest_introduced, ), capability_lock_callback=capture_capability_lock, diff --git a/src/agents_shipgate/core/agent_boundary.py b/src/agents_shipgate/core/agent_boundary.py index f89c178c..bcdb627c 100644 --- a/src/agents_shipgate/core/agent_boundary.py +++ b/src/agents_shipgate/core/agent_boundary.py @@ -458,19 +458,58 @@ def assessment_for_scan_context(context) -> AgentBoundaryAssessment: diff_text = verification.diff_text or "\n".join( f"diff --git a/{path} b/{path}" for path in verification.changed_files ) + configured_manifest = ( + Path(verification.configured_manifest_path) + if verification.configured_manifest_path + else Path(context.config_path) + ) context.agent_boundary = evaluate_agent_boundary( - workspace=Path(context.config_path).resolve().parent, + workspace=_scan_workspace( + config_path=Path(context.config_path), + configured_manifest=configured_manifest, + ), diff_text=diff_text, trigger=verification.trigger_result, input_mode="provided_diff", # Without this a custom-named manifest produced the protected-surface # boundary finding under local `check` but not under full `verify`, so # the two public surfaces published different evidence for one diff. - config_path=Path(context.config_path), + config_path=configured_manifest, ) return context.agent_boundary +def _scan_workspace(*, config_path: Path, configured_manifest: Path) -> Path: + """Recover the scan's repository root from its stable manifest identity. + + Verify scans a committed head from a temporary archive. ``config_path`` is + therefore physical (``/head/services/support/new-gate.yml``), while + changed paths and ``configured_manifest`` are repository-relative + (``services/support/new-gate.yml``). Removing those stable path components + recovers the archive root, so boundary content resolution and configured- + manifest comparison use the same coordinate system. + + Direct scan callers predating the additive identity field retain the + historical manifest-parent fallback. + """ + + resolved_config = config_path.resolve() + if configured_manifest.is_absolute(): + return resolved_config.parent + components = configured_manifest.parts + if not components or any(part == ".." for part in components): + return resolved_config.parent + root = resolved_config + for _part in components: + root = root.parent + try: + if (root / configured_manifest).resolve() == resolved_config: + return root + except OSError: + pass + return resolved_config.parent + + def _project_legacy( *, legacy: CodexBoundaryResultV2, diff --git a/src/agents_shipgate/core/agent_controls.py b/src/agents_shipgate/core/agent_controls.py index 0d1b0e00..da3a8e2a 100644 --- a/src/agents_shipgate/core/agent_controls.py +++ b/src/agents_shipgate/core/agent_controls.py @@ -48,8 +48,10 @@ def verify_command_for( not against ``--workspace``. Echoing the caller's own relative spelling therefore silently verified the *root* gate when the request named a nested one — the command succeeded and reported on the wrong manifest. The - config is emitted relative to the git root when one can be found, and - absolute otherwise, so it always names the file that was actually checked. + config is emitted relative to the git root only when the workspace and + config belong to that same repository. Otherwise it stays absolute so + ``verify`` can reject a config outside the requested workspace instead of + silently selecting a same-named file there. The workspace is emitted as given: it is the anchor the caller already proved resolvable from where they are standing. @@ -68,15 +70,17 @@ def verify_command_for( def _config_for_verify(workspace: Path | None, config: Path) -> str: - absolute = config if config.is_absolute() else (workspace or Path(".")) / config + requested_workspace = workspace or Path(".") + absolute = config if config.is_absolute() else requested_workspace / config try: absolute = absolute.resolve() except OSError: return config.as_posix() - root = git_root_for(absolute.parent) - if root is not None: + config_root = git_root_for(absolute.parent) + workspace_root = git_root_for(requested_workspace) + if config_root is not None and config_root == workspace_root: try: - return absolute.relative_to(root).as_posix() + return absolute.relative_to(config_root).as_posix() except ValueError: pass return absolute.as_posix() diff --git a/src/agents_shipgate/core/host_grants.py b/src/agents_shipgate/core/host_grants.py index 07025d3c..3e662127 100644 --- a/src/agents_shipgate/core/host_grants.py +++ b/src/agents_shipgate/core/host_grants.py @@ -44,6 +44,10 @@ HOST_GRANTS_SCHEMA_VERSION = HOST_GRANTS_BASELINE_SCHEMA_VERSION DEFAULT_BASELINE_FILE = Path(".agents-shipgate/host-grants.json") +INCOMPARABLE_BASELINE_REVIEW = ( + "Review the existing baseline and move, remove, or repair it before " + "explicitly accepting the current host grants." +) HostScope = Literal["repository", "local_static"] MAX_HOST_CONFIG_BYTES = 1024 * 1024 @@ -1208,7 +1212,6 @@ def _incomparable_payload( *, inventory: dict[str, Any], baseline_file: str, reasons: list[str] ) -> dict[str, Any]: scope = inventory.get("scope", "repository") - command = f"shipgate audit --host --scope {scope} --save-baseline" payload = { "host_grants_schema_version": HOST_GRANTS_DRIFT_SCHEMA_VERSION, "baseline_file": baseline_file, @@ -1223,7 +1226,12 @@ def _incomparable_payload( "expansion_signals": [], "issues": inventory.get("issues", []), "incomparable_reasons": sorted(reasons), - "next_action": command, + # An incomparable result was built from an existing baseline whose + # meaning cannot be trusted. Advertising --save-baseline here would + # replace that evidence with the current grants and silently + # acknowledge them. Missing baselines are handled before this builder + # and may still receive an explicit save command. + "next_action": None, } return HostGrantsDriftV2.model_validate(payload).model_dump(mode="json") @@ -1327,7 +1335,7 @@ def render_host_drift_markdown(payload: dict[str, Any]) -> str: lines.append("") for reason in payload["incomparable_reasons"]: lines.append(f"- `{reason}`") - lines.extend(["", f"Next: `{payload['next_action']}`"]) + lines.extend(["", f"Next: {INCOMPARABLE_BASELINE_REVIEW}"]) return "\n".join(lines) + "\n" if not payload["has_drift"]: lines.append("No drift — current host grants match the acknowledged baseline.") @@ -1348,6 +1356,7 @@ def render_host_drift_markdown(payload: dict[str, Any]) -> str: "DEFAULT_BASELINE_FILE", "HOST_GRANTS_INVENTORY_SCHEMA_VERSION", "HOST_GRANTS_SCHEMA_VERSION", + "INCOMPARABLE_BASELINE_REVIEW", "HostBoundarySnapshot", "HostStaticParseCache", "build_host_boundary_snapshot", diff --git a/src/agents_shipgate/core/preflight.py b/src/agents_shipgate/core/preflight.py index 7c6f401d..58d39783 100644 --- a/src/agents_shipgate/core/preflight.py +++ b/src/agents_shipgate/core/preflight.py @@ -23,6 +23,7 @@ from agents_shipgate.core.globbing import glob_match from agents_shipgate.core.host_grants import ( DEFAULT_BASELINE_FILE, + INCOMPARABLE_BASELINE_REVIEW, build_host_drift_payload, host_audit_inventory, load_host_grants_baseline, @@ -813,9 +814,19 @@ def signals_for_host_grant_drift( expansion = host_grant_drift.get("expansion_signals") or [] if expansion: reason += " Expansion signals: " + ", ".join(str(item) for item in expansion[:5]) - rerun = str( - host_grant_drift.get("next_action") - or "shipgate audit --host --drift --fail-on-drift" + rerun = ( + str( + host_grant_drift.get("next_action") + or "shipgate audit --host --drift --fail-on-drift" + ) + if comparable + else None + ) + recommendation = ( + "Route the host-grant comparison to a human. After review, " + f"run `{rerun}`." + if rerun + else f"Route the host-grant comparison to a human. {INCOMPARABLE_BASELINE_REVIEW}" ) return [ PreflightSignalV1( @@ -826,10 +837,7 @@ def signals_for_host_grant_drift( subject="host_grants", path=str(host_grant_drift.get("baseline_file") or ""), reason=reason, - recommendation=( - "Route the host-grant comparison to a human. After review, " - f"run `{rerun}`." - ), + recommendation=recommendation, related_command=rerun, ) ] diff --git a/src/agents_shipgate/core/trust_roots.py b/src/agents_shipgate/core/trust_roots.py index dd28d244..138d81e6 100644 --- a/src/agents_shipgate/core/trust_roots.py +++ b/src/agents_shipgate/core/trust_roots.py @@ -119,6 +119,33 @@ def _normalized(value: object) -> str: return PurePosixPath(posixpath.normpath(text)).as_posix() +def _is_absolute(path: str) -> bool: + """Whether a normalized POSIX spelling is absolute, including Windows drives.""" + + return posixpath.isabs(path) or ( + len(path) >= 3 and path[1] == ":" and path[2] == "/" + ) + + +def _under_workspace(workspace: str, path: str) -> str | None: + """Return one canonical spelling only when it remains inside ``workspace``.""" + + resolved = path if _is_absolute(path) else _normalized(posixpath.join(workspace, path)) + if workspace in {"", "."}: + if _is_absolute(resolved) or resolved == ".." or resolved.startswith("../"): + return None + return resolved + + workspace_parts = PurePosixPath(workspace).parts + resolved_parts = PurePosixPath(resolved).parts + if ( + len(resolved_parts) < len(workspace_parts) + or resolved_parts[: len(workspace_parts)] != workspace_parts + ): + return None + return resolved + + def is_configured_manifest( config_path: object | None, path: str, @@ -138,7 +165,8 @@ def is_configured_manifest( slip past by looking textually different from the changed path. ``workspace`` makes the comparison exact by resolving both sides against - it. Without it the fallback matches on whole path components, which can + it and rejecting either one when its canonical path escapes that boundary. + Without it the fallback matches on whole path components, which can over-match a same-named file in another directory; that direction adds a trust-root finding rather than dropping one, so the fallback is safe, just less precise. Callers that know their workspace should pass it. @@ -150,22 +178,19 @@ def is_configured_manifest( candidate = _normalized(path) if not configured or not candidate: return False - if candidate == configured: - return True if workspace is not None: root = _normalized(workspace) - if root: - absolute_config = ( - configured - if posixpath.isabs(configured) - else _normalized(posixpath.join(root, configured)) - ) - absolute_candidate = ( - candidate - if posixpath.isabs(candidate) - else _normalized(posixpath.join(root, candidate)) - ) - return absolute_config == absolute_candidate + if not root: + return False + absolute_config = _under_workspace(root, configured) + absolute_candidate = _under_workspace(root, candidate) + return bool( + absolute_config + and absolute_candidate + and absolute_config == absolute_candidate + ) + if candidate == configured: + return True return configured.endswith(f"/{candidate}") or candidate.endswith(f"/{configured}") diff --git a/src/agents_shipgate/schemas/verification.py b/src/agents_shipgate/schemas/verification.py index dc019d00..d7f281c7 100644 --- a/src/agents_shipgate/schemas/verification.py +++ b/src/agents_shipgate/schemas/verification.py @@ -31,6 +31,11 @@ class VerificationContext(BaseModel): diff_text: str | None = None diff_text_available: bool = False trigger_result: dict[str, Any] = Field(default_factory=dict) + # Stable repository-relative identity of the manifest the verifier loaded. + # A committed-head scan reads the physical file from a temporary archive, + # so ``ScanContext.config_path`` alone cannot be compared with repository- + # relative changed paths without leaking or depending on that temp path. + configured_manifest_path: str | None = None # True only when the comparison base carries no Shipgate manifest at all — # this diff *introduces* the gate rather than modifying one. Checks that # fail safe on a missing base use it to say so honestly; it never relaxes a diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index 10a1a1e0..353868c1 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -303,7 +303,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=735, + line=757, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " diff --git a/tests/test_agent_controls.py b/tests/test_agent_controls.py new file mode 100644 index 00000000..93682a56 --- /dev/null +++ b/tests/test_agent_controls.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import shlex +from pathlib import Path + +from agents_shipgate.core.agent_controls import verify_command_for + + +def _argument(command: str, name: str) -> str: + parts = shlex.split(command) + return parts[parts.index(name) + 1] + + +def test_verify_command_relativizes_nested_config_within_workspace_git_root( + tmp_path: Path, +) -> None: + repository = tmp_path / "repository" + workspace = repository / "services" / "api" + (repository / ".git").mkdir(parents=True) + workspace.mkdir(parents=True) + + command = verify_command_for(workspace, Path("shipgate.yaml")) + + assert _argument(command, "--workspace") == str(workspace) + assert _argument(command, "--config") == "services/api/shipgate.yaml" + + +def test_verify_command_keeps_cross_repo_config_absolute(tmp_path: Path) -> None: + requested_repository = tmp_path / "requested" + other_repository = tmp_path / "other" + (requested_repository / ".git").mkdir(parents=True) + (other_repository / ".git").mkdir(parents=True) + config = other_repository / "shipgate.yaml" + + command = verify_command_for(requested_repository, config) + + assert _argument(command, "--workspace") == str(requested_repository) + assert _argument(command, "--config") == config.resolve().as_posix() diff --git a/tests/test_agent_mode.py b/tests/test_agent_mode.py index 7b260704..42b9f5f5 100644 --- a/tests/test_agent_mode.py +++ b/tests/test_agent_mode.py @@ -13,6 +13,7 @@ from __future__ import annotations import json +import shlex import subprocess from pathlib import Path @@ -633,6 +634,51 @@ def test_check_offers_no_command_for_a_stdin_diff( payload = _agent_mode_error(result) _assert_documented_envelope(payload) assert payload["next_actions"][0]["kind"] == "review" + assert payload["next_actions"][0]["command"] is None + + +def test_check_offers_no_command_for_a_one_sided_range_flag_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Correcting --format must not silently switch a range to the worktree.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + result = runner.invoke( + app, + ["check", "--base", "origin/main", "--format", "nope"], + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + assert payload["next_actions"][0]["kind"] == "review" + assert payload["next_actions"][0]["command"] is None + + +@pytest.mark.parametrize( + "args", + [ + ["--diff", "changes.diff", "--base", "origin/main", "--head", "HEAD"], + ["--diff", "changes.diff", "--head", "HEAD"], + ["--diff", ""], + ["--base", "", "--head", "HEAD"], + ["--base", "", "--head", ""], + ], +) +def test_check_rejects_ambiguous_diff_shapes_without_a_command( + monkeypatch: pytest.MonkeyPatch, + args: list[str], +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + + result = runner.invoke(app, ["check", *args]) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + action = payload["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None def test_preflight_offers_no_command_for_conflicting_flags( @@ -662,12 +708,13 @@ def test_preflight_offers_no_command_for_conflicting_flags( assert payload["next_actions"][0]["kind"] == "review" -def test_audit_missing_baseline_stays_a_config_error( +def test_audit_missing_baseline_recovery_preserves_scope_and_target( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """"Record one first" is a request problem, not a filesystem failure.""" + """A missing baseline is a request problem, not a filesystem failure.""" monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + baseline = tmp_path / "missing baseline; grants.json" result = runner.invoke( app, [ @@ -675,16 +722,173 @@ def test_audit_missing_baseline_stays_a_config_error( "--host", "--workspace", str(tmp_path), + "--scope", + "local-static", "--drift", "--baseline-file", - str(tmp_path / "absent.json"), + str(baseline), ], ) assert result.exit_code == 2 payload = _agent_mode_error(result) assert payload["error"] == "config_error" - assert "--save-baseline" in payload["next_actions"][0]["command"] + action = payload["next_actions"][0] + assert action["kind"] == "command" + argv = shlex.split(action["command"]) + assert argv == [ + "agents-shipgate", + "audit", + "--host", + "--workspace", + str(tmp_path), + "--scope", + "local-static", + "--save-baseline", + "--baseline-file", + str(baseline), + ] + assert "shipgate audit --host --scope repository --save-baseline" not in ( + result.output + ) + assert payload["message"].endswith( + f"No baseline was written to {baseline}." + ) + assert "agents-shipgate audit" not in payload["message"] + assert "agents-shipgate audit" not in action["why"] + + +@pytest.mark.parametrize( + "failure", + ["malformed_json", "unsupported_schema", "integrity_failure"], +) +def test_audit_never_overwrites_an_invalid_existing_baseline( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + """An error action must preserve the evidence instead of accepting drift.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + baseline = tmp_path / "important baseline.json" + if failure == "malformed_json": + baseline.write_text("{not json", encoding="utf-8") + elif failure == "unsupported_schema": + baseline.write_text( + json.dumps({"host_grants_schema_version": "99.0"}), + encoding="utf-8", + ) + else: + saved = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--save-baseline", + "--baseline-file", + str(baseline), + "--json", + ], + ) + assert saved.exit_code == 0, saved.output + data = json.loads(baseline.read_text(encoding="utf-8")) + data["inventory_sha256"] = "0" * 64 + baseline.write_text(json.dumps(data), encoding="utf-8") + before = baseline.read_bytes() + + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--drift", + "--baseline-file", + str(baseline), + "--json", + ], + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + action = payload["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert "--save-baseline" not in result.output + assert baseline.read_bytes() == before + + save = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--save-baseline", + "--baseline-file", + str(baseline), + "--json", + ], + ) + assert save.exit_code == 2 + save_action = _agent_mode_error(save)["next_actions"][0] + assert save_action["kind"] == "review" + assert save_action["command"] is None + assert "--save-baseline" not in save.output + assert baseline.read_bytes() == before + + +def test_audit_does_not_treat_a_broken_baseline_symlink_as_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Saving through a broken link could create a target outside the request.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + target = tmp_path / "missing-target.json" + baseline = tmp_path / "baseline.json" + baseline.symlink_to(target) + + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--drift", + "--baseline-file", + str(baseline), + "--json", + ], + ) + + assert result.exit_code == 2 + action = _agent_mode_error(result)["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert not target.exists() + + save = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--save-baseline", + "--baseline-file", + str(baseline), + "--json", + ], + ) + assert save.exit_code == 2 + save_action = _agent_mode_error(save)["next_actions"][0] + assert save_action["kind"] == "review" + assert save_action["command"] is None + assert not target.exists() def test_audit_unreadable_baseline_is_a_filesystem_error( diff --git a/tests/test_agent_protocol.py b/tests/test_agent_protocol.py index ca51a7f3..b822d820 100644 --- a/tests/test_agent_protocol.py +++ b/tests/test_agent_protocol.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import shlex from pathlib import Path from typing import Any @@ -261,6 +262,11 @@ def test_codex_mcp_auto_approval_requires_human_then_clean_diff_completes( def test_check_diff_input_failure_emits_schema_valid_boundary_result(tmp_path: Path) -> None: + workspace = tmp_path / "work space; target" + workspace.mkdir() + config = Path("gate file; config.yml") + policy = Path("policy file; rules.yml") + missing_diff = tmp_path / "missing diff; printf INJECTED" result = runner.invoke( app, [ @@ -268,9 +274,13 @@ def test_check_diff_input_failure_emits_schema_valid_boundary_result(tmp_path: P "--agent", "claude-code", "--workspace", - str(tmp_path), + str(workspace), + "--config", + str(config), + "--policy", + str(policy), "--diff", - str(tmp_path / "missing.diff"), + str(missing_diff), "--format", "codex-boundary-json", ], @@ -291,6 +301,79 @@ def test_check_diff_input_failure_emits_schema_valid_boundary_result(tmp_path: P assert control["next_action"]["kind"] == "repair" assert payload["repair"]["safe_to_attempt"] is True assert payload["diagnostics"][0]["code"] == "diff_input_unresolved" + command = control["next_action"]["command"] + assert control["allowed_next_commands"] == [command] + assert payload["repair"]["command"] == command + assert shlex.split(command) == [ + "agents-shipgate", + "check", + "--agent", + "claude-code", + "--workspace", + str(workspace), + "--config", + str(config), + "--policy", + str(policy), + "--diff", + str(missing_diff), + "--format", + "codex-boundary-json", + ] + + +def test_check_diff_input_failure_preserves_a_complete_quoted_range( + tmp_path: Path, +) -> None: + """Missing refs may be fetched; their exact range must survive recovery.""" + + workspace = tmp_path / "range work space" + workspace.mkdir() + base = "missing-base; printf INJECTED" + head = "missing-head; printf INJECTED" + result = runner.invoke( + app, + [ + "check", + "--agent", + "cursor", + "--workspace", + str(workspace), + "--config", + "custom gate.yml", + "--policy", + "custom policy.yml", + "--base", + base, + "--head", + head, + "--format", + "codex-boundary-json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + control = _control(payload) + assert control["state"] == "agent_action_required" + assert shlex.split(control["next_action"]["command"]) == [ + "agents-shipgate", + "check", + "--agent", + "cursor", + "--workspace", + str(workspace), + "--config", + "custom gate.yml", + "--policy", + "custom policy.yml", + "--base", + base, + "--head", + head, + "--format", + "codex-boundary-json", + ] def test_missing_install_fixture_is_schema_valid_and_actionable() -> None: diff --git a/tests/test_codex_boundary_check.py b/tests/test_codex_boundary_check.py index 336b48d7..9cd33970 100644 --- a/tests/test_codex_boundary_check.py +++ b/tests/test_codex_boundary_check.py @@ -1087,17 +1087,8 @@ def test_codex_check_rejects_one_sided_git_refs(tmp_path: Path) -> None: ], ) - assert result.exit_code == 0 - payload = json.loads(result.output) - Draft202012Validator(json.loads(SCHEMA.read_text(encoding="utf-8"))).validate(payload) - assert payload["decision"] == "block" - control = _control(payload) - assert control["state"] == "agent_action_required" - assert control["completion_allowed"] is False - assert control["must_stop"] is False - assert control["next_action"]["actor"] == "coding_agent" - assert control["next_action"]["kind"] == "repair" - assert payload["diagnostics"][0]["code"] == "diff_input_unresolved" + assert result.exit_code == 2 + assert "--base and --head must be provided together" in result.output def test_codex_check_malformed_toml_returns_schema_valid_json(tmp_path: Path) -> None: diff --git a/tests/test_first_adoption.py b/tests/test_first_adoption.py index 5ee7e081..6956d660 100644 --- a/tests/test_first_adoption.py +++ b/tests/test_first_adoption.py @@ -644,3 +644,42 @@ def test_equivalent_config_spellings_resolve_to_the_same_manifest(): # is not the gate. assert not is_configured_manifest("/repo/x/sub/gate.yml", "sub/gate.yml", workspace="/repo") assert not is_configured_manifest("/repo/new-gate.yml", "README.md") + + +# --- archived scan identity -------------------------------------------------- + + +def test_archived_verify_keeps_the_nested_configured_manifest_identity(tmp_path): + """The scan archive path must not hide the configured manifest from boundary checks. + + ``run_verify(..., archive_head=True)`` loads this manifest from a temporary + ``/head/...`` path while the diff remains repository-relative. + The stable identity on ``VerificationContext`` keeps both coordinate + systems aligned. + """ + + repo = _repo_adopting_shipgate(tmp_path) + custom = Path("samples/support_refund_agent/new-gate.yml") + _git( + repo, + "mv", + SAMPLE_CONFIG.as_posix(), + custom.as_posix(), + ) + _git(repo, "commit", "-m", "move gate to a custom nested path") + + _run_verify(repo, base="HEAD~1", head="HEAD", config=custom) + + payload = json.loads( + (repo / "agents-shipgate-reports" / "report.json").read_text("utf-8") + ) + boundary = [ + finding + for finding in payload["findings"] + if finding["check_id"] + == "SHIP-AGENT-BOUNDARY-PROTECTED-SURFACE-UNCLASSIFIED" + and finding.get("source", {}).get("path") == custom.as_posix() + ] + assert boundary, payload["findings"] + assert boundary[0]["evidence"]["trust_root_class"] == "manifest" + assert "agents-shipgate-verify-head-" not in json.dumps(boundary[0]) diff --git a/tests/test_host_audit.py b/tests/test_host_audit.py index 5b186b5d..b2a654bd 100644 --- a/tests/test_host_audit.py +++ b/tests/test_host_audit.py @@ -4,10 +4,12 @@ from pathlib import Path import pytest +import typer from pydantic import ValidationError from typer.testing import CliRunner from agents_shipgate.cli.host_audit import ( + _atomic_write_baseline, host_audit_inventory, host_grants_sha256, render_host_audit_markdown, @@ -537,6 +539,161 @@ def test_v02_baseline_is_typed_portable_redacted_and_idempotent(tmp_path: Path) assert baseline_path.read_bytes() == first +def test_valid_baseline_update_uses_atomic_replacement(tmp_path: Path) -> None: + _seed_workspace(tmp_path) + baseline = _save_baseline(tmp_path) + original_inode = baseline.stat().st_ino + config = tmp_path / ".mcp.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["mcpServers"]["github"]["args"].append("--new-scope") + config.write_text(json.dumps(payload), encoding="utf-8") + + result = runner.invoke( + app, + ["audit", "--host", "--workspace", str(tmp_path), "--save-baseline", "--json"], + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["status"] == "updated" + assert baseline.stat().st_ino != original_inode + assert not list(baseline.parent.glob(f".{baseline.name}.*.tmp")) + + +def test_save_refuses_valid_symlink_and_preserves_external_target( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + source.mkdir() + external = _save_baseline(source) + before = external.read_bytes() + requested = tmp_path / "requested" + baseline = requested / ".agents-shipgate" / "host-grants.json" + baseline.parent.mkdir(parents=True) + baseline.symlink_to(external) + + result = runner.invoke( + app, + ["audit", "--host", "--workspace", str(requested), "--save-baseline"], + ) + + assert result.exit_code == 2 + assert baseline.is_symlink() + assert external.read_bytes() == before + + +def test_save_refuses_symlinked_parent_and_preserves_external_directory( + tmp_path: Path, +) -> None: + external = tmp_path / "external" + external.mkdir() + requested = tmp_path / "requested" + requested.mkdir() + (requested / ".agents-shipgate").symlink_to( + external, + target_is_directory=True, + ) + + result = runner.invoke( + app, + ["audit", "--host", "--workspace", str(requested), "--save-baseline"], + ) + + assert result.exit_code == 2 + assert not (external / "host-grants.json").exists() + + +def test_save_refuses_hardlink_and_preserves_external_target(tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir() + external = _save_baseline(source) + before = external.read_bytes() + requested = tmp_path / "requested" + baseline = requested / ".agents-shipgate" / "host-grants.json" + baseline.parent.mkdir(parents=True) + baseline.hardlink_to(external) + + result = runner.invoke( + app, + ["audit", "--host", "--workspace", str(requested), "--save-baseline"], + ) + + assert result.exit_code == 2 + assert external.read_bytes() == before + assert baseline.read_bytes() == before + assert external.stat().st_nlink == 2 + + +def test_save_allows_explicit_absolute_regular_target_but_not_relative_escape( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + absolute = tmp_path / "explicit-baseline.json" + created = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(workspace), + "--save-baseline", + "--baseline-file", + str(absolute), + ], + ) + assert created.exit_code == 0, created.output + assert absolute.is_file() + + escaped = tmp_path / "escaped-baseline.json" + refused = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(workspace), + "--save-baseline", + "--baseline-file", + "../escaped-baseline.json", + ], + ) + assert refused.exit_code == 2 + assert not escaped.exists() + + +def test_atomic_save_fails_closed_if_target_appears(tmp_path: Path) -> None: + target = tmp_path / "appeared.json" + target.write_text("external evidence", encoding="utf-8") + + with pytest.raises(typer.Exit) as raised: + _atomic_write_baseline(target, "{}\n", expected=None) + + assert raised.value.exit_code == 2 + assert target.read_text(encoding="utf-8") == "external evidence" + + +def test_save_reports_symlink_loop_without_a_traceback(tmp_path: Path) -> None: + loop = tmp_path / "loop" + loop.symlink_to("loop") + + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--save-baseline", + "--baseline-file", + str(loop / "host-grants.json"), + ], + ) + + assert result.exit_code == 2 + assert "Refusing to write host-grants baseline" in result.output + assert "Traceback" not in result.output + + def test_clean_and_changed_v02_drift(tmp_path: Path) -> None: _seed_workspace(tmp_path) _save_baseline(tmp_path) @@ -773,22 +930,42 @@ def test_legacy_v01_baseline_is_incomparable_advisory_and_strict_20(tmp_path: Pa _seed_workspace(tmp_path) baseline = tmp_path / ".agents-shipgate/host-grants.json" baseline.parent.mkdir() + legacy = { + "host_grants_schema_version": "0.1", + "inventory_sha256": "legacy", + "inventory": {"mcp_servers": []}, + } baseline.write_text( - json.dumps( - { - "host_grants_schema_version": "0.1", - "inventory_sha256": "legacy", - "inventory": {"mcp_servers": []}, - } - ), + json.dumps(legacy), encoding="utf-8", ) + shared = build_host_drift_payload( + baseline=legacy, + inventory=host_audit_inventory(tmp_path), + baseline_file=".agents-shipgate/host-grants.json", + ) + HostGrantsDriftV2.model_validate(shared) + assert shared["comparison_status"] == "incomparable" + assert shared["next_action"] is None + assert "--save-baseline" not in json.dumps(shared) + code, payload = _drift_json(tmp_path) assert code == 0 + HostGrantsDriftV2.model_validate(payload) assert payload["comparison_status"] == "incomparable" assert payload["has_drift"] is None assert "baseline_schema_v0.1" in payload["incomparable_reasons"][0] - assert payload["next_action"] == "shipgate audit --host --scope repository --save-baseline" + assert payload["next_action"] is None + assert baseline.exists() + + before = baseline.read_bytes() + replace = runner.invoke( + app, + ["audit", "--host", "--workspace", str(tmp_path), "--save-baseline"], + ) + assert replace.exit_code == 2 + assert "--save-baseline" not in replace.output + assert baseline.read_bytes() == before strict = runner.invoke( app, @@ -821,12 +998,39 @@ def test_malformed_nested_v02_baseline_is_incomparable_not_a_crash(tmp_path: Pat ) code, payload = _drift_json(tmp_path) assert code == 0 + HostGrantsDriftV2.model_validate(payload) assert payload["comparison_status"] == "incomparable" assert payload["has_drift"] is None assert payload["incomparable_reasons"] == ["malformed_v0.2_baseline"] - assert payload["next_action"] == ( - "shipgate audit --host --scope repository --save-baseline" + assert payload["next_action"] is None + assert json.loads(baseline.read_text(encoding="utf-8"))["inventory"]["grants"] == [ + "malformed-grant" + ] + + markdown = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--drift", + ], + ) + assert markdown.exit_code == 0, markdown.output + assert "--save-baseline" not in markdown.output + assert "Review the existing baseline" in markdown.output + assert "Next: None" not in markdown.output + + before = baseline.read_bytes() + replace = runner.invoke( + app, + ["audit", "--host", "--workspace", str(tmp_path), "--save-baseline"], ) + assert replace.exit_code == 2 + assert "--save-baseline" not in replace.output + assert baseline.read_bytes() == before + strict = runner.invoke( app, [ @@ -850,6 +1054,7 @@ def test_scope_mismatch_is_incomparable(tmp_path: Path, monkeypatch: pytest.Monk assert code == 0 assert payload["comparison_status"] == "incomparable" assert "scope_mismatch:repository->local_static" in payload["incomparable_reasons"] + assert payload["next_action"] is None def test_fail_on_drift_exits_20_and_advisory_stays_zero(tmp_path: Path) -> None: @@ -883,6 +1088,7 @@ def test_missing_corrupt_unknown_and_tampered_baselines_fail_closed(tmp_path: Pa baseline.write_text(content, encoding="utf-8") result = runner.invoke(app, ["audit", "--host", "--workspace", str(tmp_path), "--drift"]) assert result.exit_code == 2 + baseline.unlink() baseline = _save_baseline(tmp_path) payload = json.loads(baseline.read_text(encoding="utf-8")) payload["inventory_sha256"] = "0" * 64 diff --git a/tests/test_org_governance.py b/tests/test_org_governance.py index 5c8d51b9..7887dd50 100644 --- a/tests/test_org_governance.py +++ b/tests/test_org_governance.py @@ -363,6 +363,8 @@ def test_org_status_treats_incomparable_host_baseline_as_violation( assert payload["summary"]["host_grant_drift"] is True assert payload["host_grant_drift"]["comparison_status"] == "incomparable" assert payload["host_grant_drift"]["has_drift"] is None + assert payload["host_grant_drift"]["next_action"] is None + assert "--save-baseline" not in json.dumps(payload["host_grant_drift"]) assert payload["violations"] == [ { "kind": "host_grant_drift", diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 5c364ae6..937bf969 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -3,6 +3,7 @@ import difflib import json import shlex +import subprocess import sys from pathlib import Path @@ -123,6 +124,62 @@ def test_preflight_routes_protected_surface_touches_to_human(tmp_path: Path) -> assert any(".codex/config.toml" in pattern for pattern in result.forbidden_file_edits) +def test_preflight_nested_verify_command_targets_the_nested_manifest( + tmp_path: Path, +) -> None: + root = _workspace(tmp_path) + nested = root / "services" / "api" + nested.mkdir(parents=True) + (nested / "shipgate.yaml").write_text( + (root / "shipgate.yaml") + .read_text(encoding="utf-8") + .replace("name: preflight-test", "name: nested-preflight-test", 1), + encoding="utf-8", + ) + (nested / "tools.json").write_text('{"tools": []}\n', encoding="utf-8") + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + + result = build_preflight_result( + workspace=nested, + config=Path("shipgate.yaml"), + changed_files=["README.md"], + ) + + command = result.control.allowed_next_commands[0] + argv = shlex.split(command) + assert Path(argv[argv.index("--workspace") + 1]).resolve() == nested.resolve() + assert argv[argv.index("--config") + 1] == "services/api/shipgate.yaml" + assert (root / argv[argv.index("--config") + 1]).resolve() == ( + nested / "shipgate.yaml" + ).resolve() + + +def test_preflight_classifies_both_sides_of_a_custom_manifest_rename( + tmp_path: Path, +) -> None: + root = _workspace(tmp_path) + (root / "shipgate.yaml").rename(root / "shipgate-self.yaml") + diff = ( + "diff --git a/shipgate-self.yaml b/renamed-gate.yml\n" + "similarity index 100%\n" + "rename from shipgate-self.yaml\n" + "rename to renamed-gate.yml\n" + ) + + result = build_preflight_result( + workspace=root, + config=Path("shipgate-self.yaml"), + diff_text=diff, + ) + + assert result.changed_files == ["renamed-gate.yml", "shipgate-self.yaml"] + assert [ + (touch.path, touch.kind) for touch in result.protected_surface_touches + ] == [("shipgate-self.yaml", "manifest")] + assert result.requires_human_review is True + assert result.control.state == "human_review_required" + + def test_preflight_allows_exact_append_only_builtin_source_proposal( tmp_path: Path, ) -> None: @@ -869,14 +926,15 @@ def test_cli_preflight_routes_incomparable_legacy_host_baseline_to_human( payload = json.loads(result.output) assert payload["host_grant_drift"]["comparison_status"] == "incomparable" assert payload["host_grant_drift"]["has_drift"] is None + assert payload["host_grant_drift"]["next_action"] is None assert payload["first_next_action"]["actor"] == "human" signal = next( item for item in payload["signals"] if item["kind"] == "host_grant_drift" ) assert "could not be compared completely" in signal["reason"] - assert signal["related_command"] == ( - "shipgate audit --host --scope repository --save-baseline" - ) + assert signal["related_command"] is None + assert "Review the existing baseline" in signal["recommendation"] + assert "--save-baseline" not in json.dumps(payload) def test_cli_preflight_default_corrupt_host_baseline_warns_and_continues( diff --git a/tests/test_trust_root.py b/tests/test_trust_root.py index f3102ef9..2a8796cc 100644 --- a/tests/test_trust_root.py +++ b/tests/test_trust_root.py @@ -19,6 +19,7 @@ from agents_shipgate.core.context import ScanContext from agents_shipgate.core.domain import Agent from agents_shipgate.core.findings.mutations import apply_suppressions +from agents_shipgate.core.trust_roots import is_configured_manifest from agents_shipgate.schemas.manifest import SuppressionConfig from agents_shipgate.schemas.verification import VerificationContext @@ -123,6 +124,35 @@ def test_duplicate_changed_files_are_deduplicated(): assert len(findings) == 1 +def test_configured_manifest_identity_is_canonical_within_workspace(): + assert is_configured_manifest( + "/repo/docs/engineering/../gate.yml", + "docs/gate.yml", + workspace="/repo", + ) + assert is_configured_manifest( + "services/api/gate.yml", + "services/api/config/../gate.yml", + workspace="/repo", + ) + + +@pytest.mark.parametrize( + ("config", "changed"), + [ + ("../outside/gate.yml", "../outside/gate.yml"), + ("/outside/gate.yml", "/outside/gate.yml"), + ("/repo-sibling/gate.yml", "/repo-sibling/gate.yml"), + ("/repo/services/api/gate.yml", "services/other/gate.yml"), + ], +) +def test_configured_manifest_identity_rejects_workspace_escapes_and_aliases( + config: str, + changed: str, +): + assert not is_configured_manifest(config, changed, workspace="/repo") + + def test_first_match_wins_classification_order(): """A SKILL.md under .agents/skills/ classifies as agent_instructions (ordered earlier) rather than tool_surface_decl — one finding, the diff --git a/tests/test_verify.py b/tests/test_verify.py index 9f7d6cd4..5a67dbf2 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -10,9 +10,10 @@ from typer.testing import CliRunner from agents_shipgate.cli.main import app +from agents_shipgate.cli.verify import git as verify_git from agents_shipgate.cli.verify.capability_review import build_capability_review from agents_shipgate.cli.verify.fix_task import build_fix_task -from agents_shipgate.cli.verify.git import read_file_at_ref +from agents_shipgate.cli.verify.git import carries_manifest_like_yaml, read_file_at_ref from agents_shipgate.cli.verify.orchestrator import _prune_base_scan_cache from agents_shipgate.cli.verify.pr_comment import render_pr_comment from agents_shipgate.core.agent_control import derive_agent_control @@ -1383,6 +1384,62 @@ def test_read_file_at_ref_reads_single_blob(tmp_path: Path) -> None: assert read_file_at_ref(repo, "HEAD", Path("missing.json")) is None +def test_retained_manifest_probe_parses_quoted_flow_mapping(tmp_path: Path) -> None: + repo = _init_repo(tmp_path) + (repo / "old-gate.yml").write_text( + '{"version": "0.1", "project": {"name": "demo"}, ' + '"agent": {"name": "assistant"}}\n', + encoding="utf-8", + ) + _commit_all(repo, "quoted manifest") + + assert carries_manifest_like_yaml(repo, "HEAD") is True + + +def test_retained_manifest_probe_fails_closed_on_unparseable_yaml( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + (repo / "unknown.yml").write_text("project: [\nagent:\n", encoding="utf-8") + _commit_all(repo, "unparseable yaml") + + assert carries_manifest_like_yaml(repo, "HEAD") is None + + +def test_retained_manifest_probe_rejects_oversized_blob_before_reading_it( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _init_repo(tmp_path) + (repo / "oversized.yml").write_bytes( + b"project:\n name: demo\nagent:\n name: assistant\n" + + b"#" * verify_git._MAX_MANIFEST_BYTES + ) + _commit_all(repo, "oversized yaml") + calls: list[tuple[str, ...]] = [] + original = verify_git._run_git + + def recording_run_git(workspace, args, **kwargs): + calls.append(tuple(args)) + return original(workspace, args, **kwargs) + + monkeypatch.setattr(verify_git, "_run_git", recording_run_git) + + assert carries_manifest_like_yaml(repo, "HEAD") is None + assert any(args[:2] == ("cat-file", "-s") for args in calls) + assert not any(args and args[0] == "show" for args in calls) + + +def test_retained_manifest_probe_fails_closed_on_non_utf8_yaml( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + (repo / "unknown.yml").write_bytes(b"project:\n name: \xff\nagent:\n name: bot\n") + _commit_all(repo, "non-utf8 yaml") + + assert carries_manifest_like_yaml(repo, "HEAD") is None + + def test_prune_base_scan_cache_keeps_newest_entries(tmp_path: Path) -> None: cache_root = tmp_path / "base-scans" cache_root.mkdir() From ca3b700bd63c45455ef21776ff4a18aaa9a9454d Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Thu, 30 Jul 2026 16:39:00 -0700 Subject: [PATCH 10/12] Address full PR review findings --- CHANGELOG.md | 64 +- STABILITY.md | 34 +- benchmark/miner/evaluate.py | 4 +- docs/agents/protocol.md | 18 +- docs/errors.json | 6 +- docs/mcp-server.md | 11 + docs/triggers.json | 4 +- src/agents_shipgate/checks/_verify_common.py | 6 +- src/agents_shipgate/checks/verify.py | 8 +- src/agents_shipgate/checks/verify_policy.py | 14 +- src/agents_shipgate/cli/_register_init.py | 25 +- src/agents_shipgate/cli/_register_scan.py | 157 ++- src/agents_shipgate/cli/agent_mode.py | 3 +- src/agents_shipgate/cli/agent_result.py | 536 ++++++++- src/agents_shipgate/cli/apply_patches.py | 29 +- src/agents_shipgate/cli/check.py | 330 +++++- src/agents_shipgate/cli/detect.py | 28 +- .../cli/discovery/artifacts.py | 118 +- src/agents_shipgate/cli/host_audit.py | 280 ++++- src/agents_shipgate/cli/install_hooks.py | 963 ++++++++++++++-- src/agents_shipgate/cli/mcp.py | 62 +- src/agents_shipgate/cli/preflight.py | 298 ++++- src/agents_shipgate/cli/scan/orchestrator.py | 2 + src/agents_shipgate/cli/scan/prepare.py | 20 +- src/agents_shipgate/cli/scan/validation.py | 10 +- src/agents_shipgate/cli/trigger.py | 40 +- src/agents_shipgate/cli/verification.py | 6 +- src/agents_shipgate/cli/verify/command.py | 84 +- src/agents_shipgate/cli/verify/fix_task.py | 262 ++++- src/agents_shipgate/cli/verify/git.py | 1003 ++++++++++++++--- .../cli/verify/orchestrator.py | 869 ++++++++++++-- src/agents_shipgate/config/loader.py | 37 + src/agents_shipgate/core/agent_boundary.py | 354 +++++- src/agents_shipgate/core/agent_controls.py | 79 +- src/agents_shipgate/core/baseline.py | 5 +- src/agents_shipgate/core/boundary_diff.py | 307 ++++- src/agents_shipgate/core/boundary_registry.py | 3 + src/agents_shipgate/core/bounded_io.py | 78 ++ src/agents_shipgate/core/codex_boundary.py | 322 +++++- src/agents_shipgate/core/errors.py | 3 + src/agents_shipgate/core/host_boundary.py | 13 +- src/agents_shipgate/core/host_grants.py | 539 ++++++++- .../core/lenses/tool_surface.py | 5 +- .../core/manifest_proposals.py | 42 +- src/agents_shipgate/core/preflight.py | 587 ++++++++-- src/agents_shipgate/core/static_inputs.py | 247 ++++ src/agents_shipgate/core/trust_roots.py | 801 ++++++++++++- .../core/verification_identity.py | 113 +- src/agents_shipgate/inputs/common.py | 81 +- src/agents_shipgate/inputs/mcp_manifest.py | 5 +- src/agents_shipgate/inputs/policy_packs.py | 5 +- src/agents_shipgate/mcp_server/server.py | 60 +- src/agents_shipgate/schemas/preflight.py | 111 +- src/agents_shipgate/triggers.py | 46 +- .../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 | 30 +- tests/test_adapter_static_only.py | 153 ++- tests/test_agent_boundary.py | 803 ++++++++++++- tests/test_agent_controls.py | 126 ++- tests/test_agent_mode.py | 351 +++++- tests/test_agent_protocol.py | 62 +- tests/test_codex_boundary_check.py | 81 +- tests/test_first_adoption.py | 231 +++- tests/test_fix_task_contract.py | 281 ++++- tests/test_host_audit.py | 512 ++++++++- tests/test_install_hooks.py | 454 +++++++- tests/test_mcp_audit.py | 230 ++++ tests/test_mcp_server.py | 89 ++ tests/test_preflight.py | 554 ++++++++- tests/test_trust_root.py | 466 +++++++- tests/test_verify.py | 869 +++++++++++++- tests/test_verify_orchestrator.py | 2 +- 78 files changed, 13152 insertions(+), 1293 deletions(-) create mode 100644 src/agents_shipgate/core/bounded_io.py create mode 100644 src/agents_shipgate/core/static_inputs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1e2e3f..452c3cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ missed a valid manifest with quoted keys — so neither a *moved* manifest nor a base that quietly keeps one can pass itself off as a first adoption) and says so: same check id, same `medium` severity, same `human_review_required` state, new evidence kind `manifest_introduced`, and a - `fix_task` whose leading instruction is "review the generated shipgate.yaml - and merge the adoption through a human-reviewed PR". `check` gets the same - correction locally, keyed on the diff carrying exactly one manifest record - and that record being a plain addition. Adoption remains a human decision — - only the claim about what happened changed. + `fix_task` whose leading instruction names the exact configured manifest. + Only when adoption is the sole gating concern does that instruction say to + merge the adoption through a human-reviewed PR; blockers, insufficient + evidence, and additional review items lead with their own stop condition. + `check` gets the same correction locally, keyed on the diff carrying exactly + one manifest record and that record being a plain addition. Adoption remains + a human decision — only the claim about what happened changed. - **The manifest a run actually loaded is a trust root.** The trust-root table only knew `**/shipgate.yaml`, so a repository run with `--config new-gate.yml` had no manifest trust root at all: the file defining its gate could be @@ -84,13 +86,21 @@ and the manifest, with the config rendered the way `verify` resolves it — relative to the repository root — so a nested manifest is no longer verified against the root gate. +- **Detached diffs never authorize checkout-dependent verification.** A diff + supplied by file, stdin, or the read-only MCP adapter can be evaluated for + diagnostics, but it is not proof of the bytes a later `verify` command would + read. When such a result owes verification, control now stops with no + allowed command and the summary says to rerun against the intended worktree + or a complete ref range. MCP preflight also rejects a `plan` mixed with + direct request fields instead of silently discarding one input source. - **A failed baseline is never recovered by overwriting it.** A malformed, unknown-schema, or integrity-failed host-grants baseline recommended `--save-baseline` against the same path, which replaced the failed artifact with the *current* grants — acknowledging them unreviewed and destroying the - evidence a human needed. Those now route to review; only a genuinely absent - baseline gets a record command, and it carries the `--scope` it was asked - for. + evidence a human needed. Those now route to review. A genuinely absent + baseline also routes to a human because creating the first baseline + acknowledges the current grants; a failed read-only drift request never + authorizes that state-changing decision. - **Host-audit filesystem failures follow the catalog.** A `--baseline-file` naming a directory raised `IsADirectoryError` through typer as a traceback and exit 1. Filesystem failures on both `--baseline-file` and `--out` are now @@ -98,15 +108,37 @@ briefly reported as `config_error`/2, which sends an agent back to re-read flags that were fine. The baseline *reader* needed the same treatment through its `__cause__`, since the loader converts every read `OSError` into - `ValueError`; a genuinely missing baseline keeps its documented - `config_error`/2 "record one first" contract. + `ValueError`; a genuinely missing baseline remains `config_error`/2 but now + carries a review-only route for the first acknowledgement. - **The audit id distinguishes the actor.** Detecting the calling agent changed the label in the result but not the digest — the central one omitted the actor and the legacy one hardcoded `codex` — so identical evaluations by Claude Code, Cursor, and Codex shared an `audit_id`, which is exactly the - attribution problem actor detection exists to solve. A codex run's id is - unchanged — the actor is folded in only for a non-default one, so every id - issued before detection existed (all of them codex runs) keeps its value. + attribution problem actor detection exists to solve. Legacy replayable + provided-diff Codex ids keep their established shape. Non-default actors add + actor identity; worktree, ref-range, and detached evaluations also bind + input/control replayability, so those ids intentionally rotate. Semantic + control state is hashed without checkout-specific command paths. +- **Static control inputs now fail closed on identity and resource ambiguity.** + Local check, preflight, host audit, installed hooks, and verifier Git + collection bind exact non-symlink, singly-linked regular files; manifest, + policy, baseline, trust-root, diff, and Git inventories have byte or entry + ceilings. Executable filters, repository diff drivers, hidden index flags, + source-like binary diffs, malformed/coherence-breaking diff records, and + filesystem-portability collisions stop instead of silently dropping source + text. Prior verifier output is excluded from a worktree request so an + identical rerun does not hash its own artifacts. +- **Portable host instructions are protected consistently.** Boundary + matching is case-insensitive and hierarchical for `AGENTS.md`, + `AGENTS.override.md`, and `CLAUDE.md`; a case variant or nested copy cannot + acquire authority only after checkout on another host. Symlink directories + that could conceal a leading-`**/` trust root make host inventory and + preflight incomplete and route to human review. +- **Mechanical repair authorization is subject-bound.** A coding-agent repair + route now requires an applicable high-confidence non-manual patch against a + worktree subject. A ref-bound verifier cannot authorize a patch command that + would edit the checkout and then rerun the unchanged commit; it routes that + repair to a human instead. - **Adoption wording stands down when something was genuinely weakened.** Introducing the manifest while editing an existing policy file produced a `base_snapshot_unavailable` finding under a headline saying there was no @@ -172,8 +204,10 @@ 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 + `shipgate_state`) keep the human stop. Root/case-variant + `AGENTS.md`/`AGENTS.override.md`/`CLAUDE.md` instruction identities do too, + 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 diff --git a/STABILITY.md b/STABILITY.md index 55a26d8e..46716b0f 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -1046,19 +1046,26 @@ tests on every CI run, not by convention: - **`cli/bootstrap.py`** — one `subprocess.run` call shells the installed agents-shipgate CLI to chain `detect → init → scan → apply-patches`. - - **`cli/discovery/artifacts.py`** — two `subprocess.run` calls - invoke `git rev-parse` + `git ls-files` to enumerate user-repo - files. Reads git metadata only. - - **`triggers.py`** — three `subprocess.run` calls (`git diff - --name-only`, `git diff`, `git ls-files --others - --exclude-standard`) for trigger evaluation. Reads diff content - only. **Plus** one `importlib.resources.files('agents_shipgate')` - call to resolve the bundled trigger catalog. + - **`cli/discovery/artifacts.py`** — one `subprocess.run` call resolves + the repository root with `git rev-parse`; one `subprocess.Popen` + boundary incrementally drains the fixed `git ls-files` inventory under + hard byte and wall-clock limits. Both use a sanitized Git environment, + read repository metadata only, and never invoke a shell or fetch. + - **`triggers.py`** — one + `importlib.resources.files('agents_shipgate')` call resolves the bundled + trigger catalog. Optional Git-backed trigger input delegates to the + verifier's audited collector described below; this module has no + subprocess surface of its own. - **`cli/verify/git.py`** — one shared `subprocess.run` boundary invokes local Git plumbing for exact base/head and working-tree orchestration, plus `pack-objects`, `index-pack`, and `fsck` to materialize an isolated, - object-ID-validated snapshot. It never fetches, uses list argv without a - shell, and never executes user code. + object-ID-validated snapshot. One shared `subprocess.Popen` boundary + incrementally drains fixed Git diff, changed-path, attribute, inventory, + and retained-manifest reads under hard output and wall-clock bounds. The + bound is fail-closed: timeout, overflow, read/write failure, or an + unexpected return code yields no trusted result. Neither process boundary + fetches or executes user code; both use sanitized environments and list + argv without a shell. - **`core/authorization_execution.py`** — one shared `subprocess.run` boundary consumes the fixed protected Git argv, and one audited `subprocess.Popen` boundary parent-streams `pack-objects` with hard stdout, @@ -1081,9 +1088,8 @@ tests on every CI run, not by convention: `--agent-instructions-kit`, never dynamic imports or network fetches. - **`cli/trigger.py`** — imports `subprocess` only to catch `subprocess.CalledProcessError` from the shared - `triggers._git_diff_context` git probe. The `agents-shipgate trigger` - subcommand issues no `subprocess.run` call of its own; git runs in - `triggers.py` exclusively, and only when `--base`/`--head` is passed. + verifier Git collector reached through `triggers._git_diff_context`. The + `agents-shipgate trigger` subcommand issues no subprocess call of its own. - **`cli/self_check.py`** — one `__import__(module_name)` call validates that supplied modules import cleanly. Runs only under `agents-shipgate self-check`, never during scan. @@ -1101,7 +1107,7 @@ tests on every CI run, not by convention: real violation on all four fields), `test_no_unallowlisted_forbidden_surface_in_scanner` (every observed violation has a matching entry), and - `test_allowed_exceptions_pin_subprocess_run_per_call_site` (the + `test_allowed_exceptions_pin_subprocess_per_call_site` (the multi-call files have distinct entries per call site, regression- testing the structural fix from the v0.18 PR #2 review). - **[`tests/test_fixture_no_import.py`](tests/test_fixture_no_import.py)** — diff --git a/benchmark/miner/evaluate.py b/benchmark/miner/evaluate.py index 611c4b11..4ce435ed 100644 --- a/benchmark/miner/evaluate.py +++ b/benchmark/miner/evaluate.py @@ -383,14 +383,14 @@ def _verify_pr( head_sha, ] if baseline_path is not None: - cmd += ["--baseline", str(baseline_path)] + cmd += ["--baseline", str(baseline_path.resolve())] cmd += [ "--ci-mode", "advisory", "--format", "json", "--out", - str(tmp_path / "verify-reports"), + str((tmp_path / "verify-reports").resolve()), ] result = _run(cmd) payload = _parse_json(result.stdout) diff --git a/docs/agents/protocol.md b/docs/agents/protocol.md index 1cf521e7..4ad33498 100644 --- a/docs/agents/protocol.md +++ b/docs/agents/protocol.md @@ -42,6 +42,13 @@ shipgate check --agent codex --workspace . --diff change.diff --format agent-bou shipgate check --agent codex --workspace . --diff - --format agent-boundary-json ``` +A supplied file or stdin diff is detached diagnostic input: it is not bound +to checkout bytes that `verify` can reconstruct. Shipgate can still report +boundary findings from it, but when the result owes verification it returns +`human_review_required` with no allowed command. Re-run the check against the +intended worktree, or with both `--base` and `--head`, to obtain a replayable +verification route. + The no-`--diff` form resolves a git diff locally. With no `--base` or `--head`, it reads local staged, unstaged, deleted, renamed, and relevant untracked changes. With `--base` and `--head`, it reads @@ -90,10 +97,13 @@ consumers switch only on `control.state`; `decision` is diagnostic context. exactly for `human_review_required`. `risk_level` remains explanatory. With `--format agent-boundary-json`, schema-valid results exit `0`; wrappers -must switch on `control.state`, not `$?`. Diff-input recovery is represented as -`agent_action_required` with an exact next action. Unsupported -CLI shape errors such as an invalid `--agent` or `--format` still exit nonzero -before a boundary-result object exists. +must switch on `control.state`, not `$?`. A missing ref may return an +`agent_action_required` `fetch_base` action naming the exact required refs. +An unreadable diff file, detached stdin/file diff that owes verification, or +worktree/ref state Shipgate cannot bind returns `human_review_required` and +authorizes no speculative rerun. Unsupported CLI shape errors such as an +invalid `--agent` or `--format` still exit nonzero before a boundary-result +object exists. ## State Machine diff --git a/docs/errors.json b/docs/errors.json index e5f626ab..ff55e501 100644 --- a/docs/errors.json +++ b/docs/errors.json @@ -17,11 +17,11 @@ "errors": [ { "id": "config_error", - "description": "The manifest is missing or invalid: file not found, YAML parse error, schema-validation failure, or unsupported version.", + "description": "A command's configuration or request shape is missing, invalid, inconsistent, or unsafe to apply. This includes manifest loading failures as well as incompatible CLI flags, invalid preflight inputs, and host-audit baseline/control errors.", "exit_code": 2, - "typical_cause": "shipgate.yaml does not exist OR exists but the loader rejected it (extra key, missing required field, wrong nesting level).", + "typical_cause": "The manifest is missing or rejected; mutually exclusive or dependent CLI flags were combined; a preflight request is invalid; or a host-grants baseline request cannot be completed or acknowledged safely.", "additional_fields": ["message", "next_action", "next_actions"], - "recovery_hint": "Distinguish missing vs. invalid via the diagnostic id: `SHIP-DIAG-MISSING-MANIFEST` → run `agents-shipgate verify --workspace --preview --json`; if preview routes setup, run the emitted init command. `SHIP-DIAG-INVALID-MANIFEST` → edit the manifest at the path in the error; do NOT re-run `init` (it refuses to overwrite an existing file)." + "recovery_hint": "Follow the emitted ranked `next_actions[]`; do not assume every config_error is a manifest problem. For manifests, distinguish missing vs. invalid via the diagnostic id: `SHIP-DIAG-MISSING-MANIFEST` → run the emitted preview/setup route; `SHIP-DIAG-INVALID-MANIFEST` → edit the existing manifest and do NOT re-run `init`. For flag, preflight, and host-audit errors, preserve the original workspace and request context, and stop for review whenever no exact safe command is emitted." }, { "id": "config_already_exists", diff --git a/docs/mcp-server.md b/docs/mcp-server.md index f5014bee..7d19a42c 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -41,6 +41,17 @@ requests to a human, or gather evidence for a proposed high-risk capability, but it is not a second release verdict. The release gate remains `report.json.release_decision.decision`. +For `shipgate.preflight`, `plan` is mutually exclusive with the direct +`changed_files`, `diff_text`, `capability_request`, and `base_preflight` +inputs. Send one complete plan object or the applicable direct fields, never +both; a mixed request is rejected rather than silently preferring one source. + +The `diff_text` accepted by `shipgate.check` is detached diagnostic input. It +is not bound to a checkout state that `verify` can reconstruct, so a result +that owes verification stops for human routing and authorizes no verify +command. Re-run `shipgate check` locally against the intended worktree or a +complete `base`/`head` ref range before following a verification route. + `shipgate.handoff` is a read-only projection over existing verifier artifacts. It never runs `verify`, shells out to git, or writes `agent-handoff.json`; it returns the same `shipgate.agent_handoff/v6` shape that `verify` writes for diff --git a/docs/triggers.json b/docs/triggers.json index 3e4263f5..6c566770 100644 --- a/docs/triggers.json +++ b/docs/triggers.json @@ -32,7 +32,7 @@ "id": "claude_code", "hosts": ["claude-code"], "exact_paths": [".claude/settings.json", ".claude/settings.local.json", ".mcp.json", "CLAUDE.md"], - "globs": ["**/.mcp.json", ".claude/commands/*", ".claude/commands/**", ".claude/skills/*/SKILL.md", ".claude/skills/**/SKILL.md"], + "globs": ["**/.mcp.json", "**/CLAUDE.md", ".claude/commands/*", ".claude/commands/**", ".claude/skills/*/SKILL.md", ".claude/skills/**/SKILL.md"], "experimental": false }, { @@ -53,7 +53,7 @@ "id": "shared", "hosts": ["codex", "claude-code", "cursor"], "exact_paths": ["AGENTS.md", "AGENTS.override.md", "shipgate.yaml", ".shipgate/agent-contract.json", "policies/agent-boundary.shipgate.yaml", "policies/codex-boundary.shipgate.yaml", "policies/host-boundary.shipgate.yaml"], - "globs": [".agents/skills/*/SKILL.md", ".agents/skills/**/SKILL.md", ".github/workflows/*.yml", ".github/workflows/*.yaml", "**/.github/workflows/*.yml", "**/.github/workflows/*.yaml", ".agents-shipgate/baseline*.json", ".agents-shipgate/*waiver*.json", ".agents-shipgate/state*.json", "policies/*.shipgate.yaml"], + "globs": ["**/AGENTS.md", "**/AGENTS.override.md", ".agents/skills/*/SKILL.md", ".agents/skills/**/SKILL.md", ".github/workflows/*.yml", ".github/workflows/*.yaml", "**/.github/workflows/*.yml", "**/.github/workflows/*.yaml", ".agents-shipgate/baseline*.json", ".agents-shipgate/*waiver*.json", ".agents-shipgate/state*.json", "policies/*.shipgate.yaml"], "experimental": false } ], diff --git a/src/agents_shipgate/checks/_verify_common.py b/src/agents_shipgate/checks/_verify_common.py index 174e7ed6..a0b568b1 100644 --- a/src/agents_shipgate/checks/_verify_common.py +++ b/src/agents_shipgate/checks/_verify_common.py @@ -52,8 +52,8 @@ def changed_files(context: ScanContext) -> list[str]: """Normalized, de-duplicated changed-file list from the context. Returns ``[]`` when no verification context is present. Paths are - forward-slash-normalized and stripped so glob matching is stable - across platforms. + forward-slash-normalized without stripping: leading and trailing + whitespace are legal, distinct Git path bytes. """ verification = context.verification if verification is None: @@ -61,7 +61,7 @@ def changed_files(context: ScanContext) -> list[str]: seen: list[str] = [] out: set[str] = set() for raw in getattr(verification, "changed_files", []) or []: - path = str(raw).replace("\\", "/").strip() + path = str(raw).replace("\\", "/") if path and path not in out: out.add(path) seen.append(path) diff --git a/src/agents_shipgate/checks/verify.py b/src/agents_shipgate/checks/verify.py index 5834db40..c9adc1d0 100644 --- a/src/agents_shipgate/checks/verify.py +++ b/src/agents_shipgate/checks/verify.py @@ -32,6 +32,7 @@ PROTECTED_FILE_EDITS, TRUST_ROOT_SURFACES, is_configured_manifest, + is_context_configured_manifest, ) from agents_shipgate.schemas.common import ( SourceReference, @@ -50,7 +51,7 @@ def run(context: ScanContext) -> list[Finding]: findings: list[Finding] = [] seen: set[str] = set() for raw in verification.changed_files: - path = raw.replace("\\", "/").strip() + path = raw.replace("\\", "/") if not path or path in seen: continue seen.add(path) @@ -66,7 +67,7 @@ def run(context: ScanContext) -> list[Finding]: def _classify(path: str) -> tuple[str, str] | None: for trust_root_class, pattern in TRUST_ROOT_SURFACES: - if glob_match(pattern, path): + if glob_match(pattern, path) or glob_match(pattern.casefold(), path.casefold()): return trust_root_class, pattern return None @@ -78,8 +79,7 @@ def _configured_manifest(context: ScanContext, path: str) -> tuple[str, str] | N not called ``shipgate.yaml``. """ - config = getattr(context, "config_path", None) - if not is_configured_manifest(config, path): + if not is_context_configured_manifest(context, path): return None # Deliberately the changed path, not ``config_path``: a committed-head run # loads its manifest from a freshly named ``agents-shipgate-verify-head-*`` diff --git a/src/agents_shipgate/checks/verify_policy.py b/src/agents_shipgate/checks/verify_policy.py index a60341d8..7bde4417 100644 --- a/src/agents_shipgate/checks/verify_policy.py +++ b/src/agents_shipgate/checks/verify_policy.py @@ -39,7 +39,7 @@ verify_finding, ) from agents_shipgate.core.context import ScanContext -from agents_shipgate.core.trust_roots import is_configured_manifest, trust_root_class_for +from agents_shipgate.core.trust_roots import is_context_configured_manifest from agents_shipgate.schemas.report import Finding CHECK_ID = "SHIP-VERIFY-POLICY-WEAKENED" @@ -190,9 +190,8 @@ def _touched_policy_surfaces(context: ScanContext) -> list[str]: """ files = changed_files(context) - config = getattr(context, "config_path", None) hits = set(touched(_POLICY_SURFACES, files)) - hits.update(path for path in files if is_configured_manifest(config, path)) + hits.update(path for path in files if is_context_configured_manifest(context, path)) return sorted(hits) @@ -203,15 +202,14 @@ def _fail_safe(context: ScanContext) -> list[Finding]: return [] verification = context.verification introducing = verification is not None and verification.manifest_introduced - config = getattr(context, "config_path", None) # An adoption that *also* edits an existing policy pack or baseline is not # covered by "nothing existed to weaken": those files were already there. # The friendlier wording is only correct when every touched policy surface # is the manifest being introduced. if introducing and all( - trust_root_class_for(path) == "manifest" or is_configured_manifest(config, path) - for path in hit + is_context_configured_manifest(context, path) for path in hit ): + configured_manifest = verification.configured_manifest_path or hit[0] # A base with no manifest at all cannot have been weakened. This still # emits — at the same check id and severity, so the verdict and every # fail-closed consumer are unchanged — because the human decision @@ -232,8 +230,8 @@ def _fail_safe(context: ScanContext) -> list[Finding]: "This PR introduces the Shipgate manifest rather than " "changing an existing one, so no prior gate was weakened. " "Adopting a release policy is a human decision: review the " - "generated shipgate.yaml and merge it through a " - "human-reviewed PR." + f"configured manifest {configured_manifest!r}; a human " + "must decide whether to adopt it." ), ) ] diff --git a/src/agents_shipgate/cli/_register_init.py b/src/agents_shipgate/cli/_register_init.py index 72a64b82..2eb7ac68 100644 --- a/src/agents_shipgate/cli/_register_init.py +++ b/src/agents_shipgate/cli/_register_init.py @@ -29,6 +29,7 @@ ) from agents_shipgate.cli.discovery.local_contract import LOCAL_CONTRACT_RELATIVE_PATH from agents_shipgate.cli.discovery.placeholders import collect_placeholders +from agents_shipgate.core.errors import DiscoveryError from agents_shipgate.schemas.diagnostics import NextAction @@ -304,7 +305,29 @@ def init( ) next_action_dry = "Inspect the template, then re-run with --write to commit it." else: - detect_result = detect_workspace(workspace_resolved) + try: + detect_result = detect_workspace(workspace_resolved) + except DiscoveryError as exc: + message = ( + "Workspace discovery could not establish bounded coverage: " + f"{exc}" + ) + action = NextAction( + kind="review", + why=message, + expects=( + "Reduce the repository inventory or inspect the Git " + "failure, then rerun init." + ), + ) + typer.echo(message, err=True) + _emit_agent_mode_error( + "other_error", + message=message, + next_action=action.to_legacy_string(), + next_actions=[action.model_dump(mode="json")], + ) + raise typer.Exit(4) from exc template = render_auto_manifest(workspace_resolved, detect_result) # Validation gate: refuse to emit a manifest the schema would reject. try: diff --git a/src/agents_shipgate/cli/_register_scan.py b/src/agents_shipgate/cli/_register_scan.py index 59ab3c98..96efdca5 100644 --- a/src/agents_shipgate/cli/_register_scan.py +++ b/src/agents_shipgate/cli/_register_scan.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import os from pathlib import Path import typer @@ -19,14 +20,46 @@ from agents_shipgate.cli.agent_mode import emit_agent_mode_error as _emit_agent_mode_error from agents_shipgate.cli.diagnostics import top_next_actions from agents_shipgate.cli.scan.orchestrator import run_scan +from agents_shipgate.core.agent_controls import git_root_for from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError from agents_shipgate.core.logging import configure_logging +from agents_shipgate.core.trust_roots import inspect_lexical_path_identity from agents_shipgate.schemas.diagnostics import NextAction from agents_shipgate.schemas.verification import VerificationContext logger = logging.getLogger(__name__) +def _lexical_git_root(path: Path) -> Path | None: + """Find the nearest Git root before any repository-internal symlink. + + Symlinks before the first Git marker are invocation anchors (for example + ``repo-alias -> repo``). Once a Git marker has established the repository, + a later symlink is a mutable path inside that repository and discovery + must not cross it into another checkout. + """ + + selected: Path | None = None + anchors = [*reversed(path.parents), path] + for anchor in anchors: + try: + is_symlink = anchor.is_symlink() + except OSError: + return None + if is_symlink: + if selected is not None: + break + continue + if (anchor / ".git").exists(): + selected = anchor + if selected is None: + return None + try: + return selected.resolve() + except OSError: + return None + + def _build_verification_context( changed_files: Path | None, ) -> VerificationContext | None: @@ -44,10 +77,119 @@ def _build_verification_context( raise ConfigError( f"--changed-files file could not be read: {changed_files} ({exc})" ) from exc - paths = [line.strip() for line in text.splitlines() if line.strip()] + # Preserve exact path spellings. The transport delimiter is the literal + # LF byte only: ``str.splitlines`` would also split legal POSIX filenames + # at VT, FF, NEL, U+2028, and U+2029 and could detach a custom manifest + # identity from its trust-root finding. + paths = [line for line in text.split("\n") if line] return VerificationContext(changed_files=paths, diff_text_available=False) +def _configured_manifest_identity( + *, + config_path: Path, + workspace: Path | None, +) -> tuple[str, Path]: + """Bind the loaded path to one repo-relative manifest identity. + + The returned physical path is the same normalized lexical path inspected + here. The scan must load that path rather than the pre-normalized CLI + spelling: on POSIX, ``link/../gate.yml`` follows ``link`` before applying + ``..``, so inspecting the normalized path but loading the original would + validate one manifest and evaluate another. + """ + + lexical = Path(os.path.abspath(os.path.normpath(os.fspath(config_path)))) + try: + canonical_parent = lexical.parent.resolve() + except OSError: + canonical_parent = lexical.parent + # Resolve this only to choose the comparison root. The identity mapper + # below starts from the first *outer* lexical anchor that resolves inside + # that root, then inspects every repository-relative component without + # following it. That distinction allows `/var` or `repo-alias -> repo` + # while still rejecting `repo/gate-dir -> .`, whose retargeting changes + # which manifest the scan loads. + candidate = canonical_parent / lexical.name + lexical_git_root = _lexical_git_root(lexical.parent) + cwd = Path.cwd().resolve() + if lexical_git_root is not None: + root = lexical_git_root + elif workspace is not None: + workspace_root = workspace.resolve() + root = git_root_for(workspace_root) or workspace_root + elif lexical.is_relative_to(cwd) or candidate.is_relative_to(cwd): + # A caller standing inside a non-Git project still supplies a concrete + # lexical anchor. Prefer it to a Git repository reached only by an + # internal config-path symlink. + root = git_root_for(cwd) or cwd + else: + canonical_git_root = git_root_for(canonical_parent) + if canonical_git_root is None: + raise ConfigError( + "--changed-files paths are repository-relative, but no " + "repository root could be proven for --config " + f"{config_path}. Invoke scan from the repository root or " + "use a Git/workspace-rooted checkout." + ) + # No lexical workspace/cwd root owns the config path, so a Git root + # reached through an external alias is the only remaining provable + # repository anchor. + root = canonical_git_root + + relative: Path | None = None + for anchor in reversed(lexical.parents): + try: + resolved_anchor = anchor.resolve() + anchor_tail = resolved_anchor.relative_to(root) + lexical_tail = lexical.relative_to(anchor) + except (OSError, ValueError): + continue + relative = anchor_tail / lexical_tail + break + if relative is None: + raise ConfigError( + "--config could not be mapped into the changed-files workspace: " + f"{config_path}" + ) + if "\n" in relative.as_posix() or "\r" in relative.as_posix(): + raise ConfigError( + "--config cannot contain a newline when --changed-files uses a " + "line-delimited path transport." + ) + + # Normalize filesystem-proved case/Unicode aliases, but never follow a + # symlink below the repository anchor. There can be more than one alias + # component, so correct one stored entry at a time and inspect again. + for _attempt in range(len(relative.parts) + 1): + issue = inspect_lexical_path_identity(root, relative) + if issue is None: + return relative.as_posix(), lexical + if issue.kind == "symlink": + raise ConfigError( + "--config must not contain repository symlink components: " + f"{issue.requested}" + ) + if issue.kind != "alias" or issue.actual is None: + detail = f": {issue.detail}" if issue.detail else "" + raise ConfigError( + "--config could not be inspected safely: " + f"{issue.requested}{detail}" + ) + requested = root / relative + try: + suffix = requested.relative_to(issue.requested) + relative = (issue.actual / suffix).relative_to(root) + except ValueError as exc: + raise ConfigError( + "--config alias could not be mapped safely: " + f"{issue.requested}" + ) from exc + # The loop is bounded by the number of components. Reaching this point + # means aliases did not converge to one stored path. + raise ConfigError(f"--config identity did not converge safely: {config_path}") + + def register(app: typer.Typer) -> None: @app.command(hidden=True) def scan( @@ -231,6 +373,19 @@ def scan( "against one manifest (-c ) when supplying " "--changed-files." ) + if verification_context is not None and len(config_paths) == 1: + configured_manifest_path, validated_config_path = ( + _configured_manifest_identity( + config_path=config_paths[0], + workspace=workspace, + ) + ) + config_paths[0] = validated_config_path + verification_context = verification_context.model_copy( + update={ + "configured_manifest_path": configured_manifest_path + } + ) if len(config_paths) == 1: report, exit_code = run_scan( config_path=config_paths[0], diff --git a/src/agents_shipgate/cli/agent_mode.py b/src/agents_shipgate/cli/agent_mode.py index 2450e9be..4125597d 100644 --- a/src/agents_shipgate/cli/agent_mode.py +++ b/src/agents_shipgate/cli/agent_mode.py @@ -2,6 +2,7 @@ import json import os +import shlex import sys from collections.abc import Mapping from pathlib import Path @@ -125,4 +126,4 @@ def emit_agent_mode_error( def _command_string() -> str: argv = [Path(sys.argv[0]).name, *sys.argv[1:]] - return " ".join(argv) + return shlex.join(argv) diff --git a/src/agents_shipgate/cli/agent_result.py b/src/agents_shipgate/cli/agent_result.py index e3288a49..d3006e8e 100644 --- a/src/agents_shipgate/cli/agent_result.py +++ b/src/agents_shipgate/cli/agent_result.py @@ -1,21 +1,38 @@ from __future__ import annotations import json +import os +import stat from pathlib import Path, PurePosixPath from typing import Any -from agents_shipgate.config.loader import load_manifest +from agents_shipgate.cli.verify.git import ( + carries_manifest_like_yaml, + read_file_at_ref, + removes_a_yaml_file, +) +from agents_shipgate.config.loader import load_manifest_text from agents_shipgate.core.agent_boundary import ( build_agent_boundary_result as project_agent_boundary_result, ) from agents_shipgate.core.agent_boundary import ( evaluate_agent_boundary, ) -from agents_shipgate.core.boundary_diff import BoundaryChangeSet, BoundaryInputIssue +from agents_shipgate.core.agent_controls import git_root_for +from agents_shipgate.core.boundary_diff import ( + BoundaryChangeSet, + BoundaryInputIssue, + git_diff_path_token, +) from agents_shipgate.core.boundary_registry import is_agent_boundary_path from agents_shipgate.core.codex_boundary import parse_unified_diff -from agents_shipgate.core.errors import InputParseError -from agents_shipgate.core.trust_roots import is_configured_manifest +from agents_shipgate.core.errors import ConfigError, InputParseError +from agents_shipgate.core.trust_roots import ( + inspect_lexical_path_identity, + is_configured_manifest, + read_identity_bound_text, + trust_root_class_for, +) from agents_shipgate.inputs.codex_plugin import resolve_local_codex_marketplace_roots from agents_shipgate.schemas.agent_boundary import AgentBoundaryResultV1 from agents_shipgate.schemas.agent_result import AgentResultV2 @@ -29,6 +46,8 @@ ) from agents_shipgate.triggers import evaluate as evaluate_trigger +_MANIFEST_SNAPSHOT_UNSET = object() + def build_codex_agent_result( *, @@ -38,6 +57,14 @@ def build_codex_agent_result( config: Path, policy: Path | None, input_issues: list[BoundaryInputIssue] | None = None, + input_mode: str = "provided_diff", + base: str | None = None, + head: str | None = None, + verification_replayable: bool | None = None, + base_manifest_absent: bool | None = None, + requested_workspace: Path | None = None, + changed_files_override: list[str] | None = None, + manifest_text_snapshot: str | None | object = _MANIFEST_SNAPSHOT_UNSET, ) -> CodexBoundaryResultV2: """Frozen v2 compatibility projection from the central assessment.""" @@ -47,8 +74,15 @@ def build_codex_agent_result( diff_text=diff_text, config=config, policy=policy, - input_mode="provided_diff", + input_mode=input_mode, input_issues=input_issues, + base=base, + head=head, + verification_replayable=verification_replayable, + base_manifest_absent=base_manifest_absent, + requested_workspace=requested_workspace, + changed_files_override=changed_files_override, + manifest_text_snapshot=manifest_text_snapshot, ).legacy_result @@ -61,6 +95,13 @@ def build_agent_boundary_result( policy: Path | None, input_mode: str = "provided_diff", input_issues: list[BoundaryInputIssue] | None = None, + base: str | None = None, + head: str | None = None, + verification_replayable: bool | None = None, + base_manifest_absent: bool | None = None, + requested_workspace: Path | None = None, + changed_files_override: list[str] | None = None, + manifest_text_snapshot: str | None | object = _MANIFEST_SNAPSHOT_UNSET, ) -> AgentBoundaryResultV1: assessment = _assessment_for_diff( agent=agent, @@ -70,6 +111,13 @@ def build_agent_boundary_result( policy=policy, input_mode=input_mode, input_issues=input_issues, + base=base, + head=head, + verification_replayable=verification_replayable, + base_manifest_absent=base_manifest_absent, + requested_workspace=requested_workspace, + changed_files_override=changed_files_override, + manifest_text_snapshot=manifest_text_snapshot, ) return project_agent_boundary_result(assessment) @@ -83,13 +131,91 @@ def _assessment_for_diff( policy: Path | None, input_mode: str, input_issues: list[BoundaryInputIssue] | None, + base: str | None, + head: str | None, + verification_replayable: bool | None, + base_manifest_absent: bool | None, + requested_workspace: Path | None, + changed_files_override: list[str] | None, + manifest_text_snapshot: str | None | object, ): - workspace_as_given = workspace + workspace_as_given = requested_workspace or workspace workspace = workspace.resolve() diff_files = parse_unified_diff(diff_text) - changed_files = sorted({item.path for item in diff_files if item.path}) - config_path = config if config.is_absolute() else workspace / config - manifest_present = config_path.is_file() + changed_files = sorted( + { + *(changed_files_override or []), + *( + path + for item in diff_files + for path in (item.new_path, item.old_path) + if path + ), + } + ) + config_path = _lexical_config_path( + workspace, + config, + requested_workspace=workspace_as_given, + ) + manifest_text = ( + _read_manifest_snapshot(workspace, config_path) + if manifest_text_snapshot is _MANIFEST_SNAPSHOT_UNSET + else manifest_text_snapshot + ) + assert manifest_text is None or isinstance(manifest_text, str) + manifest_present = manifest_text is not None + manifest: AgentsShipgateManifest | None = None + if manifest_text is not None: + try: + manifest = load_manifest_text(manifest_text, source=config_path) + except ConfigError: + # Invalid manifest content remains present trigger context, but it + # cannot safely contribute declarations. + manifest = None + if verification_replayable is None: + verification_replayable = input_mode != "provided_diff" + adoption_candidate = any( + item.is_new + and not item.is_deleted + and not item.is_rename + and item.new_path + and ( + trust_root_class_for(item.new_path.replace("\\", "/")) == "manifest" + or is_configured_manifest( + config_path, + item.new_path, + workspace=workspace, + ) + ) + for item in diff_files + ) + if base_manifest_absent is None and adoption_candidate: + comparison_ref = ( + base + if input_mode == "git_range" and base + else "HEAD" + if input_mode == "worktree" + else None + ) + if comparison_ref is not None: + no_manifest = ( + carries_manifest_like_yaml( + workspace, + comparison_ref, + protected_names=frozenset({"shipgate.yaml"}), + ) + is False + ) + no_yaml_removed = ( + removes_a_yaml_file( + workspace, + base if input_mode == "git_range" else None, + head if input_mode == "git_range" and head else "HEAD", + ) + is False + ) + base_manifest_absent = no_manifest and no_yaml_removed trigger = evaluate_trigger( paths=changed_files, diff_text=diff_text, @@ -98,6 +224,7 @@ def _assessment_for_diff( ) declared = _declared_tool_surfaces_changed( workspace=workspace, + manifest=manifest, config_path=config_path, changed_files=changed_files, ) @@ -114,10 +241,91 @@ def _assessment_for_diff( changed_files=changed_files, declared=declared, ), + changed_files_override=changed_files, input_mode=input_mode, # type: ignore[arg-type] input_issues=input_issues, config_path=config_path, requested_workspace=workspace_as_given, + base=base, + head=head, + verification_replayable=verification_replayable, + base_manifest_absent=base_manifest_absent, + ) + + +def _lexical_config_path( + workspace: Path, + config: Path, + *, + requested_workspace: Path | None = None, +) -> Path: + """Keep local-control manifest identity exact without breaking absence recovery.""" + + requested_anchor = ( + requested_workspace.resolve() + if requested_workspace is not None + else workspace + ) + candidate = config if config.is_absolute() else requested_anchor / config + if config.is_absolute() and requested_workspace is not None: + lexical_workspace = Path( + os.path.abspath(os.path.normpath(os.fspath(requested_workspace))) + ) + lexical_config = Path(os.path.normpath(os.fspath(config))) + try: + requested_tail = lexical_config.relative_to(lexical_workspace) + except ValueError: + pass + else: + # An invocation may enter the workspace through an external + # symlink or filesystem alias. Map only the proven tail onto the + # canonical workspace; repository-internal links remain visible to + # the component inspector below and are rejected. + candidate = requested_anchor / requested_tail + lexical = Path(os.path.normpath(os.fspath(candidate))) + try: + relative = lexical.relative_to(workspace) + except ValueError as exc: + raise ConfigError(f"--config must be inside --workspace: {config}") from exc + + issue = inspect_lexical_path_identity(workspace, relative) + if issue is None: + # The shared inspector deliberately permits missing suffix components; + # the existing unconfigured-repository preview route handles them. + try: + metadata = lexical.lstat() + except FileNotFoundError: + return lexical + except OSError as exc: + raise ConfigError(f"--config could not be inspected safely: {config}") from exc + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ConfigError( + "--config must identify one singly-linked regular file; " + f"non-regular or hardlinked manifest refused: {config}" + ) + return lexical + try: + requested = issue.requested.relative_to(workspace).as_posix() + except ValueError: + requested = issue.requested.as_posix() + if issue.kind == "symlink": + raise ConfigError( + f"--config must not contain symlink components: {requested}" + ) + if issue.kind == "alias": + if issue.actual is None: + actual = "a differently spelled filesystem entry" + else: + try: + actual = issue.actual.relative_to(workspace).as_posix() + except ValueError: + actual = issue.actual.as_posix() + raise ConfigError( + "--config must use the exact filesystem spelling: " + f"{requested} resolves to {actual}" + ) + raise ConfigError( + f"--config could not be inspected safely: {requested}: {issue.detail}" ) @@ -209,6 +417,7 @@ def _is_non_deployed_fixture_path(path: str) -> bool: def _declared_tool_surfaces_changed( *, workspace: Path, + manifest: AgentsShipgateManifest | None, config_path: Path, changed_files: list[str], ) -> list[str]: @@ -232,11 +441,7 @@ def _declared_tool_surfaces_changed( inspects are dropped, since ``check`` did evaluate those. """ - if not changed_files or not config_path.is_file(): - return [] - try: - manifest = load_manifest(config_path) - except Exception: # noqa: BLE001 - enrichment must never break the check. + if not changed_files or manifest is None: return [] manifest_dir = config_path.parent declared: set[str] = set() @@ -317,7 +522,15 @@ def git_boundary_change_set( ``--config new-gate.yml`` produced an empty change set and ``allow``. """ - workspace = workspace.resolve() + requested_workspace = workspace + repository_root = git_root_for(workspace) + workspace = repository_root or workspace.resolve() + if config_path is not None: + config_path = _lexical_config_path( + workspace, + config_path, + requested_workspace=requested_workspace, + ) if bool(base) != bool(head): raise RuntimeError( "--base and --head must be provided together; omit both to check " @@ -327,12 +540,50 @@ def git_boundary_change_set( revspec = f"{base}...{head}" else: revspec = "" + if repository_root is None: + raise RuntimeError("Workspace is not inside a Git repository.") try: changed_paths, diff_text = _git_diff_context(revspec, cwd=workspace) + except ConfigError as exc: + # Preserve trust-root/index-identity failures so ``shipgate check`` + # keeps the typed exit-2 contract instead of degrading them into the + # generic diff-unavailable recovery path (which exits successfully). + if ( + "Git index flags hide paths from worktree collection" in str(exc) + or "refuses Git filter attributes" in str(exc) + or "repository-local diff" in str(exc) + ): + raise + raise RuntimeError(str(exc) or "git diff failed") from exc except Exception as exc: # noqa: BLE001 - normalize git probe failures for CLI. raise RuntimeError(str(exc) or "git diff failed") from exc + changed_paths = sorted( + { + *changed_paths, + *( + path + for item in parse_unified_diff(diff_text) + for path in (item.new_path, item.old_path) + if path + ), + } + ) issues: list[BoundaryInputIssue] = [] - if not revspec: + manifest_text_snapshot: str | None = None + if revspec and config_path is not None: + try: + relative = config_path.relative_to(workspace) + manifest_text_snapshot = read_file_at_ref(workspace, head, relative) + except (OSError, RuntimeError, ValueError) as exc: + raise ConfigError( + f"Configured manifest could not be read from head ref {head!r}: {exc}" + ) from exc + elif not revspec: + changed_paths, manifest_text_snapshot = _bind_worktree_config_to_head( + workspace=workspace, + config_path=config_path, + changed_paths=changed_paths, + ) diff_text, issues = _append_untracked_diffs( workspace=workspace, changed_paths=changed_paths, @@ -346,9 +597,45 @@ def git_boundary_change_set( diff_text=diff_text, changed_paths=tuple(sorted(changed_paths)), issues=tuple(issues), + manifest_text_snapshot=manifest_text_snapshot, ) +def _bind_worktree_config_to_head( + *, + workspace: Path, + config_path: Path | None, + changed_paths: list[str], +) -> tuple[list[str], str | None]: + """Keep ignored/index-hidden manifest bytes inside local check context.""" + + if config_path is None: + return changed_paths, None + try: + relative = config_path.relative_to(workspace) + except ValueError as exc: + raise ConfigError( + f"--config must be inside --workspace: {config_path}" + ) from exc + try: + worktree_text = read_identity_bound_text( + workspace, + relative, + max_bytes=_MAX_MANIFEST_BYTES, + ) + head_text = read_file_at_ref(workspace, "HEAD", relative) + except FileNotFoundError: + return changed_paths, None + except (OSError, UnicodeDecodeError, ValueError) as exc: + raise ConfigError( + f"Configured manifest {relative.as_posix()} could not be bound " + f"to HEAD: {exc}" + ) from exc + if worktree_text == head_text: + return changed_paths, worktree_text + return sorted({*changed_paths, relative.as_posix()}), worktree_text + + def agent_result_json_payload(result: AgentResultV2) -> dict[str, Any]: payload = result.model_dump(mode="json", exclude_none=True) payload["control"] = result.control.model_dump(mode="json") @@ -361,6 +648,29 @@ def agent_result_json(result: AgentResultV2) -> str: _MAX_BOUNDARY_FILE_BYTES = 128 * 1024 _MAX_UNTRACKED_BYTES = 1024 * 1024 +_MAX_MANIFEST_BYTES = 4 * 1024 * 1024 + + +def _read_manifest_snapshot(workspace: Path, config_path: Path) -> str | None: + try: + relative = config_path.relative_to(workspace) + except ValueError as exc: + raise ConfigError( + f"--config must be inside --workspace: {config_path}" + ) from exc + try: + return read_identity_bound_text( + workspace, + relative, + max_bytes=_MAX_MANIFEST_BYTES, + ) + except FileNotFoundError: + return None + except (OSError, UnicodeDecodeError, ValueError) as exc: + raise ConfigError( + f"Configured manifest {relative.as_posix()} could not be read " + f"through an identity-bound snapshot: {exc}" + ) from exc def _append_untracked_diffs( @@ -370,34 +680,55 @@ def _append_untracked_diffs( diff_text: str, config_path: Path | None = None, ) -> tuple[str, list[BoundaryInputIssue]]: - represented = {item.path for item in parse_unified_diff(diff_text) if item.path} + represented = { + path + for item in parse_unified_diff(diff_text) + for path in (item.new_path, item.old_path) + if path + } appended: list[str] = [] issues: list[BoundaryInputIssue] = [] consumed = 0 for path in sorted(set(changed_paths) - represented): - if not _potential_boundary_or_capability_path(path, config_path): + if not _potential_boundary_or_capability_path( + path, + config_path, + workspace=workspace, + ): + continue + if any(marker in path for marker in ("\x00", "\n", "\r")): + issues.append( + BoundaryInputIssue( + code="boundary_path_not_diff_representable", + path=path, + message=( + "A recognized boundary path contains a control character " + "that cannot be represented safely in a unified diff." + ), + ) + ) continue - candidate = workspace / path text: str | None = None issue_code: str | None = None - if not _safe_untracked_file(workspace, candidate): - issue_code = "boundary_input_symlink_or_external" - else: - try: - size = candidate.stat().st_size - if size > _MAX_BOUNDARY_FILE_BYTES or consumed + size > _MAX_UNTRACKED_BYTES: - issue_code = "boundary_input_oversized" - else: - raw = candidate.read_bytes() - if b"\x00" in raw: - issue_code = "boundary_input_binary" - else: - text = raw.decode("utf-8") - consumed += size - except UnicodeDecodeError: - issue_code = "boundary_input_non_utf8" - except OSError: - issue_code = "boundary_input_unreadable" + raw, read_issue = _read_exact_untracked_file( + workspace, + Path(path), + max_bytes=min( + _MAX_BOUNDARY_FILE_BYTES, + _MAX_UNTRACKED_BYTES - consumed, + ), + ) + if read_issue is not None: + issue_code = read_issue + elif raw is not None: + if b"\x00" in raw: + issue_code = "boundary_input_binary" + else: + try: + text = raw.decode("utf-8") + consumed += len(raw) + except UnicodeDecodeError: + issue_code = "boundary_input_non_utf8" if issue_code: issues.append( BoundaryInputIssue( @@ -412,7 +743,12 @@ def _append_untracked_diffs( appended.append(_new_file_diff(path, "")) continue assert text is not None - if not _is_relevant_untracked(path, text, config_path): + if not _is_relevant_untracked( + path, + text, + config_path, + workspace=workspace, + ): continue appended.append(_new_file_diff(path, text)) if not appended: @@ -423,21 +759,83 @@ def _append_untracked_diffs( ) -def _safe_untracked_file(workspace: Path, candidate: Path) -> bool: +def _read_exact_untracked_file( + workspace: Path, + relative: Path, + *, + max_bytes: int, +) -> tuple[bytes | None, str | None]: + """Read one untracked source through a stable, non-aliased descriptor.""" + + if relative.is_absolute() or ".." in relative.parts or max_bytes < 0: + return None, "boundary_input_symlink_or_external" + issue = inspect_lexical_path_identity(workspace, relative) + if issue is not None: + return None, "boundary_input_symlink_or_external" + flags = ( + os.O_RDONLY + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + descriptors: list[int] = [] try: - relative = candidate.relative_to(workspace) - except ValueError: - return False - current = workspace - for part in relative.parts: - current = current / part - if current.is_symlink(): - return False + if os.open in os.supports_dir_fd: + directory_flags = ( + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + current = os.open(workspace, directory_flags) + descriptors.append(current) + for component in relative.parts[:-1]: + current = os.open(component, directory_flags, dir_fd=current) + descriptors.append(current) + descriptor = os.open(relative.name, flags, dir_fd=current) + else: + descriptor = os.open(workspace / relative, flags) + descriptors.append(descriptor) + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1: + return None, "boundary_input_symlink_or_external" + if opened.st_size > max_bytes: + return None, "boundary_input_oversized" + raw = bytearray() + while chunk := os.read(descriptor, min(64 * 1024, max_bytes + 1 - len(raw))): + raw.extend(chunk) + if len(raw) > max_bytes: + return None, "boundary_input_oversized" + after = os.fstat(descriptor) + except (OSError, NotImplementedError): + return None, "boundary_input_unreadable" + finally: + for descriptor_to_close in reversed(descriptors): + try: + os.close(descriptor_to_close) + except OSError: + pass try: - candidate.resolve().relative_to(workspace) - except (OSError, ValueError): - return False - return candidate.is_file() + current_metadata = (workspace / relative).lstat() + except OSError: + return None, "boundary_input_unreadable" + if ( + _stat_identity(opened) != _stat_identity(after) + or _stat_identity(opened) != _stat_identity(current_metadata) + or inspect_lexical_path_identity(workspace, relative) is not None + ): + return None, "boundary_input_unreadable" + return bytes(raw), None + + +def _stat_identity(metadata: os.stat_result) -> tuple[int, ...]: + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_nlink, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) def _new_file_diff(path: str, text: str) -> str: @@ -445,18 +843,30 @@ def _new_file_diff(path: str, text: str) -> str: body = "\n".join(f"+{line}" for line in lines) hunk = f"@@ -0,0 +1,{len(lines)} @@\n{body}\n" if lines else "@@ -0,0 +0,0 @@\n" return ( - f"diff --git a/{path} b/{path}\n" + "diff --git " + f"{git_diff_path_token('a/', path)} {git_diff_path_token('b/', path)}\n" "new file mode 100644\n" "--- /dev/null\n" - f"+++ b/{path}\n" + f"+++ {git_diff_path_token('b/', path)}\n" f"{hunk}" ) def _potential_boundary_or_capability_path( - path: str, config_path: Path | None = None + path: str, + config_path: Path | None = None, + *, + workspace: Path | None = None, ) -> bool: - if is_agent_boundary_path(path) or is_configured_manifest(config_path, path): + if ( + is_agent_boundary_path(path) + or trust_root_class_for(path) is not None + or is_configured_manifest( + config_path, + path, + workspace=workspace, + ) + ): return True result = evaluate_trigger( paths=[path], @@ -468,9 +878,21 @@ def _potential_boundary_or_capability_path( def _is_relevant_untracked( - path: str, text: str, config_path: Path | None = None + path: str, + text: str, + config_path: Path | None = None, + *, + workspace: Path | None = None, ) -> bool: - if is_agent_boundary_path(path) or is_configured_manifest(config_path, path): + if ( + is_agent_boundary_path(path) + or trust_root_class_for(path) is not None + or is_configured_manifest( + config_path, + path, + workspace=workspace, + ) + ): return True result = evaluate_trigger( paths=[path], diff --git a/src/agents_shipgate/cli/apply_patches.py b/src/agents_shipgate/cli/apply_patches.py index 6e2414f0..3cbbb0a0 100644 --- a/src/agents_shipgate/cli/apply_patches.py +++ b/src/agents_shipgate/cli/apply_patches.py @@ -62,6 +62,14 @@ def apply_patches( "manual explicitly." ), ), + finding_ids: list[str] | None = typer.Option( + None, + "--finding-id", + help=( + "Apply patches only from this exact finding id. May be repeated. " + "Every requested id must exist; omission keeps the legacy all-findings behavior." + ), + ), apply: bool = typer.Option( False, "--apply", @@ -130,8 +138,27 @@ def apply_patches( raise typer.Exit(5) manifest_dir_resolved = Path(manifest_dir).resolve() + findings = report.get("findings", []) + requested_ids = set(finding_ids or []) + available_ids = { + str(finding.get("id")) + for finding in findings + if isinstance(finding, dict) and finding.get("id") + } + missing_ids = sorted(requested_ids - available_ids) + if missing_ids: + message = ( + "Requested finding id(s) are absent from the report: " + + ", ".join(missing_ids) + ) + typer.echo(message, err=True) + _emit_malformed_patch_error(from_path, message) + raise typer.Exit(2) + raw_patches: list[dict[str, Any]] = [] - for finding in report.get("findings", []): + for finding in findings: + if requested_ids and str(finding.get("id")) not in requested_ids: + continue for patch in finding.get("patches") or []: raw_patches.append(patch) diff --git a/src/agents_shipgate/cli/check.py b/src/agents_shipgate/cli/check.py index bdfc4b1f..7af53e01 100644 --- a/src/agents_shipgate/cli/check.py +++ b/src/agents_shipgate/cli/check.py @@ -1,7 +1,9 @@ from __future__ import annotations +import hashlib +import json +import os import shlex -import sys from pathlib import Path import typer @@ -16,20 +18,28 @@ build_codex_agent_result, git_boundary_change_set, ) +from agents_shipgate.cli.verify.git import commit_sha from agents_shipgate.core.agent_control import derive_agent_control -from agents_shipgate.schemas.agent_boundary import AgentBoundaryResultV1 +from agents_shipgate.core.agent_controls import git_root_for +from agents_shipgate.core.bounded_io import ( + MAX_EXPLICIT_DIFF_BYTES, + read_bounded_utf8_file, + read_bounded_utf8_stdin, +) +from agents_shipgate.core.errors import ConfigError, InputParseError +from agents_shipgate.schemas.agent_boundary import ( + AGENT_BOUNDARY_RESULT_SCHEMA_VERSION, + AgentBoundaryResultV1, +) from agents_shipgate.schemas.agent_control import ( - CodingAgentCommandAction, + CodingAgentFetchBaseAction, HumanControlAction, ) -from agents_shipgate.schemas.codex_boundary_result import CodexBoundaryResultV2 -from agents_shipgate.schemas.diagnostics import NextAction - -_AMBIGUOUS_DIFF_INPUT_WHY = ( - "The failing check request cannot be replayed as-is — its diff came from " - "stdin, or only one of --base/--head was given. Decide the intended input " - "and re-run the check yourself." +from agents_shipgate.schemas.codex_boundary_result import ( + CODEX_BOUNDARY_RESULT_SCHEMA_VERSION, + CodexBoundaryResultV2, ) +from agents_shipgate.schemas.diagnostics import NextAction def _corrected_request( @@ -76,13 +86,13 @@ def _corrected_request( parts = ["agents-shipgate", "check"] if agent in {"codex", "claude-code", "cursor"}: parts.extend(["--agent", agent]) - parts.extend(["--workspace", shlex.quote(str(workspace))]) + parts.extend(["--workspace", shlex.quote(_cwd_anchored(workspace))]) parts.extend(["--config", shlex.quote(str(config))]) if policy is not None: parts.extend(["--policy", shlex.quote(str(policy))]) if has_diff: assert diff is not None - parts.extend(["--diff", shlex.quote(diff)]) + parts.extend(["--diff", shlex.quote(_cwd_anchored(Path(diff)))]) elif has_base and has_head: assert base is not None and head is not None parts.extend(["--base", shlex.quote(base), "--head", shlex.quote(head)]) @@ -91,6 +101,17 @@ def _corrected_request( return " ".join(parts) +def _cwd_anchored(path: Path) -> str: + """Return an absolute lexical spelling without resolving symlinks. + + Generated commands must replay from any working directory, while a path + such as ``link/../input.diff`` must retain its original traversal + semantics. ``Path.resolve``/``abspath`` would silently change that subject. + """ + + return str(path if path.is_absolute() else Path.cwd() / path) + + def _flag_error(message: str, *, command: str | None, expects: str) -> typer.Exit: """Report flag misuse on both channels and return the exit to raise.""" @@ -163,7 +184,8 @@ def check( # The actor lands in the result and in the audit id, so an undetected # harness mislabels every row it writes. An explicit flag always wins. - agent = agent or detect_actor() + if agent is None: + agent = detect_actor() def corrected(*, valid_agent: str | None) -> str | None: return _corrected_request( @@ -197,11 +219,27 @@ def corrected(*, valid_agent: str | None) -> str | None: command=None, expects="Both non-empty refs, or neither for local worktree changes.", ) + if any( + value is not None + and ( + value.startswith("-") + or any(char in value for char in "\0\r\n") + ) + for value in (base, head) + ): + raise _flag_error( + "--base and --head must not begin with '-' or contain control delimiters.", + command=None, + expects="Choose exact, option-safe Git refs and rerun the request.", + ) if agent not in {"codex", "claude-code", "cursor"}: raise _flag_error( "--agent must be one of: codex, claude-code, cursor.", - command=corrected(valid_agent=detect_actor()), - expects="An --agent value the boundary evaluator recognizes.", + command=None, + expects=( + "Choose the intended caller identity explicitly; an unknown " + "--agent value cannot be corrected without changing attribution." + ), ) if format_ == "agent-json": raise _flag_error( @@ -218,10 +256,20 @@ def corrected(*, valid_agent: str | None) -> str | None: ) try: input_issues = [] + changed_files_override: list[str] | None = None + manifest_text_snapshot: str | None = None + manifest_snapshot_captured = False if diff == "-": - diff_text = sys.stdin.read() + diff_text = read_bounded_utf8_stdin( + max_bytes=MAX_EXPLICIT_DIFF_BYTES, + label="Diff input", + ) elif diff: - diff_text = Path(diff).read_text(encoding="utf-8") + diff_text = read_bounded_utf8_file( + Path(diff), + max_bytes=MAX_EXPLICIT_DIFF_BYTES, + label="Diff input", + ) else: change_set = git_boundary_change_set( workspace=workspace, @@ -231,6 +279,49 @@ def corrected(*, valid_agent: str | None) -> str | None: ) diff_text = change_set.diff_text input_issues = list(change_set.issues) + changed_files_override = list(change_set.changed_paths) + manifest_text_snapshot = change_set.manifest_text_snapshot + manifest_snapshot_captured = True + except InputParseError as exc: + if isinstance(exc.__cause__, FileNotFoundError): + result = _diff_input_error_result( + agent=agent, + workspace=workspace, + config=config, + policy=policy, + format_=format_, + diff=diff, + base=base, + head=head, + error_class=type(exc.__cause__).__name__, + error=str(exc), + ) + if format_ == "agent-boundary-json": + result = _neutral_diff_input_error( + result, + agent=agent, + base=base, + diff=diff, + ) + typer.echo(agent_result_json(result)) + return + raise _flag_error( + str(exc), + command=None, + expects=( + "Resolve the exact deterministic Git/diff or manifest-identity " + "failure reported by this check before rerunning it." + ), + ) from exc + except ConfigError as exc: + raise _flag_error( + str(exc), + command=None, + expects=( + "Resolve the exact deterministic Git/diff or manifest-identity " + "failure reported by this check before rerunning it." + ), + ) from exc except (OSError, RuntimeError) as exc: result = _diff_input_error_result( agent=agent, @@ -241,6 +332,7 @@ def corrected(*, valid_agent: str | None) -> str | None: diff=diff, base=base, head=head, + error_class=type(exc).__name__, error=str(exc) or "diff input could not be resolved", ) if format_ == "agent-boundary-json": @@ -255,15 +347,38 @@ def corrected(*, valid_agent: str | None) -> str | None: ) kwargs = { "agent": agent, - "workspace": workspace, + "workspace": git_root_for(workspace) or workspace.resolve(), + "requested_workspace": workspace, "diff_text": diff_text, "config": config, - "policy": policy, + "policy": ( + policy + if policy is None or policy.is_absolute() + else workspace.resolve() / policy + ), "input_issues": input_issues, + "base": base, + "head": head, + "input_mode": "provided_diff" if diff else "git_range" if base else "worktree", + # A standalone diff can describe either side of a change, but verify + # accepts a checkout or a ref range. Do not authorize a worktree verify + # for a subject the check cannot bind to repository state. + "verification_replayable": diff is None, + "changed_files_override": changed_files_override, } - if format_ == "agent-boundary-json": - kwargs["input_mode"] = "provided_diff" if diff else "git_range" if base else "worktree" - result = builder(**kwargs) + if manifest_snapshot_captured: + kwargs["manifest_text_snapshot"] = manifest_text_snapshot + try: + result = builder(**kwargs) + except ConfigError as exc: + raise _flag_error( + str(exc), + command=None, + expects=( + "A configured manifest path that uses its exact stored " + "filesystem spelling and contains no symlink components." + ), + ) from exc typer.echo(agent_result_json(result)) @@ -277,19 +392,56 @@ def _diff_input_error_result( diff: str | None, base: str | None, head: str | None, + error_class: str, error: str, ) -> CodexBoundaryResultV2: - command = _corrected_request( - agent=agent, - workspace=workspace, - config=config, - policy=policy, - diff=diff, - base=base, - head=head, - format_=format_, - ) summary = "Agents Shipgate could not resolve the diff input for local agent control." + if diff is not None: + route_why = ( + f"Review or restore the requested diff artifact {diff!r}; it could not " + "be read, so rerunning the same check request cannot repair the input." + ) + next_action: CodingAgentFetchBaseAction | HumanControlAction = HumanControlAction( + kind="review", + why=route_why, + ) + repair_actor = "human" + elif base is not None and head is not None: + root = git_root_for(workspace) + refs_available = ( + root is not None + and commit_sha(root, base) is not None + and commit_sha(root, head) is not None + ) + if refs_available: + route_why = ( + "Both requested refs are already available, so fetching cannot " + f"repair the diff-collection failure: {error}. Review the " + "reported deterministic Git input/configuration issue." + ) + next_action = HumanControlAction(kind="review", why=route_why) + repair_actor = "human" + else: + expected_refs = f"{base} and {head}" + route_why = ( + f"Make both requested git refs available in workspace {workspace} before " + "rerunning the check; the failed check does not fetch refs itself." + ) + next_action = CodingAgentFetchBaseAction( + kind="fetch_base", + expects=expected_refs, + why=route_why, + ) + repair_actor = "coding_agent" + else: + route_why = ( + f"Review workspace {workspace} and restore a readable git worktree " + "before rerunning the check; repeating the failed request cannot repair it." + ) + next_action = HumanControlAction(kind="review", why=route_why) + repair_actor = "human" + + human_route = isinstance(next_action, HumanControlAction) return CodexBoundaryResultV2( agent=agent, subject={ @@ -301,44 +453,42 @@ def _diff_input_error_result( }, decision="block", risk_level="medium", - audit_id="agent_check_diff_input_error", + audit_id=_diff_input_error_audit_id( + agent=agent, + workspace=workspace, + config=config, + policy=policy, + output_schema=( + AGENT_BOUNDARY_RESULT_SCHEMA_VERSION + if format_ == "agent-boundary-json" + else CODEX_BOUNDARY_RESULT_SCHEMA_VERSION + ), + diff=diff, + base=base, + head=head, + error_class=error_class, + ), policy_version="unresolved", summary=summary, changed_files=[], - control=( - derive_agent_control( - reason=summary, - next_action=CodingAgentCommandAction( - kind="repair", - command=command, - why=( - "Fix the diff input, make the requested git refs available, or omit " - "--base/--head for local uncommitted changes; then rerun shipgate check." - ), - ), - allowed_next_commands=[command], - ) - if command - else derive_agent_control( - reason=summary, - next_action=HumanControlAction( - kind="review", - why=_AMBIGUOUS_DIFF_INPUT_WHY, - ), - human_review_required=True, - human_review_why=_AMBIGUOUS_DIFF_INPUT_WHY, - stop_reason=_AMBIGUOUS_DIFF_INPUT_WHY, - ) + control=derive_agent_control( + reason=summary, + next_action=next_action, + human_review_required=human_route, + human_review_why=route_why if human_route else None, + stop_reason=route_why if human_route else None, ), repair={ - "actor": "coding_agent" if command else "human", - "safe_to_attempt": bool(command), + "actor": repair_actor, + # A fetch request or human review route has no exact executable + # repair. Keep the compatibility field false instead of claiming + # that replaying the failed check can fix its own missing input. + "safe_to_attempt": False, "instructions": [ f"Resolve diff input error: {error}", - "Provide both --base and --head for committed refs, or omit both for local work.", - "If --diff names a file, make sure the file exists and contains a unified diff.", + route_why, + "Rerun the original check only after the requested input is available.", ], - **({"command": command} if command else {}), "forbidden_shortcuts": [ "Do not claim completion without a successful shipgate check rerun.", "Do not infer a Shipgate decision from prose or a failed command.", @@ -367,6 +517,62 @@ def _diff_input_error_result( ) +def _diff_input_error_audit_id( + *, + agent: str, + workspace: Path, + config: Path, + policy: Path | None, + output_schema: str, + diff: str | None, + base: str | None, + head: str | None, + error_class: str, +) -> str: + """Stable, actor- and target-bound identity for a failed check input.""" + + try: + target = str(workspace.resolve()) + except OSError: + target = str(workspace) + input_kind = ( + "provided_diff" + if diff is not None + else "git_range" + if base is not None or head is not None + else "worktree" + ) + requested_workspace = Path(_cwd_anchored(workspace)) + config_identity = ( + config if config.is_absolute() else requested_workspace / config + ) + policy_identity = ( + None + if policy is None + else policy if policy.is_absolute() else requested_workspace / policy + ) + diff_identity = ( + None if diff is None else _cwd_anchored(Path(diff)) + ) + payload = { + "schema": output_schema, + "kind": "diff_input_error", + "actor": agent, + "workspace": target, + "config": os.fspath(config_identity), + "policy": os.fspath(policy_identity) if policy_identity is not None else None, + "input_kind": input_kind, + "diff": diff_identity, + "base": base, + "head": head, + "error_class": error_class, + } + digest = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest()[:24] + return f"agent_boundary_error_{digest}" + + def _neutral_diff_input_error( legacy: CodexBoundaryResultV2, *, diff --git a/src/agents_shipgate/cli/detect.py b/src/agents_shipgate/cli/detect.py index cef6627a..f7aa4c7f 100644 --- a/src/agents_shipgate/cli/detect.py +++ b/src/agents_shipgate/cli/detect.py @@ -16,12 +16,14 @@ import typer +from agents_shipgate.cli.agent_mode import emit_agent_mode_error from agents_shipgate.cli.diagnostics import ( diagnose_detect, top_next_actions, ) from agents_shipgate.cli.discovery import detect_workspace -from agents_shipgate.schemas.diagnostics import Diagnostic +from agents_shipgate.core.errors import DiscoveryError +from agents_shipgate.schemas.diagnostics import Diagnostic, NextAction def detect( @@ -44,7 +46,29 @@ def detect( ) -> None: """Classify a workspace: which agent framework(s), if any.""" workspace_resolved = workspace.resolve() - result = detect_workspace(workspace_resolved, max_python_files=max_python_files) + try: + result = detect_workspace( + workspace_resolved, + max_python_files=max_python_files, + ) + except DiscoveryError as exc: + message = f"Workspace discovery could not establish bounded coverage: {exc}" + action = NextAction( + kind="review", + why=message, + expects=( + "Reduce the repository inventory or inspect the Git failure, " + "then rerun detect." + ), + ) + typer.echo(message, err=True) + emit_agent_mode_error( + "other_error", + message=message, + next_action=action.to_legacy_string(), + next_actions=[action.model_dump(mode="json")], + ) + raise typer.Exit(4) from exc has_manifest = (workspace_resolved / "shipgate.yaml").is_file() diagnostics: list[Diagnostic] = diagnose_detect( result, has_manifest=has_manifest, workspace=workspace_resolved diff --git a/src/agents_shipgate/cli/discovery/artifacts.py b/src/agents_shipgate/cli/discovery/artifacts.py index 1c8efaa0..06627f42 100644 --- a/src/agents_shipgate/cli/discovery/artifacts.py +++ b/src/agents_shipgate/cli/discovery/artifacts.py @@ -4,8 +4,11 @@ import json import os import subprocess +import threading from pathlib import Path +from agents_shipgate.core.errors import DiscoveryError + OPENAPI_PATTERNS = ( "*openapi*.yaml", "*openapi*.yml", @@ -470,11 +473,36 @@ def _candidate_files(workspace: Path) -> list[Path]: def _git_candidate_files(workspace: Path) -> list[Path] | None: + env = { + key: value for key, value in os.environ.items() if not key.startswith("GIT_") + } + env.update( + { + "GIT_ATTR_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_NO_LAZY_FETCH": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "GIT_PAGER": "cat", + "GIT_PROTOCOL_FROM_USER": "0", + "GIT_TERMINAL_PROMPT": "0", + } + ) try: root_result = subprocess.run( - ["git", "-C", str(workspace), "rev-parse", "--show-toplevel"], + [ + "git", + "--no-replace-objects", + "-C", + str(workspace), + "rev-parse", + "--show-toplevel", + ], check=False, capture_output=True, + env=env, text=True, timeout=2, ) @@ -486,12 +514,15 @@ def _git_candidate_files(workspace: Path) -> list[Path] | None: if not git_root: return None - try: - files_result = subprocess.run( - [ - "git", - "-C", - str(workspace), + files_output = _run_git_inventory_bounded( + workspace, + [ + "-c", + "core.fsmonitor=false", + "-c", + "submodule.recurse=false", + "-c", + "core.quotePath=false", "ls-files", "-co", "--exclude-standard", @@ -499,18 +530,18 @@ def _git_candidate_files(workspace: Path) -> list[Path] | None: "-z", "--", ".", - ], - check=False, - capture_output=True, - timeout=5, + ], + env=env, + max_output_bytes=16 * 1024 * 1024, + ) + if files_output is None: + raise DiscoveryError( + "Git candidate-file inventory exceeded static output bounds or " + "could not be collected safely." ) - except (OSError, subprocess.SubprocessError): - return None - if files_result.returncode != 0: - return None candidates: list[Path] = [] - for raw_rel in files_result.stdout.split(b"\0"): + for raw_rel in files_output.split(b"\0"): if not raw_rel: continue try: @@ -532,6 +563,61 @@ def _git_candidate_files(workspace: Path) -> list[Path] | None: return sorted(candidates) +def _run_git_inventory_bounded( + workspace: Path, + args: list[str], + *, + env: dict[str, str], + max_output_bytes: int, +) -> bytes | None: + """Collect Git discovery paths without buffering unbounded output.""" + + try: + process = subprocess.Popen( + ["git", "--no-replace-objects", "-C", str(workspace), *args], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except OSError: + return None + output = bytearray() + exceeded = False + failed = False + + def drain() -> None: + nonlocal exceeded, failed + assert process.stdout is not None + try: + while chunk := process.stdout.read(64 * 1024): + remaining = max_output_bytes + 1 - len(output) + if remaining > 0: + output.extend(chunk[:remaining]) + if len(output) > max_output_bytes: + exceeded = True + try: + process.kill() + except OSError: + pass + return + except OSError: + failed = True + + reader = threading.Thread(target=drain, daemon=True) + reader.start() + try: + returncode = process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + reader.join() + return None + reader.join() + if returncode != 0 or exceeded or failed: + return None + return bytes(output) + + def _walk_candidate_files(workspace: Path) -> list[Path]: candidates: list[Path] = [] workspace = workspace.resolve() diff --git a/src/agents_shipgate/cli/host_audit.py b/src/agents_shipgate/cli/host_audit.py index ae5df7bc..36b2f52b 100644 --- a/src/agents_shipgate/cli/host_audit.py +++ b/src/agents_shipgate/cli/host_audit.py @@ -2,6 +2,7 @@ from __future__ import annotations +import errno import json import os import shlex @@ -25,6 +26,7 @@ host_grants_sha256, inventory_is_complete, load_host_grants_baseline, + load_host_grants_baseline_with_text, normalized_host_grants, redacted_config_sha256, render_host_audit_markdown, @@ -58,7 +60,7 @@ def _config_error( message: str, *, next_action: str, - command: str | None = "agents-shipgate audit --host", + command: str | None = None, ) -> typer.Exit: """Report flag misuse on both channels and return the exit to raise. @@ -89,6 +91,53 @@ def _config_error( return typer.Exit(2) +def _audit_recovery_command( + *, + workspace: Path, + host: bool, + scope: str, + save_baseline: bool, + drift: bool, + baseline_file: Path, + fail_on_drift: bool, + json_output: bool, + out: Path | None, +) -> str: + """Serialize one complete, shell-safe host-audit request. + + Recovery actions are an operational control surface: dropping a target or + mode flag can make an agent inspect a different workspace or answer a + different question. Keep every effective request field, including output + options, and let callers explicitly alter only the field they are fixing. + """ + + exact_workspace = Path( + os.path.abspath(os.path.normpath(os.fspath(workspace))) + ) + argv = ["agents-shipgate", "audit"] + if host: + argv.append("--host") + argv.extend(("--workspace", str(exact_workspace), "--scope", scope)) + if save_baseline: + argv.append("--save-baseline") + if drift: + argv.append("--drift") + if save_baseline or drift or baseline_file != DEFAULT_BASELINE_FILE: + argv.extend(("--baseline-file", str(baseline_file))) + if fail_on_drift: + argv.append("--fail-on-drift") + if json_output: + argv.append("--json") + if out is not None: + exact_out = ( + out + if out.is_absolute() + else Path(os.path.abspath(os.path.normpath(os.fspath(Path.cwd() / out)))) + ) + argv.extend(("--out", str(exact_out))) + return shlex.join(argv) + + def audit( workspace: Path = typer.Option( Path("."), @@ -145,25 +194,58 @@ def audit( """Zero-config, read-only audits. Currently supports --host.""" if not host: + command = ( + _audit_recovery_command( + workspace=workspace, + host=True, + scope=scope, + save_baseline=save_baseline, + drift=drift, + baseline_file=baseline_file, + fail_on_drift=fail_on_drift, + json_output=json_output, + out=out, + ) + if ( + scope in {"repository", "local-static"} + and not (save_baseline and drift) + and not (fail_on_drift and not drift) + ) + else None + ) raise _config_error( "Nothing to audit: pass --host for the host-capability inventory.", next_action="Re-run as `agents-shipgate audit --host`.", + command=command, ) if scope not in {"repository", "local-static"}: raise _config_error( "--scope must be 'repository' or 'local-static'.", - next_action="Re-run audit with --scope repository or --scope local-static.", + next_action=( + "Choose whether this request needs repository-only or local-static " + "host coverage, then re-run it with that scope." + ), + command=None, ) if save_baseline and drift: raise _config_error( "--save-baseline and --drift are mutually exclusive: record the " "acknowledged state or compare against it, not both.", - next_action="Re-run audit with either --save-baseline or --drift, not both.", + next_action=( + "Choose whether to acknowledge the current grants or compare them " + "with the baseline, then re-run the original request with exactly " + "one of --save-baseline or --drift." + ), + command=None, ) if fail_on_drift and not drift: raise _config_error( "--fail-on-drift requires --drift.", - next_action="Re-run audit with --drift, or drop --fail-on-drift.", + next_action=( + "Choose whether to run a drift gate or an advisory inventory, then " + "re-run the original request with compatible flags." + ), + command=None, ) inventory_scope = "local_static" if scope == "local-static" else "repository" @@ -179,16 +261,16 @@ def audit( workspace=workspace, baseline_file=baseline_file, ) + _reject_out_baseline_alias(out, write_target) existing = _refuse_invalid_baseline_overwrite(write_target) try: payload = build_host_grants_baseline(inventory) except ValueError as exc: + coverage_review = _incomplete_inventory_review(inventory) raise _config_error( str(exc), - next_action=( - "Resolve the incomplete host inventory, then re-run " - "`agents-shipgate audit --host --save-baseline`." - ), + next_action=coverage_review, + command=None, ) from exc text = json.dumps(payload, indent=2, sort_keys=True) + "\n" try: @@ -217,6 +299,7 @@ def audit( "scope": payload["scope"], "status": status, } + _reject_out_baseline_alias(out, write_target) _write_json_out(out, outcome) if json_output: typer.echo(json.dumps(outcome, indent=2, sort_keys=True)) @@ -229,6 +312,7 @@ def audit( return if drift: + _reject_out_baseline_alias(out, resolved_baseline) try: baseline = load_host_grants_baseline(resolved_baseline) except ValueError as exc: @@ -254,9 +338,11 @@ def audit( # schema, integrity-failed — must never be recovered by writing # over it. Recommending --save-baseline there replaced the failed # artifact with the *current* grants, silently acknowledging them - # and destroying the evidence a human needed to look at. Only a - # genuinely absent baseline can be recorded without losing - # anything, and that command carries the scope it was asked for. + # and destroying the evidence a human needed to look at. + # A genuinely absent baseline is distinguishable from a malformed + # one, but recording it still acknowledges the current grants. + # The failed read-only drift request therefore routes to a human + # instead of authorizing a state-changing save command. missing = isinstance( exc.__cause__, FileNotFoundError ) and not resolved_baseline.is_symlink() @@ -278,21 +364,16 @@ def audit( ) ), next_action=( - "Record the first baseline for this exact workspace, " - "scope, and target." + "Human review is required before creating the first acknowledged " + f"host-grants baseline at {baseline_file}. Review the current " + f"{scope} inventory for workspace {workspace}, then record the " + "baseline only in a separate, explicitly approved request." if missing else "Inspect the existing baseline file and repair or " "replace it deliberately; do not overwrite it with the " "current grants, which would acknowledge them unreviewed." ), - command=( - "agents-shipgate audit --host --workspace " - f"{shlex.quote(str(workspace))} --scope {shlex.quote(scope)} " - f"--save-baseline --baseline-file " - f"{shlex.quote(str(baseline_file))}" - if missing - else None - ), + command=None, ) from exc payload = build_host_drift_payload( baseline=baseline, @@ -319,6 +400,45 @@ def audit( typer.echo(render_host_audit_markdown(inventory), nl=False) +def _incomplete_inventory_review(inventory: dict[str, object]) -> str: + """Describe the exact coverage gap without authorizing baseline acceptance.""" + + coverage_rows = inventory.get("host_coverage") + incomplete: list[str] = [] + if isinstance(coverage_rows, list): + for row in coverage_rows: + if not isinstance(row, dict) or row.get("status") == "complete": + continue + host = str(row.get("host") or "unknown-host") + status = str(row.get("status") or "unknown") + issue_ids = [ + str(value) + for value in (row.get("issue_ids") or []) + if isinstance(value, str) + ] + suffix = f" ({', '.join(issue_ids)})" if issue_ids else "" + incomplete.append(f"{host}={status}{suffix}") + + issue_rows = inventory.get("issues") + sources: list[str] = [] + if isinstance(issue_rows, list): + for issue in issue_rows: + if not isinstance(issue, dict) or not issue.get("blocking"): + continue + host = str(issue.get("host") or "unknown-host") + source = str(issue.get("source") or "unknown-source") + sources.append(f"{host}:{source}") + + coverage_text = ", ".join(incomplete) or "coverage status unavailable" + source_text = ", ".join(sources) or "no blocking source was identified" + return ( + "Human review is required before acknowledging this inventory. Repair " + f"the incomplete coverage ({coverage_text}); blocking sources: " + f"{source_text}. Inspect a fresh host inventory after repair, then make " + "a separate explicit baseline-acknowledgement decision." + ) + + def _baseline_write_target(*, workspace: Path, baseline_file: Path) -> Path: workspace_root = workspace.resolve() raw_target = ( @@ -343,8 +463,21 @@ def _baseline_write_target(*, workspace: Path, baseline_file: Path) -> Path: def _reject_baseline_symlinks(target: Path) -> None: try: redirected = target.resolve() != target - except (OSError, RuntimeError) as exc: + except RuntimeError as exc: raise _unsafe_baseline_path(target, f"could not resolve the path: {exc}") from exc + except OSError as exc: + if exc.errno == errno.ELOOP: + raise _unsafe_baseline_path( + target, + f"the path contains a symbolic-link loop: {exc}", + ) from exc + raise _io_error( + f"Could not inspect host-grants baseline path {target}: {exc}", + next_action=( + "Inspect the baseline path and its parent permissions, then re-run " + "the audit." + ), + ) from exc if redirected: raise _unsafe_baseline_path(target, "the path contains a symbolic link") @@ -360,10 +493,12 @@ def _unsafe_baseline_path(path: Path, reason: str) -> typer.Exit: ) -def _file_identity(value: os.stat_result) -> tuple[int, int, int, int, int, int]: +def _file_identity( + value: os.stat_result, +) -> tuple[int, int, int, int, int, int, int]: return ( value.st_dev, value.st_ino, value.st_size, - value.st_mtime_ns, value.st_nlink, value.st_mode, + value.st_mtime_ns, value.st_ctime_ns, value.st_nlink, value.st_mode, ) @@ -375,9 +510,17 @@ def _refuse_invalid_baseline_overwrite( except FileNotFoundError: return None except OSError as exc: - raise _unsafe_baseline_path( - baseline_file, - f"could not inspect the existing target: {exc}", + if exc.errno == errno.ELOOP: + raise _unsafe_baseline_path( + baseline_file, + f"the path contains a symbolic-link loop: {exc}", + ) from exc + raise _io_error( + f"Could not inspect existing host-grants baseline {baseline_file}: {exc}", + next_action=( + "Inspect the baseline path and its permissions, then re-run the " + "audit." + ), ) from exc if stat.S_ISDIR(before.st_mode): return None @@ -387,8 +530,22 @@ def _refuse_invalid_baseline_overwrite( "the existing target is not a single-link regular file", ) try: - baseline = load_host_grants_baseline(baseline_file) + baseline, text = load_host_grants_baseline_with_text(baseline_file) except ValueError as exc: + if isinstance(exc.__cause__, OSError): + if exc.__cause__.errno == errno.ELOOP: + raise _unsafe_baseline_path( + baseline_file, + f"the path contains a symbolic-link loop: {exc.__cause__}", + ) from exc + raise _io_error( + f"Could not read existing host-grants baseline {baseline_file}: " + f"{exc.__cause__}", + next_action=( + "Inspect the baseline file and its permissions, then re-run " + "the audit." + ), + ) from exc raise _config_error( _existing_baseline_error_message(exc, baseline_file=baseline_file), next_action=INCOMPARABLE_BASELINE_REVIEW, @@ -406,12 +563,19 @@ def _refuse_invalid_baseline_overwrite( command=None, ) try: - text = baseline_file.read_text(encoding="utf-8") after = baseline_file.lstat() except OSError as exc: - raise _unsafe_baseline_path( - baseline_file, - f"the existing target changed or became unreadable: {exc}", + if exc.errno == errno.ELOOP: + raise _unsafe_baseline_path( + baseline_file, + f"the path contains a symbolic-link loop: {exc}", + ) from exc + raise _io_error( + f"Could not finish inspecting host-grants baseline {baseline_file}: {exc}", + next_action=( + "Inspect the baseline file and its permissions, then re-run the " + "audit." + ), ) from exc if _file_identity(before) != _file_identity(after): raise _unsafe_baseline_path( @@ -536,11 +700,24 @@ def _existing_baseline_error_message(exc: ValueError, *, baseline_file: Path) -> def _write_json_out(out: Path | None, payload: dict) -> None: if out is None: return + temp_path: Path | None = None try: out.parent.mkdir(parents=True, exist_ok=True) - out.write_text( - json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" - ) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=out.parent, + prefix=f".{out.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + temp_path = Path(handle.name) + # Replacing the directory entry does not follow a last-moment symlink + # or hardlink at ``out`` and therefore cannot write through it into + # baseline evidence. + os.replace(temp_path, out) + temp_path = None except OSError as exc: # An unwritable --out reached the user as a Rich traceback and exit 1, # which is neither the documented exit code nor something an agent can @@ -551,6 +728,39 @@ def _write_json_out(out: Path | None, payload: dict) -> None: "Point --out at a writable file path, then re-run the audit." ), ) from exc + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +def _reject_out_baseline_alias(out: Path | None, baseline: Path) -> None: + """Keep report output from truncating acknowledged baseline evidence.""" + + if out is None: + return + output = Path(os.path.abspath(os.path.normpath(os.fspath(out)))) + baseline_path = Path( + os.path.abspath(os.path.normpath(os.fspath(baseline))) + ) + aliases = output == baseline_path + try: + aliases = aliases or output.resolve() == baseline_path.resolve() + except (OSError, RuntimeError): + pass + if not aliases: + try: + aliases = os.path.samestat(output.stat(), baseline_path.stat()) + except OSError: + pass + if aliases: + raise _config_error( + f"--out must not alias --baseline-file ({baseline}).", + next_action=( + "Choose a distinct output path so audit reporting cannot " + "overwrite acknowledged baseline evidence." + ), + command=None, + ) __all__ = [ diff --git a/src/agents_shipgate/cli/install_hooks.py b/src/agents_shipgate/cli/install_hooks.py index b8f47cd5..ff13a734 100644 --- a/src/agents_shipgate/cli/install_hooks.py +++ b/src/agents_shipgate/cli/install_hooks.py @@ -2,6 +2,7 @@ import json import os +import stat import tempfile from dataclasses import dataclass from pathlib import Path @@ -11,6 +12,7 @@ from agents_shipgate.checks.verify import TRUST_ROOT_SURFACES from agents_shipgate.core.errors import ConfigError +from agents_shipgate.core.trust_roots import inspect_lexical_path_identity SETTINGS_RELATIVE_PATH = Path(".claude/settings.json") HOOK_SCRIPT_RELATIVE_PATH = Path(".claude/hooks/agents-shipgate.py") @@ -137,7 +139,18 @@ def render_or_install_hooks( ) if ci_mode not in {"advisory", "strict"}: raise ConfigError("--ci-mode must be advisory or strict") + for label, value in (("--base", base), ("--head", head)): + if value and not _safe_ref_token(value): + raise ConfigError( + f"{label} must not begin with '-' or contain control delimiters" + ) + if head and not base: + raise ConfigError( + "--head requires --base for installed hooks; omit --head for " + "working-tree verification" + ) workspace = workspace.resolve() + config_relative = _exact_hook_config(workspace, config) settings_path = workspace / SETTINGS_RELATIVE_PATH script_path = workspace / HOOK_SCRIPT_RELATIVE_PATH _ensure_repo_local_write(settings_path, workspace) @@ -146,7 +159,7 @@ def render_or_install_hooks( existing_settings = _read_settings(settings_path) rendered_settings = _merge_claude_settings( existing_settings, - config=config.as_posix(), + config=config_relative, base=base, head=head, ci_mode=ci_mode, @@ -195,6 +208,33 @@ def render_or_install_hooks( ) +def _exact_hook_config(workspace: Path, config: Path) -> str: + """Return one stable repository-relative manifest identity for the hook.""" + + raw = config if config.is_absolute() else workspace / config + candidate = Path(os.path.abspath(os.path.normpath(os.fspath(raw)))) + try: + relative = candidate.relative_to(workspace) + except ValueError as exc: + raise ConfigError("--config must stay inside --workspace") from exc + issue = inspect_lexical_path_identity(workspace, relative) + if issue is not None: + raise ConfigError( + "--config must use one exact non-symlink filesystem identity" + ) + try: + metadata = candidate.lstat() + except FileNotFoundError: + return relative.as_posix() + except OSError as exc: + raise ConfigError("--config could not be inspected safely") from exc + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ConfigError( + "--config must identify one singly-linked regular file" + ) + return relative.as_posix() + + def _read_settings(path: Path) -> dict[str, Any]: if not path.exists(): return {} @@ -209,6 +249,12 @@ def _read_settings(path: Path) -> dict[str, Any]: return data +def _safe_ref_token(value: str) -> bool: + return bool(value) and not value.startswith("-") and not any( + char in value for char in "\0\r\n" + ) + + def _merge_claude_settings( settings: dict[str, Any], *, @@ -425,17 +471,46 @@ def _hook_script_text() -> str: import json import os import shlex +import stat import subprocess import sys import tempfile +import threading from pathlib import Path from typing import Any VERIFY_TIMEOUT_SECONDS = 170 UNTRACKED_DIFF_CONTENT_LIMIT_BYTES = 131072 +GIT_PATH_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024 +GIT_DIFF_OUTPUT_LIMIT_BYTES = 32 * 1024 * 1024 _MAX_REMEMBERED_SURFACES = 256 _MAX_REMEMBERED_SESSIONS = 8 +_SAFE_DIFF_CONFIG = [ + "-c", "core.fsmonitor=false", + "-c", "core.autocrlf=false", + "-c", "core.safecrlf=false", + "-c", "core.eol=lf", + "-c", "core.bigFileThreshold=32m", + "-c", "core.fileMode=false", + "-c", "core.precomposeUnicode=false", + "-c", "submodule.recurse=false", + "-c", "core.quotePath=false", +] +_DETERMINISTIC_DIFF_OPTIONS = [ + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=dirty", + "--no-color", + "--diff-algorithm=myers", + "--no-indent-heuristic", + "--unified=3", + "--src-prefix=a/", + "--dst-prefix=b/", + "--find-renames=50%", + "--submodule=short", + "--full-index", +] # Host permission modes that answer a hook's permission request without asking # a human. An edit that lands under one of these is not evidence that anyone # saw a prompt, so it must never seed the approval memory. Every other mode @@ -462,13 +537,17 @@ def main() -> int: payload = _read_payload() root = _project_root(payload) if args.mode == "pretooluse": - return _pretooluse(payload, root) + return _pretooluse(payload, root, args) if args.mode == "trigger": return _trigger(payload, root, args) return _verify(payload, root, args) -def _pretooluse(payload: dict[str, Any], root: Path) -> int: +def _pretooluse( + payload: dict[str, Any], + root: Path, + args: argparse.Namespace, +) -> int: """Surface the authority boundary BEFORE a protected file is edited. When the agent is about to Edit/Write a trust-root surface (the @@ -495,7 +574,10 @@ def _pretooluse(payload: dict[str, Any], root: Path) -> int: contained = _contained_repo_paths(payload, root) matched: list[tuple[str, str, str]] = [] for path in _changed_paths(payload, root): - hit = _protected_surface_for(path) + hit = _protected_surface_for(path, configured_manifest=args.config) + alias_kind = _unsafe_alias_kind(root, path) + if hit is None and alias_kind is not None: + hit = ("path_identity", alias_kind) if hit is None: continue # Only a path proven to be inside this repository can be matched @@ -533,9 +615,20 @@ def _pretooluse(payload: dict[str, Any], root: Path) -> int: return 0 -def _protected_surface_for(path: str) -> tuple[str, str] | None: +def _protected_surface_for( + path: str, + *, + configured_manifest: str | None = None, +) -> tuple[str, str] | None: + normalized = path.replace("\\", "/") + if configured_manifest: + configured = configured_manifest.replace("\\", "/").removeprefix("./") + if normalized == configured or normalized.casefold() == configured.casefold(): + return "manifest", configured for kind, pattern in PROTECTED_SURFACES: - if _glob_match(pattern, path): + if _glob_match(pattern, path) or _glob_match( + pattern.casefold(), path.casefold() + ): return kind, pattern return None @@ -607,8 +700,15 @@ def _trigger(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> i paths = _changed_paths(payload, root) if not paths: return 0 - _record_in_session_approvals(payload, root) + _record_in_session_approvals(payload, root, args) diff_text = _git_diff_for_paths(root, paths) + if diff_text is None: + return _emit_context( + "PostToolUse", + "Agents Shipgate could not inspect the edited source text through " + "the repository's static Git diff boundary. Before finishing, run " + f"`{_manual_verify_command(args, root=root, worktree=True)}` manually.", + ) # For edit-time nudges, evaluate path relevance without the opted-in # manifest force-run rule. CI still runs every PR for opted-in repos. result = _run_trigger_for_paths( @@ -617,13 +717,22 @@ def _trigger(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> i diff_text=diff_text, manifest_present=False, ) - if result is None or not result.get("should_run"): + protected = any( + _protected_surface_for(path, configured_manifest=args.config) is not None + for path in paths + ) + if result is None and not protected: + return 0 + if not protected and not result.get("should_run"): return 0 path_preview = ", ".join(paths[:3]) - rationale = result.get("rationale") or "Shipgate trigger matched." + rationale = ( + (result or {}).get("rationale") + or "A configured protected surface changed." + ) if (root / args.config).is_file(): - command = _manual_verify_command(args, root=root) + command = _manual_verify_command(args, root=root, worktree=True) else: command = "AGENTS_SHIPGATE_AGENT_MODE=1 agents-shipgate verify --preview --json" return _emit_context( @@ -638,7 +747,11 @@ def _trigger(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> i ) -def _record_in_session_approvals(payload: dict[str, Any], root: Path) -> None: +def _record_in_session_approvals( + payload: dict[str, Any], + root: Path, + args: argparse.Namespace, +) -> None: """Note protected files whose edit the human just allowed. PostToolUse only fires once the tool call went through, so when the host @@ -665,7 +778,7 @@ def _record_in_session_approvals(payload: dict[str, Any], root: Path) -> None: protected = [ path for path in _contained_repo_paths(payload, root) - if _protected_surface_for(path) is not None + if _protected_surface_for(path, configured_manifest=args.config) is not None ] if protected: _remember_approved_surfaces(root, str(payload.get("session_id") or ""), protected) @@ -699,6 +812,22 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in config_path = root / args.config stop_hook_active = bool(payload.get("stop_hook_active")) snapshot = _change_snapshot(root, args) + if snapshot["kind"] == "unavailable": + if snapshot.get("reason") == "base_ref_unavailable": + return _emit_context( + "Stop", + "Agents Shipgate could not determine the committed change set " + "because the configured base ref is unavailable locally. Fetch " + "the base ref, then rerun the hook or run " + f"`{_manual_verify_command(args, root=root)}` manually.", + ) + return _emit_context( + "Stop", + "Agents Shipgate could not collect a bounded, static worktree " + "snapshot. Commit the intended changes, then run the ref-bound " + "verifier manually; the hook will not execute repository-configured " + "filters or trust incomplete Git output.", + ) if not config_path.is_file(): if not snapshot["paths"]: return 0 @@ -708,12 +837,17 @@ 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"): + protected = any( + _protected_surface_for(path, configured_manifest=args.config) is not None + for path in snapshot["paths"] + ) + if protected or (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. " + "Agents Shipgate trigger matched, but the configured manifest " + f"{args.config!r} does not exist. " "Run `agents-shipgate verify --preview --json` and initialize " "the manifest if this workspace contains an agent.", ) @@ -728,14 +862,18 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in diff_text=snapshot["diff_text"], manifest_present=False, ) - if trigger is None: + protected = any( + _protected_surface_for(path, configured_manifest=args.config) is not None + for path in snapshot["paths"] + ) + if trigger is None and not protected: return _emit_context( "Stop", "Agents Shipgate hook could not evaluate the local trigger. Hooks are " "advisory; before finishing an agent-related diff, run " - f"`{_manual_verify_command(args, root=root)}` manually.", + f"`{_manual_verify_command(args, root=root, worktree=snapshot['kind'] == 'worktree')}` manually.", ) - if not trigger.get("should_run"): + if not protected and not trigger.get("should_run"): return 0 signature = str(snapshot["signature"]) @@ -745,6 +883,12 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in base = os.environ.get("AGENTS_SHIPGATE_VERIFY_BASE", args.base).strip() head = os.environ.get("AGENTS_SHIPGATE_VERIFY_HEAD", args.head).strip() ci_mode = os.environ.get("AGENTS_SHIPGATE_VERIFY_CI_MODE", args.ci_mode) + if (base and not _safe_ref_token(base)) or (head and not _safe_ref_token(head)): + return _emit_context( + "Stop", + "Agents Shipgate refused an option-like or control-delimited Git " + "ref. Set explicit, option-safe base/head refs before verification.", + ) base_note = "" if base and not _ref_exists(root, base): base_note = ( @@ -763,7 +907,7 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in ] if base: command.extend(["--base", base]) - if head: + if head and snapshot["kind"] != "worktree": command.extend(["--head", head]) command.extend(["--ci-mode", ci_mode, "--format", "json"]) env = {**os.environ, "AGENTS_SHIPGATE_AGENT_MODE": "1"} @@ -798,7 +942,7 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in "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 " + f"`{_manual_verify_command(args, root=root, worktree=snapshot['kind'] == 'worktree')}` manually and read " "`agents-shipgate-reports/report.json`.", ) @@ -809,6 +953,7 @@ def _verify(payload: dict[str, Any], root: Path, args: argparse.Namespace) -> in signature=signature, base_note=base_note, stop_hook_active=stop_hook_active, + worktree=snapshot["kind"] == "worktree", ) @@ -820,6 +965,7 @@ def _route_verify_result( signature: str, base_note: str, stop_hook_active: bool, + worktree: bool, ) -> int: decision = ((verifier.get("release_decision") or {}).get("decision") or "unknown") blockers = len((verifier.get("release_decision") or {}).get("blockers") or []) @@ -846,8 +992,31 @@ def _route_verify_result( 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) + action_kind = next_action.get("kind") + command = next_action.get("command") + allowed_commands = control.get("allowed_next_commands") + allowed_commands = ( + allowed_commands + if isinstance(allowed_commands, list) + and all(isinstance(item, str) for item in allowed_commands) + else [] + ) why = next_action.get("why") or "One coding-agent action remains." + if action_kind == "fetch_base" and not command: + expects = next_action.get("expects") or "the requested Git ref" + return _emit_stop_block( + f"Agents Shipgate verify ran before completion: {summary}.{base_note} " + f"Make {expects!r} available locally, then rerun the verifier. " + f"{why} No executable command was authorized by the control result." + ) + if not isinstance(command, str) or command not in allowed_commands: + return _emit_context( + "Stop", + "Agents Shipgate returned agent_action_required without one exact " + "authorized command. Do not invent or replay a fallback command; " + "inspect control.next_action and allowed_next_commands, then route " + "the malformed handoff to human review.", + ) return _emit_stop_block( f"Agents Shipgate verify ran before completion: {summary}.{base_note} " f"One exact coding-agent action remains before finishing: run `{command}`. " @@ -876,7 +1045,12 @@ def _route_verify_result( ) -def _manual_verify_command(args: argparse.Namespace, *, root: Path | None = None) -> str: +def _manual_verify_command( + args: argparse.Namespace, + *, + root: Path | None = None, + worktree: bool = False, +) -> str: parts = [ "AGENTS_SHIPGATE_AGENT_MODE=1", "agents-shipgate", @@ -888,7 +1062,7 @@ def _manual_verify_command(args: argparse.Namespace, *, root: Path | None = None ] if args.base and (root is None or _ref_exists(root, args.base)): parts.extend(["--base", args.base]) - if args.head: + if args.head and not worktree: parts.extend(["--head", args.head]) parts.extend(["--ci-mode", args.ci_mode, "--format", "json"]) return " ".join(shlex.quote(str(part)) for part in parts) @@ -901,6 +1075,8 @@ def _run_trigger_for_paths( diff_text: str, manifest_present: bool, ) -> dict[str, Any] | None: + if any(not _safe_changed_path_transport(path) for path in paths): + return None with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: for path in paths: handle.write(path + "\n") @@ -954,42 +1130,247 @@ def _run( def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str] | None: - return _run(["git", "-C", str(root), *args], cwd=root, timeout=20) + return _run( + ["git", "--no-replace-objects", "-C", str(root), *args], + cwd=root, + timeout=20, + env=_git_environment(), + ) + + +def _git_environment() -> dict[str, str]: + env = { + key: value + for key, value in os.environ.items() + if not key.startswith("GIT_") + } + env.update( + { + "GIT_ALLOW_PROTOCOL": "", + "GIT_ATTR_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_NO_LAZY_FETCH": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "GIT_PAGER": "cat", + "GIT_PROTOCOL_FROM_USER": "0", + "GIT_TERMINAL_PROMPT": "0", + } + ) + return env + + +def _run_git_bounded( + root: Path, + args: list[str], + *, + limit: int = 1024 * 1024, +) -> bytes | None: + process: subprocess.Popen[bytes] | None = None + try: + process = subprocess.Popen( + ["git", "--no-replace-objects", "-C", str(root), *args], + cwd=root, + env=_git_environment(), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except OSError: + return None + + output = bytearray() + exceeded = False + read_failed = False + + def _drain() -> None: + nonlocal exceeded, read_failed + assert process is not None and process.stdout is not None + try: + while chunk := process.stdout.read(64 * 1024): + remaining = limit + 1 - len(output) + if remaining > 0: + output.extend(chunk[:remaining]) + if len(output) > limit: + exceeded = True + try: + process.kill() + except OSError: + pass + return + except OSError: + read_failed = True + + reader = threading.Thread(target=_drain, daemon=True) + reader.start() + try: + returncode = process.wait(timeout=20) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + reader.join() + return None + reader.join() + if returncode != 0 or exceeded or read_failed: + return None + return bytes(output) def _ref_exists(root: Path, ref: str) -> bool: - completed = _run_git(root, ["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"]) + if not _safe_ref_token(ref): + return False + completed = _run_git( + root, + [ + "rev-parse", + "--verify", + "--quiet", + "--end-of-options", + f"{ref}^{{commit}}", + ], + ) return completed is not None and completed.returncode == 0 +def _is_git_repository(root: Path) -> bool: + completed = _run_git(root, ["rev-parse", "--is-inside-work-tree"]) + return ( + completed is not None + and completed.returncode == 0 + and completed.stdout.strip() == "true" + ) + + +def _commit_for_ref(root: Path, ref: str) -> str | None: + if not _safe_ref_token(ref): + return None + completed = _run_git( + root, + [ + "rev-parse", + "--verify", + "--quiet", + "--end-of-options", + f"{ref}^{{commit}}", + ], + ) + if completed is None or completed.returncode != 0: + return None + return completed.stdout.strip() or None + + def _change_snapshot(root: Path, args: argparse.Namespace) -> dict[str, Any]: - paths = _worktree_changed_paths(root) + if _has_executable_worktree_filter(root): + return { + "kind": "unavailable", + "paths": [], + "diff_text": "", + "signature": "", + } + paths = _worktree_changed_paths(root, configured_manifest=args.config) + if paths is None: + return { + "kind": "unavailable", + "paths": [], + "diff_text": "", + "signature": "", + } + if any(not _safe_changed_path_transport(path) for path in paths): + return { + "kind": "unavailable", + "paths": [], + "diff_text": "", + "signature": "", + } if paths: diff_text = _worktree_diff(root, paths) - return _snapshot("worktree", paths, diff_text, args) + if diff_text is None: + return { + "kind": "unavailable", + "paths": [], + "diff_text": "", + "signature": "", + } + return _snapshot(root, "worktree", paths, diff_text, args) - head = args.head or "HEAD" - if args.base and _ref_exists(root, args.base) and _ref_exists(root, head): - diff = _diff_context(root, f"{args.base}...{head}") + base = os.environ.get("AGENTS_SHIPGATE_VERIFY_BASE", args.base).strip() + configured_head = os.environ.get( + "AGENTS_SHIPGATE_VERIFY_HEAD", args.head + ).strip() + if configured_head and not base: + return { + "kind": "unavailable", + "reason": "head_without_base", + "paths": [], + "diff_text": "", + "signature": "", + } + head = configured_head or "HEAD" + if base: + if not _ref_exists(root, base) or not _ref_exists(root, head): + return { + "kind": "unavailable", + "reason": "base_ref_unavailable", + "paths": [], + "diff_text": "", + "signature": "", + } + diff = _diff_context(root, f"{base}...{head}") if diff is not None: commit_paths, diff_text = diff if commit_paths: - return _snapshot("commit", commit_paths, diff_text, args) + if any( + not _safe_changed_path_transport(path) + for path in commit_paths + ): + return { + "kind": "unavailable", + "paths": [], + "diff_text": "", + "signature": "", + } + return _snapshot(root, "commit", commit_paths, diff_text, args) + else: + return { + "kind": "unavailable", + "reason": "commit_diff_unavailable", + "paths": [], + "diff_text": "", + "signature": "", + } return {"kind": "none", "paths": [], "diff_text": "", "signature": ""} def _snapshot( + root: Path, kind: str, paths: list[str], diff_text: str, args: argparse.Namespace, ) -> dict[str, Any]: paths = sorted({path for path in paths if path}) + effective_base = os.environ.get( + "AGENTS_SHIPGATE_VERIFY_BASE", args.base + ).strip() + effective_head = os.environ.get( + "AGENTS_SHIPGATE_VERIFY_HEAD", args.head + ).strip() + effective_ci_mode = os.environ.get( + "AGENTS_SHIPGATE_VERIFY_CI_MODE", args.ci_mode + ) payload = { "kind": kind, - "base": args.base, - "head": args.head, + "config": args.config, + "base": effective_base, + "base_commit": _commit_for_ref(root, effective_base) + if effective_base + else None, + "head": effective_head, + "head_commit": _commit_for_ref(root, effective_head or "HEAD"), + "ci_mode": effective_ci_mode, + "cli": os.environ.get("AGENTS_SHIPGATE_CLI", "agents-shipgate"), "paths": paths, "diff_sha256": hashlib.sha256(diff_text.encode("utf-8")).hexdigest(), } @@ -1004,28 +1385,208 @@ def _snapshot( } -def _worktree_changed_paths(root: Path) -> list[str]: +def _worktree_changed_paths( + root: Path, + *, + configured_manifest: str, +) -> list[str] | None: + hidden_sensitive = _has_index_hidden_path(root) + if hidden_sensitive is not False: + # A hidden sensitive path is not observable through ordinary diff + # plumbing. An unavailable inventory is equally unsafe: neither may be + # mistaken for a clean worktree. + return None paths: list[str] = [] - tracked = _run_git(root, ["diff", "HEAD", "--name-only"]) - if tracked is not None and tracked.returncode == 0: - paths.extend(line.strip() for line in tracked.stdout.splitlines() if line.strip()) - untracked = _run_git(root, ["ls-files", "--others", "--exclude-standard"]) - if untracked is not None and untracked.returncode == 0: - paths.extend(line.strip() for line in untracked.stdout.splitlines() if line.strip()) - return sorted({path for path in paths if path}) + tracked = ( + _run_git_bounded( + root, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "HEAD", + "--name-only", + "-z", + ], + limit=GIT_PATH_OUTPUT_LIMIT_BYTES, + ) + if _ref_exists(root, "HEAD") + else b"" + ) + if tracked is None: + return None + untracked = _run_git_bounded( + root, + [ + *_SAFE_DIFF_CONFIG, + "ls-files", + "--others", + "--exclude-standard", + "-z", + ], + limit=GIT_PATH_OUTPUT_LIMIT_BYTES, + ) + if untracked is None: + return None + try: + tracked_text = tracked.decode("utf-8", errors="strict") + untracked_text = untracked.decode("utf-8", errors="strict") + except UnicodeDecodeError: + return None + paths.extend(path for path in tracked_text.split("\0") if path) + paths.extend(path for path in untracked_text.split("\0") if path) + return _bind_config_to_worktree_paths( + root, + paths=sorted({path for path in paths if path}), + configured_manifest=configured_manifest, + ) -def _worktree_diff(root: Path, paths: list[str]) -> str: - completed = _run_git(root, ["diff", "HEAD", "--", *paths]) - body = completed.stdout if completed is not None and completed.returncode == 0 else "" +def _bind_config_to_worktree_paths( + root: Path, + *, + paths: list[str], + configured_manifest: str, +) -> list[str] | None: + normalized = configured_manifest.replace("\\", "/").removeprefix("./") + candidate_path = Path(normalized) + if ( + not normalized + or candidate_path.is_absolute() + or ".." in candidate_path.parts + or not _safe_changed_path_transport(normalized) + ): + return None + raw, metadata = _read_untracked_file(root, normalized) + candidate = root / candidate_path + if raw is None or metadata is None: + return paths if not candidate.exists() else None + if metadata.st_size > UNTRACKED_DIFF_CONTENT_LIMIT_BYTES: + return None + head_probe = _run_git( + root, + ["cat-file", "-e", f"HEAD:{normalized}"], + ) + if head_probe is None: + return None + head_raw: bytes | None + if head_probe.returncode == 0: + head_raw = _run_git_bounded( + root, + ["show", f"HEAD:{normalized}"], + limit=UNTRACKED_DIFF_CONTENT_LIMIT_BYTES, + ) + if head_raw is None: + return None + elif head_probe.returncode in {1, 128}: + head_raw = None + else: + return None + if head_raw == raw: + return paths + return sorted({*paths, normalized}) + + +def _has_index_hidden_path(root: Path) -> bool | None: + if not _ref_exists(root, "HEAD"): + return False + raw = _run_git_bounded( + root, + [*_SAFE_DIFF_CONFIG, "ls-files", "--cached", "-v", "-z"], + limit=GIT_PATH_OUTPUT_LIMIT_BYTES, + ) + if raw is None: + return None + try: + records = raw.decode("utf-8", errors="strict").split("\0") + except UnicodeDecodeError: + return None + for record in records: + if not record: + continue + if len(record) < 3 or record[1] != " ": + return None + marker = record[0] + hidden = marker == "S" or marker.islower() + if hidden: + return True + return False + + +def _safe_changed_path_transport(path: str) -> bool: + return bool(path) and not any(char in path for char in "\0\r\n") + + +def _literal_pathspec(path: str) -> str: + return f":(top,literal){path}" + + +def _worktree_diff(root: Path, paths: list[str]) -> str | None: + if _has_executable_worktree_filter(root): + return None + raw = ( + _run_git_bounded( + root, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "HEAD", + "--", + *[_literal_pathspec(path) for path in paths], + ], + limit=GIT_DIFF_OUTPUT_LIMIT_BYTES, + ) + if _ref_exists(root, "HEAD") + else b"" + ) + if raw is None: + return None + try: + body = raw.decode("utf-8", errors="strict") + except UnicodeDecodeError: + return None + if _diff_hides_source_text(body, paths): + return None untracked = _untracked_content_for_paths(root, paths) + if untracked is None: + return None return _join_text(body, untracked) -def _git_diff_for_paths(root: Path, paths: list[str]) -> str: - completed = _run_git(root, ["diff", "HEAD", "--", *paths]) - body = completed.stdout if completed is not None and completed.returncode == 0 else "" - return _join_text(body, _untracked_content_for_paths(root, paths)) +def _git_diff_for_paths(root: Path, paths: list[str]) -> str | None: + if not _is_git_repository(root): + return _untracked_content_for_paths( + root, + paths, + assume_all_untracked=True, + ) + if _has_executable_worktree_filter(root): + return None + raw = _run_git_bounded( + root, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "HEAD", + "--", + *[_literal_pathspec(path) for path in paths], + ], + limit=GIT_DIFF_OUTPUT_LIMIT_BYTES, + ) + if raw is None: + return None + try: + body = raw.decode("utf-8", errors="strict") + except UnicodeDecodeError: + return None + if _diff_hides_source_text(body, paths): + return None + untracked = _untracked_content_for_paths(root, paths) + if untracked is None: + return None + return _join_text(body, untracked) def _join_text(left: str, right: str) -> str: @@ -1034,40 +1595,241 @@ def _join_text(left: str, right: str) -> str: return left or right -def _untracked_content_for_paths(root: Path, paths: list[str]) -> str: +def _untracked_content_for_paths( + root: Path, + paths: list[str], + *, + assume_all_untracked: bool = False, +) -> str | None: + tracked_paths: set[str] + if assume_all_untracked: + tracked_paths = set() + else: + tracked_raw = _run_git_bounded( + root, + [ + *_SAFE_DIFF_CONFIG, + "ls-files", + "-z", + "--", + *[_literal_pathspec(path) for path in paths], + ], + limit=GIT_PATH_OUTPUT_LIMIT_BYTES, + ) + if tracked_raw is None: + return None + try: + tracked_paths = { + item + for item in tracked_raw.decode("utf-8", errors="strict").split("\0") + if item + } + except UnicodeDecodeError: + return None + chunks: list[str] = [] + aggregate_bytes = 0 for path in paths: - tracked = _run_git(root, ["ls-files", "--error-unmatch", path]) - if tracked is not None and tracked.returncode == 0: - continue - candidate = root / path - if not candidate.is_file(): - continue - try: - stat = candidate.stat() - if stat.st_size > UNTRACKED_DIFF_CONTENT_LIMIT_BYTES: - chunks.append( - f"# untracked {path} size={stat.st_size} mtime_ns={stat.st_mtime_ns}" - ) - continue - raw = candidate.read_bytes() - except OSError: + if path in tracked_paths: continue + raw, metadata = _read_untracked_file(root, path) + if raw is None or metadata is None: + return None + if metadata.st_size > UNTRACKED_DIFF_CONTENT_LIMIT_BYTES: + # A metadata-only marker is cache-unsafe: same-size content can be + # rewritten while preserving mtime. Make the snapshot unavailable + # until the file is committed (or reduced below the bounded read). + return None + digest = hashlib.sha256(raw).hexdigest() if b"\0" in raw: - chunks.append(f"# untracked binary {path} size={stat.st_size}") - continue - text = raw.decode("utf-8", errors="replace") - chunks.append(f"# untracked {path}\n{text}") + chunk = ( + f"# untracked binary {path} size={metadata.st_size} " + f"sha256={digest}" + ) + else: + text = raw.decode("utf-8", errors="replace") + chunk = f"# untracked {path} sha256={digest}\n{text}" + aggregate_bytes += len(chunk.encode("utf-8")) + 1 + if aggregate_bytes > GIT_DIFF_OUTPUT_LIMIT_BYTES: + return None + chunks.append(chunk) return "\n".join(chunks) +def _read_untracked_file( + root: Path, + path: str, +) -> tuple[bytes | None, os.stat_result | None]: + if not _safe_changed_path_transport(path): + return None, None + candidate = Path(os.path.abspath(os.path.normpath(os.fspath(root / path)))) + try: + relative = candidate.relative_to(root) + except ValueError: + return None, None + current = root + try: + for component in relative.parts: + current = current / component + if current.is_symlink(): + return None, None + before = candidate.lstat() + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + return None, None + flags = ( + os.O_RDONLY + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + descriptor = os.open(candidate, flags) + try: + opened = os.fstat(descriptor) + if _file_metadata(opened) != _file_metadata(before): + return None, None + if opened.st_size > UNTRACKED_DIFF_CONTENT_LIMIT_BYTES: + raw = b"" + else: + raw = os.read(descriptor, UNTRACKED_DIFF_CONTENT_LIMIT_BYTES + 1) + after = os.fstat(descriptor) + finally: + os.close(descriptor) + current_metadata = candidate.lstat() + except OSError: + return None, None + if ( + _file_metadata(opened) != _file_metadata(after) + or _file_metadata(opened) != _file_metadata(current_metadata) + or len(raw) > UNTRACKED_DIFF_CONTENT_LIMIT_BYTES + ): + return None, None + return raw, opened + + +def _file_metadata(metadata: os.stat_result) -> tuple[int, ...]: + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_nlink, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) + + def _diff_context(root: Path, revspec: str) -> tuple[list[str], str] | None: - names = _run_git(root, ["diff", "--name-only", revspec]) - body = _run_git(root, ["diff", revspec]) - if names is None or body is None or names.returncode != 0 or body.returncode != 0: + if not _safe_ref_token(revspec): + return None + names = _run_git_bounded( + root, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "--name-only", + "-z", + revspec, + ], + limit=GIT_PATH_OUTPUT_LIMIT_BYTES, + ) + body = _run_git_bounded( + root, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + revspec, + ], + limit=GIT_DIFF_OUTPUT_LIMIT_BYTES, + ) + if names is None or body is None: return None - paths = [line.strip() for line in names.stdout.splitlines() if line.strip()] - return paths, body.stdout + try: + names_text = names.decode("utf-8", errors="strict") + body_text = body.decode("utf-8", errors="strict") + except UnicodeDecodeError: + return None + paths = [path for path in names_text.split("\0") if path] + if _diff_hides_source_text(body_text, paths): + return None + return paths, body_text + + +def _diff_hides_source_text(diff_text: str, paths: list[str]) -> bool: + if "Binary files " not in diff_text and "GIT binary patch" not in diff_text: + return False + source_suffixes = { + ".json", ".jsonl", ".md", ".py", ".toml", ".yaml", ".yml", + } + return any( + Path(path).suffix.casefold() in source_suffixes + or _protected_surface_for(path) is not None + for path in paths + ) + + +def _safe_ref_token(value: str) -> bool: + return bool(value) and not value.startswith("-") and not any( + char in value for char in "\0\r\n" + ) + + +def _has_executable_worktree_filter(root: Path) -> bool: + completed = _run_git( + root, + [ + "config", + "--includes", + "--get-regexp", + r"^filter\..*\.(clean|process|smudge)$", + ], + ) + if completed is None: + return True + if completed.returncode not in {0, 1}: + return True + if completed.returncode == 0: + for line in completed.stdout.splitlines(): + _key, separator, value = line.partition(" ") + if separator and value.strip(): + return True + diff_config = _run_git( + root, + [ + "config", + "--includes", + "--get-regexp", + r"^diff\.", + ], + ) + if diff_config is None or diff_config.returncode not in {0, 1}: + return True + if diff_config.returncode == 0 and diff_config.stdout.strip(): + return True + info = _run_git(root, ["rev-parse", "--git-path", "info/attributes"]) + if info is None or info.returncode != 0 or not info.stdout.strip(): + return True + info_path = Path(info.stdout.strip()) + if not info_path.is_absolute(): + info_path = root / info_path + try: + if info_path.is_symlink() or (info_path.is_file() and info_path.stat().st_size): + return True + except OSError: + return True + attributed = _run_git_bounded( + root, + [ + *_SAFE_DIFF_CONFIG, + "ls-files", + "-z", + "--", + ":(top)**", + ":(exclude,attr:!filter)", + ":(exclude,attr:-filter)", + ], + ) + return attributed is None or bool(attributed) def _state_path(root: Path) -> Path | None: @@ -1193,14 +1955,59 @@ def _changed_paths(payload: dict[str, Any], root: Path) -> list[str]: return sorted({path for path in paths if path}) +def _unsafe_alias_kind(root: Path, path: str) -> str | None: + """Return a conservative prompt reason for aliased/non-regular writes.""" + + if any(char in path for char in "\0\r\n"): + return "control-delimited-path" + candidate = Path(os.path.abspath(os.path.normpath(os.fspath(root / path)))) + try: + relative = candidate.relative_to(root) + except ValueError: + return None + current = root + try: + for part in relative.parts: + requested = current / part + exact_entry = False + with os.scandir(current) as entries: + for entry in entries: + if entry.name == part: + exact_entry = True + break + if not exact_entry: + try: + requested.lstat() + except FileNotFoundError: + return None + return "aliased-path" + current = requested + metadata = requested.lstat() + if stat.S_ISLNK(metadata.st_mode): + return "symbolic-link-path" + metadata = candidate.lstat() + except FileNotFoundError: + return None + except OSError: + return "uninspectable-path" + if not stat.S_ISREG(metadata.st_mode): + return "non-regular-path" + if metadata.st_nlink != 1: + return "hardlinked-path" + return None + + def _repo_path(value: str, root: Path) -> str: path = Path(value) - if path.is_absolute(): - try: - return path.resolve().relative_to(root).as_posix() - except ValueError: - return path.name - return path.as_posix() + lexical = Path( + os.path.abspath( + os.path.normpath(os.fspath(path if path.is_absolute() else root / path)) + ) + ) + try: + return lexical.relative_to(root).as_posix() + except ValueError: + return lexical.name def _emit_context(event: str, message: str) -> int: diff --git a/src/agents_shipgate/cli/mcp.py b/src/agents_shipgate/cli/mcp.py index af5ada62..70293bdb 100644 --- a/src/agents_shipgate/cli/mcp.py +++ b/src/agents_shipgate/cli/mcp.py @@ -253,48 +253,70 @@ def build_mcp_audit( base_tools: list[Tool] = [] head_tools: list[Tool] = [] servers: list[NormalizedMcpServer] = [] - changed_files = sorted({item.path for item in diff_files if item.path}) + changed_files = sorted( + { + path + for item in diff_files + for path in (item.new_path, item.old_path) + if path + } + ) for diff_file in diff_files: - if not diff_file.path: - continue - if is_codex_config_path(diff_file.path): - resolved = resolve_changed_file_text(workspace, diff_file, diagnostics) - if resolved.old_text: + old_path = diff_file.old_path + new_path = diff_file.new_path + resolved = None + old_is_codex = bool(old_path and is_codex_config_path(old_path)) + new_is_codex = bool(new_path and is_codex_config_path(new_path)) + if old_is_codex or new_is_codex: + resolved = resolve_changed_file_text( + workspace, + diff_file, + diagnostics, + preserve_rename_source=True, + ) + if old_is_codex and resolved.old_text and old_path: base_tools.extend( _tools_from_toml( resolved.old_text, - source_ref=diff_file.path, - source_path=diff_file.path, + source_ref=old_path, + source_path=old_path, diagnostics=diagnostics, ) ) - if resolved.new_text: + if new_is_codex and resolved.new_text and new_path: parsed_servers, tools = _servers_tools_from_toml( resolved.new_text, - source_ref=diff_file.path, - source_path=diff_file.path, + source_ref=new_path, + source_path=new_path, diagnostics=diagnostics, ) servers.extend(parsed_servers) head_tools.extend(tools) - continue - if is_mcp_json_path(diff_file.path): - resolved = resolve_changed_file_text(workspace, diff_file, diagnostics) - if resolved.old_text: + old_is_mcp = bool(old_path and is_mcp_json_path(old_path)) + new_is_mcp = bool(new_path and is_mcp_json_path(new_path)) + if old_is_mcp or new_is_mcp: + if resolved is None: + resolved = resolve_changed_file_text( + workspace, + diff_file, + diagnostics, + preserve_rename_source=True, + ) + if old_is_mcp and resolved.old_text and old_path: base_tools.extend( _tools_from_mcp_json( resolved.old_text, - source_ref=diff_file.path, - source_path=diff_file.path, + source_ref=old_path, + source_path=old_path, diagnostics=diagnostics, ) ) - if resolved.new_text: + if new_is_mcp and resolved.new_text and new_path: parsed_servers, tools = _servers_tools_from_mcp_json( resolved.new_text, - source_ref=diff_file.path, - source_path=diff_file.path, + source_ref=new_path, + source_path=new_path, diagnostics=diagnostics, ) servers.extend(parsed_servers) diff --git a/src/agents_shipgate/cli/preflight.py b/src/agents_shipgate/cli/preflight.py index 4d27f87d..bf8fd128 100644 --- a/src/agents_shipgate/cli/preflight.py +++ b/src/agents_shipgate/cli/preflight.py @@ -2,7 +2,9 @@ import json import logging +import os import shlex +import stat import sys from pathlib import Path from typing import Any @@ -10,11 +12,19 @@ import typer from pydantic import ValidationError -from agents_shipgate.cli.agent_mode import emit_agent_mode_error_action +from agents_shipgate.cli.agent_mode import emit_agent_mode_error +from agents_shipgate.core.bounded_io import ( + MAX_EXPLICIT_DIFF_BYTES, + MAX_EXPLICIT_JSON_BYTES, + MAX_EXPLICIT_PATH_LIST_BYTES, + read_bounded_utf8_file, + read_bounded_utf8_stdin, +) from agents_shipgate.core.codex_boundary import parse_unified_diff from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError from agents_shipgate.core.logging import configure_logging from agents_shipgate.core.preflight import build_preflight_result +from agents_shipgate.core.trust_roots import inspect_lexical_path_identity from agents_shipgate.schemas.diagnostics import NextAction from agents_shipgate.schemas.preflight import ( CapabilityRequestV1, @@ -39,6 +49,15 @@ def _rerun_command(**flags: object) -> str | None: not. """ + cwd_relative_paths = { + "workspace", + "changed_files", + "diff", + "capability_request", + "plan", + "base_preflight", + } + stdin_paths = {"diff", "capability_request", "plan", "base_preflight"} parts = ["agents-shipgate", "preflight"] for name, value in flags.items(): if value is None or value is False: @@ -47,8 +66,11 @@ def _rerun_command(**flags: object) -> str | None: parts.append(f"--{name.replace('_', '-')}") continue text = str(value) - if text == "-": + if text == "-" and name in stdin_paths: return None + if name in cwd_relative_paths: + path = Path(text) + text = str(path if path.is_absolute() else Path.cwd() / path) parts.extend([f"--{name.replace('_', '-')}", shlex.quote(text)]) return " ".join(parts) @@ -60,32 +82,161 @@ def _agent_mode_exit( exit_code: int, next_action: str, command: str | None, + repair_path: Path | None = None, ) -> typer.Exit: - """Emit the structured agent-mode error line and return the exit to raise. + """Emit ranked recovery actions and return the exit to raise. ``error_kind`` must be one of the ids published in ``docs/errors.json``; an id an agent cannot look up is no better than prose. + + A malformed input cannot be repaired by immediately replaying the failed + command. When the failing file is known, editing it is rank one and an + exact rerun may follow as rank two. Stdin and request-shape conflicts have + no editable file or valid replay, so they remain review-only. """ - action = ( - NextAction( - kind="command", - command=command, - why=next_action, - expects="A preflight run of the same request that completes.", + actions: list[NextAction] = [] + if repair_path is not None: + actions.append( + NextAction( + kind="edit", + path=str(repair_path), + why=next_action, + expects="The named preflight input is valid and readable.", + ) ) - if command - else NextAction(kind="review", why=next_action) - ) - emit_agent_mode_error_action( + elif command is None: + actions.append(NextAction(kind="review", why=next_action)) + if command is not None and repair_path is not None: + actions.append( + NextAction( + kind="command", + command=command, + why="Re-run the exact preflight request after repairing its input.", + expects="A preflight run of the same request that completes.", + ) + ) + if not actions: + actions.append(NextAction(kind="review", why=next_action)) + + emit_agent_mode_error( error_kind, message=str(exc), exit_code=exit_code, - action=action, + next_action=actions[0].to_legacy_string(), + next_actions=[action.model_dump(mode="json") for action in actions], ) return typer.Exit(exit_code) +def _safe_repair_file( + workspace: Path, + path: Path | None, + *, + relative_to_workspace: bool, +) -> Path | None: + """Return one exact, singly-linked editable file inside the workspace.""" + + if path is None or str(path) == "-": + return None + root = workspace.resolve() + anchor = root if relative_to_workspace else Path.cwd() + candidate = path if path.is_absolute() else anchor / path + if ".." in candidate.parts: + return None + lexical = Path(os.path.normpath(os.fspath(candidate))) + try: + relative = lexical.relative_to(root) + except ValueError: + return None + if inspect_lexical_path_identity(root, relative) is not None: + return None + try: + metadata = lexical.lstat() + except OSError: + return None + # A hard link inside the workspace can name an inode whose other name is + # outside it. Granting an edit action would then authorize an external + # mutation despite the lexical containment check. + return ( + lexical + if stat.S_ISREG(metadata.st_mode) and metadata.st_nlink == 1 + else None + ) + + +def _safe_manifest_repair_path(workspace: Path, config: Path) -> Path | None: + """Return an editable manifest only when it is one exact in-workspace file.""" + + return _safe_repair_file( + workspace, + config, + relative_to_workspace=True, + ) + + +def _repair_path_for_parse_error( + exc: BaseException, + *, + workspace: Path, + config: Path, + changed_files: Path | None, + diff: Path | None, + capability_request: Path | None, + plan: Path | None, + base_preflight: Path | None, + host_baseline: Path | None, +) -> Path | None: + """Identify the malformed file without changing CLI path semantics.""" + + message = str(exc).lower() + if "preflight plan" in message: + return _safe_repair_file(workspace, plan, relative_to_workspace=False) + if "capability request" in message: + return _safe_repair_file( + workspace, + capability_request, + relative_to_workspace=False, + ) + if "base preflight" in message: + return _safe_repair_file( + workspace, + base_preflight, + relative_to_workspace=False, + ) + if "host-grant" in message or "host grant" in message: + # A host-grants baseline is human-owned trust evidence. Editing or + # replacing it acknowledges the current host authority, so parse and + # integrity failures must remain review-only even when the file itself + # would otherwise be a mechanically safe edit target. + return None + if "changed-files" in message or "changed files" in message: + return _safe_repair_file( + workspace, + changed_files, + relative_to_workspace=False, + ) + if "unified diff" in message or "diff input" in message: + return _safe_repair_file(workspace, diff, relative_to_workspace=False) + if any( + marker in message + for marker in ( + "manifest", + "invalid yaml", + "config file must contain", + "check_severity_overrides was removed", + ) + ): + return _safe_manifest_repair_path(workspace, config) + if isinstance(exc, OSError) and exc.filename: + return _safe_repair_file( + workspace, + Path(exc.filename), + relative_to_workspace=False, + ) + return None + + def preflight( workspace: Path = typer.Option( Path("."), @@ -154,6 +305,7 @@ def preflight( base_preflight=base_preflight, host_baseline=host_baseline, json=json_output, + verbose=verbose, ) try: @@ -203,6 +355,21 @@ def preflight( "the same preflight request." ), command=rerun, + repair_path=( + None + if conflicting_request or rerun is None + else _repair_path_for_parse_error( + exc, + workspace=workspace, + config=config, + changed_files=changed_files, + diff=diff, + capability_request=capability_request, + plan=plan, + base_preflight=base_preflight, + host_baseline=host_baseline, + ) + ), ) from exc except InputParseError as exc: typer.echo(f"Input parsing error: {exc}", err=True) @@ -215,6 +382,21 @@ def preflight( "the error, then re-run the same preflight request." ), command=rerun, + repair_path=( + None + if conflicting_request or rerun is None + else _repair_path_for_parse_error( + exc, + workspace=workspace, + config=config, + changed_files=changed_files, + diff=diff, + capability_request=capability_request, + plan=plan, + base_preflight=base_preflight, + host_baseline=host_baseline, + ) + ), ) from exc except AgentsShipgateError as exc: typer.echo(f"Agents Shipgate error: {exc}", err=True) @@ -236,6 +418,21 @@ def preflight( "the same preflight request." ), command=rerun, + repair_path=( + None + if rerun is None + else _repair_path_for_parse_error( + exc, + workspace=workspace, + config=config, + changed_files=changed_files, + diff=diff, + capability_request=capability_request, + plan=plan, + base_preflight=base_preflight, + host_baseline=host_baseline, + ) + ), ) from exc except Exception as exc: # noqa: BLE001 - agents must never see a bare traceback. # Every other command reports unexpected failures on the agent channel; @@ -273,40 +470,66 @@ def preflight( def _read_changed_files(path: Path | None) -> list[str]: if path is None: return [] - return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + # LF is the format delimiter. Spaces and Unicode separators are legal Git + # filename content and must not be normalized into another repository path. + lines = read_bounded_utf8_file( + path, + max_bytes=MAX_EXPLICIT_PATH_LIST_BYTES, + label="Changed-files input", + ).split("\n") + return [ + line[:-1] if line.endswith("\r") else line + for line in lines + if line not in {"", "\r"} + ] def _read_diff(path: Path) -> str: - return sys.stdin.read() if str(path) == "-" else path.read_text(encoding="utf-8") + if str(path) == "-": + return read_bounded_utf8_stdin( + max_bytes=MAX_EXPLICIT_DIFF_BYTES, + label="Diff input", + ) + return read_bounded_utf8_file( + path, + max_bytes=MAX_EXPLICIT_DIFF_BYTES, + label="Diff input", + ) def _read_capability_request(path: Path | None) -> CapabilityRequestV1 | None: if path is None: return None - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise InputParseError(f"Capability request is not valid JSON: {exc}") from exc + source_label = _json_source_label(path, label="Capability request") + payload = _read_json_file_or_stdin(path, label="Capability request") if not isinstance(payload, dict): - raise InputParseError("Capability request JSON must be an object.") + raise InputParseError(f"{source_label} JSON must be an object.") try: return CapabilityRequestV1.model_validate(payload) except ValidationError as exc: - raise ConfigError(f"Invalid capability request: {exc}") from exc + raise ConfigError(f"Invalid {source_label}: {exc}") from exc def _read_plan(path: Path) -> PreflightPlanV1: + source_label = _json_source_label(path, label="Preflight plan") if str(path) == "-": - raw = "" if sys.stdin.isatty() else sys.stdin.read() + raw = ( + "" + if sys.stdin.isatty() + else read_bounded_utf8_stdin( + max_bytes=MAX_EXPLICIT_JSON_BYTES, + label="Preflight plan", + ) + ) payload: Any = {} if not raw.strip() else _loads_json(raw, label="Preflight plan") else: payload = _read_json_file_or_stdin(path, label="Preflight plan") if not isinstance(payload, dict): - raise InputParseError("Preflight plan JSON must be an object.") + raise InputParseError(f"{source_label} JSON must be an object.") try: return PreflightPlanV1.model_validate(payload) except ValidationError as exc: - raise ConfigError(f"Invalid preflight plan: {exc}") from exc + raise ConfigError(f"Invalid {source_label}: {exc}") from exc def _read_base_preflight( @@ -314,9 +537,10 @@ def _read_base_preflight( ) -> PreflightResultV1 | PreflightResultV2 | PreflightResultV3 | None: if path is None: return None + source_label = _json_source_label(path, label="Base preflight") payload = _read_json_file_or_stdin(path, label="Base preflight") if not isinstance(payload, dict): - raise InputParseError("Base preflight JSON must be an object.") + raise InputParseError(f"{source_label} JSON must be an object.") try: version = payload.get("preflight_schema_version") if version == "0.3": @@ -325,12 +549,28 @@ def _read_base_preflight( return PreflightResultV2.model_validate(payload) return PreflightResultV1.model_validate(payload) except ValidationError as exc: - raise ConfigError(f"Invalid base preflight result: {exc}") from exc + raise ConfigError(f"Invalid {source_label}: {exc}") from exc def _read_json_file_or_stdin(path: Path, *, label: str) -> Any: - raw = sys.stdin.read() if str(path) == "-" else path.read_text(encoding="utf-8") - return _loads_json(raw, label=label) + raw = ( + read_bounded_utf8_stdin( + max_bytes=MAX_EXPLICIT_JSON_BYTES, + label=label, + ) + if str(path) == "-" + else read_bounded_utf8_file( + path, + max_bytes=MAX_EXPLICIT_JSON_BYTES, + label=label, + ) + ) + source_label = _json_source_label(path, label=label) + return _loads_json(raw, label=source_label) + + +def _json_source_label(path: Path, *, label: str) -> str: + return label if str(path) == "-" else f"{label} file {path}" def _loads_json(raw: str, *, label: str) -> Any: diff --git a/src/agents_shipgate/cli/scan/orchestrator.py b/src/agents_shipgate/cli/scan/orchestrator.py index 20bbb408..880ea08e 100644 --- a/src/agents_shipgate/cli/scan/orchestrator.py +++ b/src/agents_shipgate/cli/scan/orchestrator.py @@ -43,6 +43,7 @@ def run_scan( packet_generated_at: str | None = None, verification_context: VerificationContext | None = None, capability_lock_callback: Callable[[CapabilityLockFileV1], None] | None = None, + manifest_text: str | None = None, ) -> tuple[ReadinessReport, int]: """Run a full scan pipeline. Returns ``(report, exit_code)``. @@ -67,6 +68,7 @@ def run_scan( packet_enabled=packet_enabled, packet_formats=packet_formats, baseline_mode=baseline_mode, + manifest_text=manifest_text, ) with _perf.phase("load_inputs"): inputs = _load_inputs( diff --git a/src/agents_shipgate/cli/scan/prepare.py b/src/agents_shipgate/cli/scan/prepare.py index 00cffbad..876b6186 100644 --- a/src/agents_shipgate/cli/scan/prepare.py +++ b/src/agents_shipgate/cli/scan/prepare.py @@ -2,7 +2,10 @@ from pathlib import Path -from agents_shipgate.config.loader import load_manifest_with_positions +from agents_shipgate.config.loader import ( + load_manifest_text_with_positions, + load_manifest_with_positions, +) from agents_shipgate.core.errors import ConfigError from agents_shipgate.schemas.common import parse_severity @@ -20,6 +23,7 @@ def _prepare_scan( packet_enabled: bool | None, packet_formats: list[str] | None, baseline_mode: str, + manifest_text: str | None = None, ) -> _ResolvedManifest: """Phase 1: load manifest with positions; apply CLI overrides. @@ -27,7 +31,13 @@ def _prepare_scan( ``ConfigError`` (exit 2) for invalid packet formats or unsupported baseline modes — both fail before any source loading happens. """ - raw_manifest, manifest_positions = load_manifest_with_positions(config_path) + if manifest_text is None: + raw_manifest, manifest_positions = load_manifest_with_positions(config_path) + else: + raw_manifest, manifest_positions = load_manifest_text_with_positions( + manifest_text, + source=config_path, + ) manifest = raw_manifest.model_copy(deep=True) if ci_mode: manifest.ci.mode = ci_mode @@ -52,5 +62,9 @@ def _prepare_scan( return _ResolvedManifest( manifest=manifest, manifest_positions=manifest_positions, - base_dir=config_path.resolve().parent, + base_dir=( + config_path.resolve().parent + if manifest_text is None + else config_path.parent.resolve() + ), ) diff --git a/src/agents_shipgate/cli/scan/validation.py b/src/agents_shipgate/cli/scan/validation.py index 6cd67f60..b30cec04 100644 --- a/src/agents_shipgate/cli/scan/validation.py +++ b/src/agents_shipgate/cli/scan/validation.py @@ -3,6 +3,8 @@ from pathlib import Path from agents_shipgate.cli.discovery.placeholders import collect_placeholders +from agents_shipgate.core.errors import InputParseError +from agents_shipgate.inputs.common import load_text_file def _resolve_source_paths( @@ -27,8 +29,8 @@ def _resolve_source_paths( """ unresolved: list[dict[str, object]] = [] try: - manifest_text = config_path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + manifest_text = load_text_file(config_path) + except InputParseError: manifest_text = "" text_lines = manifest_text.splitlines() base_resolved = base_dir.resolve() @@ -80,8 +82,8 @@ def _manifest_placeholder_warnings(config_path: Path) -> list[str]: in that case. """ try: - manifest_text = config_path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + manifest_text = load_text_file(config_path) + except InputParseError: return [] placeholders = collect_placeholders(manifest_text) name = config_path.name diff --git a/src/agents_shipgate/cli/trigger.py b/src/agents_shipgate/cli/trigger.py index 4a28dc74..f80a28ba 100644 --- a/src/agents_shipgate/cli/trigger.py +++ b/src/agents_shipgate/cli/trigger.py @@ -25,6 +25,13 @@ import typer +from agents_shipgate.core.bounded_io import ( + MAX_EXPLICIT_DIFF_BYTES, + MAX_EXPLICIT_JSON_BYTES, + MAX_EXPLICIT_PATH_LIST_BYTES, + read_bounded_utf8_file, +) +from agents_shipgate.core.errors import ConfigError, InputParseError from agents_shipgate.triggers import ( _git_diff_context, evaluate, @@ -127,7 +134,7 @@ def trigger( paths, diff_text = _git_diff_context( revspec, cwd=workspace.resolve() ) - except (FileNotFoundError, subprocess.CalledProcessError) as exc: + except (FileNotFoundError, subprocess.CalledProcessError, ConfigError) as exc: typer.echo( f"--base/--head git diff failed: {exc}. Run inside a git " "checkout, or pass --changed-files / --diff instead.", @@ -138,9 +145,15 @@ def trigger( try: paths = _read_lines(changed_files) diff_text = ( - diff.read_text(encoding="utf-8") if diff is not None else "" + read_bounded_utf8_file( + diff, + max_bytes=MAX_EXPLICIT_DIFF_BYTES, + label="Trigger diff input", + ) + if diff is not None + else "" ) - except (OSError, UnicodeDecodeError) as exc: + except (OSError, UnicodeDecodeError, InputParseError) as exc: typer.echo( f"Could not read trigger input file: {exc}. Check the " "--changed-files / --diff path.", @@ -156,8 +169,19 @@ def trigger( detect_result: dict | None = None if detect_json is not None: try: - detect_result = json.loads(detect_json.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + detect_result = json.loads( + read_bounded_utf8_file( + detect_json, + max_bytes=MAX_EXPLICIT_JSON_BYTES, + label="Detect JSON input", + ) + ) + except ( + OSError, + UnicodeDecodeError, + InputParseError, + json.JSONDecodeError, + ) as exc: typer.echo( f"--detect-json could not be read: {exc}. Check the path " "and that it holds `agents-shipgate detect --json` output.", @@ -202,6 +226,10 @@ def _read_lines(path: Path | None) -> list[str]: return [] return [ line.strip() - for line in path.read_text(encoding="utf-8").splitlines() + for line in read_bounded_utf8_file( + path, + max_bytes=MAX_EXPLICIT_PATH_LIST_BYTES, + label="Changed-files input", + ).splitlines() if line.strip() ] diff --git a/src/agents_shipgate/cli/verification.py b/src/agents_shipgate/cli/verification.py index 3d723389..56bd54c1 100644 --- a/src/agents_shipgate/cli/verification.py +++ b/src/agents_shipgate/cli/verification.py @@ -77,7 +77,11 @@ def prepare( head_ref = head or "HEAD" if not ref_exists(root, head_ref): raise typer.BadParameter(f"head ref is unavailable locally: {head_ref}") - changed, diff_text = diff_context(root, base, head_ref) if base else working_tree_context(root) + changed, diff_text = ( + diff_context(root, base, head_ref) + if base + else working_tree_context(root, reject_index_hidden=True) + ) resolved_date = evaluation_date or commit_date(root, head_ref) config_relative = _under(root, config).relative_to(root) policy_paths = [_under(root, path) for path in policy_packs or []] diff --git a/src/agents_shipgate/cli/verify/command.py b/src/agents_shipgate/cli/verify/command.py index 7a8e98f7..c8a317e2 100644 --- a/src/agents_shipgate/cli/verify/command.py +++ b/src/agents_shipgate/cli/verify/command.py @@ -25,17 +25,41 @@ logger = logging.getLogger(__name__) +def _unsafe_config_identity_error(exc: ConfigError) -> bool: + """Whether recovery must not offer edits or a replay command.""" + + message = str(exc) + identity_failure = any( + marker in message + for marker in ( + "must be inside --workspace", + "must not contain symlink components", + "must use the exact filesystem spelling", + "could not be inspected safely", + ) + ) + return identity_failure and ( + message.startswith("--config") + or message.startswith("Head manifest") + or message.startswith("Base manifest") + ) + + def verify( workspace: Path = typer.Option( Path("."), "--workspace", help="Workspace/git checkout containing the head tree.", ), - config: Path = typer.Option( - Path("shipgate.yaml"), + config: Path | None = typer.Option( + None, "--config", "-c", - help="Path to shipgate.yaml, relative to --workspace unless absolute.", + help=( + "Path to shipgate.yaml. An explicit relative path is relative to " + "--workspace; when omitted, the default is shipgate.yaml at the " + "Git root." + ), ), base: str | None = typer.Option( None, @@ -175,6 +199,16 @@ def verify( stdout_format = _resolve_verify_format(format_, json_output=json_output, preview=preview) if ci_mode and ci_mode not in {"advisory", "strict"}: raise ConfigError("--ci-mode must be advisory or strict") + for label, value in (("--base", base), ("--head", head)): + if value is not None and ( + not value + or value.startswith("-") + or any(char in value for char in "\0\r\n") + ): + raise ConfigError( + f"{label} must be non-empty, must not begin with '-', " + "and must not contain control delimiters" + ) parsed_fail_on = _parse_fail_on(fail_on) parsed_pr_comment_style = _parse_pr_comment_style(pr_comment_style) if preview and authorization is not None: @@ -198,10 +232,20 @@ def verify( raise typer.Exit(2) from exc try: + effective_config = config + if effective_config is None: + if preview: + try: + config_root = ensure_git_workspace(workspace.resolve()) + except ConfigError: + config_root = workspace.resolve() + else: + config_root = ensure_git_workspace(workspace.resolve()) + effective_config = config_root / "shipgate.yaml" if preview: verifier, _report, exit_code = run_preview( workspace=workspace, - config=config, + config=effective_config, base=base, head=head, out=out, @@ -211,7 +255,7 @@ def verify( head_ref = head or "HEAD" verifier, _report, exit_code = run_verify( workspace=workspace, - config=config, + config=effective_config, base=base, head=head_ref, archive_head=head is not None, @@ -233,13 +277,29 @@ def verify( ) except ConfigError as exc: typer.echo(f"Config error: {exc}", err=True) - diagnostics = _diagnose_config_error( - config=str(config), - workspace=workspace, - exc=exc, - plugins_enabled=False if no_plugins else None, - ) - flattened = top_next_actions(diagnostics) + if _unsafe_config_identity_error(exc): + guidance = ( + "Review the configured manifest identity and rerun verify only " + "after selecting an exact in-workspace, non-symlink path." + ) + flattened = [ + NextAction( + kind="review", + why=guidance, + expects=( + "The configured manifest uses its exact stored identity " + "inside the evaluated workspace." + ), + ) + ] + else: + diagnostics = _diagnose_config_error( + config=str(config or Path("shipgate.yaml")), + workspace=workspace, + exc=exc, + plugins_enabled=False if no_plugins else None, + ) + flattened = top_next_actions(diagnostics) _echo_next_action_hint(flattened) emit_agent_mode_error( "config_error", diff --git a/src/agents_shipgate/cli/verify/fix_task.py b/src/agents_shipgate/cli/verify/fix_task.py index ccce98b0..b0831f39 100644 --- a/src/agents_shipgate/cli/verify/fix_task.py +++ b/src/agents_shipgate/cli/verify/fix_task.py @@ -31,6 +31,12 @@ _MAX_INSTRUCTIONS = 5 _MAX_REPAIRS = 10 +_ADOPTION_CHECK_ID = "SHIP-VERIFY-POLICY-WEAKENED" +_ADOPTION_EVIDENCE_KIND = "manifest_introduced" +_ADOPTION_TRUST_ROOT_CHECK_ID = "SHIP-VERIFY-TRUST-ROOT-TOUCHED" +_ADOPTION_BOUNDARY_CHECK_ID = ( + "SHIP-AGENT-BOUNDARY-PROTECTED-SURFACE-UNCLASSIFIED" +) _FORBIDDEN_REPAIR_SPECS: tuple[tuple[str, str, str, str], ...] = ( ( @@ -77,6 +83,8 @@ def build_fix_task( config: str | None = None, worktree: bool = False, rerun_options: Sequence[str] | None = None, + report_path: str = "agents-shipgate-reports/report.json", + repair_subject_available: bool = True, ) -> VerifierFixTask | None: """Project the head scan onto a single repair task. @@ -85,9 +93,10 @@ def build_fix_task( ``manifest_introduced`` changes only the wording of the trust-root instruction and repair: a PR that adds the manifest to a base that had none - is adopting a gate, not weakening one, and the human act it needs is - "review and merge this", not "approve a downgrade". Routing is untouched — - adoption is still an authority escalation, so it still routes to a human. + is adopting a gate, not weakening one. "Review and merge this" appears only + when adoption is the sole gating concern; otherwise the real blocker leads + and adoption is named as a separate review. Routing is untouched — + adoption remains an authority escalation and always routes to a human. """ if merge_verdict == "mergeable": return None @@ -125,6 +134,11 @@ def build_fix_task( ) gating = _gating_findings(report) + pure_adoption_review = _is_pure_adoption_review( + report, + gating, + manifest_introduced=manifest_introduced, + ) # Degraded static evidence (below the IE threshold) is an authority gap # regardless of which verdict it produced. An active high/critical finding @@ -148,7 +162,7 @@ def build_fix_task( mechanical = bool(gating) and all( finding.autofix_safe is True and finding.requires_human_review is False for finding in gating - ) + ) and _all_gating_findings_have_applicable_patches(gating) authority_escalation = ( capability_review.policy_weakened or capability_review.trust_root_touched @@ -158,6 +172,7 @@ def build_fix_task( or manifest_introduced or merge_verdict in {"insufficient_evidence", "unknown"} or evidence_degraded + or not repair_subject_available ) if mechanical and not authority_escalation: return VerifierFixTask( @@ -167,6 +182,7 @@ def build_fix_task( allowed_repairs=_mechanical_repairs( gating, verification_command=verification_command, + report_path=report_path, ), forbidden_repairs=_forbidden_repairs(gating), forbidden_shortcuts=list(FORBIDDEN_SHORTCUTS), @@ -184,6 +200,8 @@ def build_fix_task( merge_verdict=merge_verdict, evidence_degraded=evidence_degraded, manifest_introduced=manifest_introduced, + pure_adoption_review=pure_adoption_review, + config=config, ), allowed_repairs=_human_repairs( report, @@ -191,6 +209,7 @@ def build_fix_task( gating, verification_command=verification_command, manifest_introduced=manifest_introduced, + pure_adoption_review=pure_adoption_review, config=config, ), forbidden_repairs=_forbidden_repairs(gating), @@ -217,6 +236,103 @@ def _gating_findings(report: ReadinessReport) -> list[Finding]: return out +def is_pure_adoption_review( + report: ReadinessReport | None, + *, + manifest_introduced: bool, +) -> bool: + """Whether adoption is the release decision's only gating concern. + + Friendly "review, then merge" copy is only truthful for this narrow + substrate. A blocked or insufficient-evidence report, any blocker, or any + second review item must lead with its real stop condition instead. + """ + + if report is None or report.release_decision is None: + return False + return _is_pure_adoption_review( + report, + _gating_findings(report), + manifest_introduced=manifest_introduced, + ) + + +def _is_pure_adoption_review( + report: ReadinessReport, + gating: list[Finding], + *, + manifest_introduced: bool, +) -> bool: + decision = report.release_decision + assert decision is not None + coverage = decision.evidence_coverage + adoption_findings = [ + finding + for finding in gating + if finding.check_id == _ADOPTION_CHECK_ID + and finding.evidence.get("kind") == _ADOPTION_EVIDENCE_KIND + ] + manifest_paths = { + str(path) + for finding in adoption_findings + for path in (finding.evidence.get("changed_policy_files") or []) + if path + } + adoption_path = next(iter(manifest_paths)) if len(manifest_paths) == 1 else None + return bool( + manifest_introduced + and decision.decision == "review_required" + and not decision.blockers + and len(adoption_findings) == 1 + and adoption_path is not None + # Every decision item must resolve to a finding; otherwise an unknown + # second concern could disappear behind the friendly adoption copy. + and len(gating) == len(decision.review_items) + # A real adoption produces multiple layers of evidence for the same + # human decision: the policy fail-safe, the generic trust-root touch, + # and (for custom names) the agent-boundary manifest row. Treat those + # as one concern only when all point to the exact introduced manifest. + and all( + _is_same_manifest_adoption_finding(finding, adoption_path) + for finding in gating + ) + and not coverage.human_review_recommended + and not coverage.evidence_gaps + and not evidence_below_ie_threshold( + coverage, + tool_count=len(report.tool_inventory), + ) + ) + + +def _is_same_manifest_adoption_finding( + finding: Finding, + manifest_path: str, +) -> bool: + if ( + finding.check_id == _ADOPTION_CHECK_ID + and finding.evidence.get("kind") == _ADOPTION_EVIDENCE_KIND + ): + return finding.evidence.get("changed_policy_files") == [manifest_path] + if finding.check_id == _ADOPTION_TRUST_ROOT_CHECK_ID: + return bool( + finding.evidence.get("changed_file") == manifest_path + and finding.evidence.get("trust_root_class") == "manifest" + ) + if finding.check_id == _ADOPTION_BOUNDARY_CHECK_ID: + source_path = finding.source.path if finding.source is not None else None + return bool( + source_path == manifest_path + and finding.evidence.get("kind") + in {"manifest_introduced", "protected_surface_unclassified"} + and ( + finding.evidence.get("kind") == "manifest_introduced" + or finding.evidence.get("trust_root_class") == "manifest" + ) + ) + return False + + def _human_instructions( report: ReadinessReport, capability_review: VerifierCapabilityReview, @@ -225,6 +341,8 @@ def _human_instructions( merge_verdict: MergeVerdict = "human_review_required", evidence_degraded: bool = False, manifest_introduced: bool = False, + pure_adoption_review: bool = False, + config: str | None = None, ) -> list[str]: decision = report.release_decision assert decision is not None @@ -234,9 +352,24 @@ def _human_instructions( # about their change. It also has to survive ``_MAX_INSTRUCTIONS``: a # cold-start repo generates enough evidence remedies to push a # later-appended instruction off the end. - adoption_note = _adoption_instruction(capability_review, manifest_introduced) + adoption_note = _adoption_instruction( + capability_review, + pure_adoption_review, + config=config, + ) if adoption_note is not None: out.append(adoption_note) + elif manifest_introduced: + manifest = ( + f"the configured manifest {config!r}" + if config + else "the configured Shipgate manifest" + ) + out.append( + f"This PR also introduces {manifest}. A human must review that " + "adoption as a separate release-policy decision; it does not clear " + "the other gating concerns." + ) # Surface the concrete "make the hidden authority enumerable" remedies # whenever evidence is degraded — not only on the bare IE verdict. A # high-finding case elevated to review_required carries the same @@ -252,7 +385,12 @@ def _human_instructions( if capability_review.trust_root_touched: out.append( "A human must review the touched release trust root (manifest, CI " - "gate, agent instructions, or trigger catalog) before merge." + "gate, agent instructions, or trigger catalog)" + + ( + " as part of the release decision." + if manifest_introduced + else " before merge." + ) ) # List every gating finding's recommendation — a human-routed task owns the # whole decision, including findings whose routing fields were ambiguous. @@ -262,26 +400,30 @@ def _human_instructions( def _adoption_instruction( capability_review: VerifierCapabilityReview, - manifest_introduced: bool, + pure_adoption_review: bool, + *, + config: str | None = None, ) -> str | None: """The one human act a first adoption needs, or ``None``. Keyed on the adoption itself rather than on ``trust_root_touched``, which would make the instruction disappear in the one case it exists for. It - stands down when ``policy_weakened`` is set: that means an existing policy - file was also changed, so "nothing existing was weakened" is false and the - generic instructions — including the ``review_policy_weakening`` repair — - are the ones the reviewer needs. + stands down when ``policy_weakened`` is set so the generic instructions — + including the ``review_policy_weakening`` repair — remain authoritative. """ - if not manifest_introduced or capability_review.policy_weakened: + if not pure_adoption_review or capability_review.policy_weakened: return None + manifest = ( + f"the configured manifest {config!r}" + if config + else "the configured Shipgate manifest" + ) return ( - "This PR adopts Agents Shipgate: the base carries no manifest, so " - "nothing existing was weakened. Review the generated shipgate.yaml " - "(and the agent-instruction and CI files it adds) and merge them " - "through a human-reviewed PR — a coding agent cannot adopt a release " - "policy on the repository's behalf." + "This PR adopts Agents Shipgate. " + f"Review {manifest} and merge it through a human-reviewed PR — a " + "coding agent cannot adopt a release policy on the repository's " + "behalf." ) @@ -362,8 +504,26 @@ def _mechanical_repairs( gating: list[Finding], *, verification_command: str, + report_path: str, ) -> list[VerifierRepair]: repairs: list[VerifierRepair] = [] + finding_ids = sorted( + { + finding.id + for finding in gating + if finding.id is not None + } + ) + apply_parts = [ + "agents-shipgate", + "apply-patches", + "--from", + report_path, + ] + for finding_id in finding_ids: + apply_parts.extend(["--finding-id", finding_id]) + apply_parts.extend(["--confidence", "high", "--apply"]) + apply_command = " ".join(shlex.quote(part) for part in apply_parts) for finding in gating: for index, patch in enumerate(finding.patches or [], start=1): if getattr(patch, "kind", None) == "manual": @@ -379,11 +539,7 @@ def _mechanical_repairs( target=target, finding_id=finding.id, check_id=finding.check_id, - command=( - "agents-shipgate apply-patches --from " - "agents-shipgate-reports/report.json " - "--confidence high --apply" - ), + command=apply_command, reason=getattr(patch, "rationale", None) or finding.recommendation, ) @@ -402,6 +558,22 @@ def _mechanical_repairs( return [] +def _all_gating_findings_have_applicable_patches( + gating: list[Finding], +) -> bool: + """Agent routing requires one exact selectable high-confidence repair.""" + + return all( + finding.id is not None + and any( + getattr(patch, "kind", None) != "manual" + and getattr(patch, "confidence", None) == "high" + for patch in finding.patches or [] + ) + for finding in gating + ) + + def _human_repairs( report: ReadinessReport, capability_review: VerifierCapabilityReview, @@ -409,12 +581,20 @@ def _human_repairs( *, verification_command: str, manifest_introduced: bool = False, + pure_adoption_review: bool = False, config: str | None = None, ) -> list[VerifierRepair]: decision = report.release_decision assert decision is not None repairs: list[VerifierRepair] = [] - if _adoption_instruction(capability_review, manifest_introduced) is not None: + if ( + _adoption_instruction( + capability_review, + pure_adoption_review, + config=config, + ) + is not None + ): repairs.append( VerifierRepair( id="adopt_shipgate_manifest", @@ -428,6 +608,19 @@ def _human_repairs( ) ) else: + if manifest_introduced: + repairs.append( + VerifierRepair( + id="review_shipgate_adoption", + actor="human", + kind="review_trust_root_change", + target=config or "shipgate.yaml", + reason=( + "Review the proposed Shipgate adoption separately from " + "the other gating concerns." + ), + ) + ) if capability_review.policy_weakened: repairs.append( VerifierRepair( @@ -435,7 +628,14 @@ def _human_repairs( actor="human", kind="review_policy_change", target=config or "shipgate.yaml", - reason="A human must approve release-policy weakening before merge.", + reason=( + "A human must approve release-policy weakening" + + ( + " as part of the release decision." + if manifest_introduced + else " before merge." + ) + ), ) ) if capability_review.trust_root_touched: @@ -445,7 +645,14 @@ def _human_repairs( actor="human", kind="review_trust_root_change", target="manifest, CI gate, agent instructions, or trigger catalog", - reason="A human must review the touched release trust root before merge.", + reason=( + "A human must review the touched release trust root" + + ( + " as part of the release decision." + if manifest_introduced + else " before merge." + ) + ), ) ) for gap in decision.evidence_coverage.evidence_gaps: @@ -543,7 +750,10 @@ def _machine_patches(gating: list[Finding]) -> list[VerifierFixTaskPatch]: out: list[VerifierFixTaskPatch] = [] for finding in gating: for patch in finding.patches or []: - if getattr(patch, "kind", "manual") == "manual": + if ( + getattr(patch, "kind", "manual") == "manual" + or getattr(patch, "confidence", None) != "high" + ): continue out.append( VerifierFixTaskPatch( diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 970fa838..d6c0b69b 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -5,6 +5,7 @@ import re import subprocess import tempfile +import threading import unicodedata from dataclasses import dataclass from pathlib import Path @@ -13,10 +14,83 @@ import yaml +from agents_shipgate.core.boundary_registry import is_agent_boundary_path from agents_shipgate.core.errors import ConfigError +from agents_shipgate.core.trust_roots import trust_root_class_for from agents_shipgate.schemas.human_authorization import canonical_https_git_endpoint _GIT_OBJECT_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") +_WORKTREE_FILTER_CONFIG_LIMIT = 1024 * 1024 +_WORKTREE_ATTRIBUTE_LIST_LIMIT = 8 * 1024 * 1024 +_DIFF_CONFIG_LIMIT = 1024 * 1024 +_DIFF_METADATA_LIMIT = 8 * 1024 * 1024 +_DIFF_BODY_LIMIT = 32 * 1024 * 1024 +_TEXT_CAPABILITY_SUFFIXES = frozenset( + { + ".json", + ".jsonl", + ".md", + ".mdc", + ".mjs", + ".cjs", + ".js", + ".jsx", + ".py", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + } +) + +_SAFE_DIFF_CONFIG = [ + "-c", + "core.fsmonitor=false", + "-c", + "core.autocrlf=false", + "-c", + "core.safecrlf=false", + "-c", + "core.eol=lf", + "-c", + "core.bigFileThreshold=32m", + "-c", + "core.fileMode=false", + "-c", + "core.precomposeUnicode=false", + "-c", + "submodule.recurse=false", + "-c", + "core.quotePath=false", + "-c", + f"core.attributesFile={os.devnull}", + "-c", + f"diff.orderFile={os.devnull}", + "-c", + "diff.suppressBlankEmpty=false", + "-c", + "diff.renameLimit=32767", +] +_DETERMINISTIC_DIFF_OPTIONS = [ + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=dirty", + "--no-color", + "--diff-algorithm=myers", + "--no-indent-heuristic", + "--unified=3", + "--inter-hunk-context=0", + "-O", + os.devnull, + "--src-prefix=a/", + "--dst-prefix=b/", + "--find-renames=50%", + "--submodule=short", + "--full-index", +] def ensure_git_workspace(workspace: Path) -> Path: @@ -56,9 +130,16 @@ class GitPushEndpoint: def commit_sha(workspace: Path, ref: str) -> str | None: + _validate_ref_token(ref) result = _run_git( workspace, - ["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + [ + "rev-parse", + "--verify", + "--quiet", + "--end-of-options", + f"{ref}^{{commit}}", + ], check=False, ) if result.returncode != 0: @@ -255,28 +336,43 @@ def _short_sha(sha: str) -> str: def ref_exists(workspace: Path, ref: str) -> bool: - result = _run_git( - workspace, - ["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], - check=False, - ) - return result.returncode == 0 + return commit_sha(workspace, ref) is not None def tree_sha(workspace: Path, ref: str) -> str: - result = _run_git(workspace, ["rev-parse", f"{ref}^{{tree}}"]) + commit = commit_sha(workspace, ref) + if commit is None: + raise ConfigError(f"Git ref is unavailable: {ref}") + result = _run_git( + workspace, + ["rev-parse", "--verify", "--end-of-options", f"{commit}^{{tree}}"], + ) return result.stdout.strip() def merge_base_sha(workspace: Path, base: str, head: str) -> str | None: - result = _run_git(workspace, ["merge-base", base, head], check=False) + base_commit = commit_sha(workspace, base) + head_commit = commit_sha(workspace, head) + if base_commit is None or head_commit is None: + return None + result = _run_git( + workspace, + ["merge-base", "--", base_commit, head_commit], + check=False, + ) if result.returncode != 0: return None return result.stdout.strip() or None def commit_date(workspace: Path, ref: str) -> str: - return _run_git(workspace, ["show", "-s", "--format=%cs", ref]).stdout.strip() + commit = commit_sha(workspace, ref) + if commit is None: + raise ConfigError(f"Git ref is unavailable: {ref}") + return _run_git( + workspace, + ["show", "-s", "--format=%cs", "--end-of-options", commit], + ).stdout.strip() def repository_identity(workspace: Path) -> str: @@ -350,134 +446,370 @@ def git_path(workspace: Path, path: str) -> Path: def diff_context(workspace: Path, base: str, head: str) -> tuple[list[str], str]: - revspec = f"{base}...{head}" - names = _run_git(workspace, ["diff", "--name-only", revspec]) - body = _run_git(workspace, ["diff", revspec]) - paths = [line for line in names.stdout.splitlines() if line.strip()] - return paths, body.stdout + base_commit = commit_sha(workspace, base) + head_commit = commit_sha(workspace, head) + if base_commit is None or head_commit is None: + raise ConfigError("Git diff refs are unavailable locally") + revspec = f"{base_commit}...{head_commit}" + return diff_revspec_context(workspace, revspec) -def read_file_at_ref(workspace: Path, ref: str, path: Path) -> str | None: - """Return one file's text at ``ref`` without materializing the tree.""" +def diff_revspec_context(workspace: Path, revspec: str) -> tuple[list[str], str]: + """Return deterministic committed-ref diff paths and body.""" - result = _run_git( + _reject_unbound_diff_configuration(workspace) + revspec = _resolved_diff_revspec(workspace, revspec) + names = _run_git_bounded_output( workspace, - ["show", f"{ref}:{path.as_posix()}"], - check=False, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "--name-status", + "-z", + revspec, + ], + max_output_bytes=_DIFF_METADATA_LIMIT, ) - if result.returncode != 0: - return None - return result.stdout + body = _run_git_bounded_output( + workspace, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + revspec, + ], + max_output_bytes=_DIFF_BODY_LIMIT, + ) + if names is None or body is None: + raise ConfigError("Git diff exceeded static output bounds or could not be read.") + paths = sorted(_paths_from_name_status(names)) + _reject_binary_capability_paths(workspace, revspec) + diff_text = _decode_diff_body(body) + return paths, diff_text -def paths_named_at_ref( - workspace: Path, ref: str, names: frozenset[str] -) -> list[str] | None: - """Tracked paths at ``ref`` whose file name is one of ``names``. - Used to prove that a ref carries no Shipgate manifest *anywhere*, not just - at the configured path — otherwise moving the manifest to a new path would - make a modified gate look like a first adoption. +def _paths_from_name_status(payload: bytes) -> list[str]: + """Return every path named by a NUL-delimited ``git diff --name-status``. - ``None`` means the listing failed, which is not the same as an empty - result: callers must treat it as "cannot prove", never as proven absence. + Rename and copy records carry two paths. Both are part of the evaluated + change: keeping only the destination lets a protected source disappear + behind an unprotected new name before the verifier classifies it. """ - result = _run_git(workspace, ["ls-tree", "-r", "--name-only", ref], check=False) - if result.returncode != 0: + fields = payload.split(b"\0") + if fields and fields[-1] == b"": + fields.pop() + paths: list[str] = [] + index = 0 + while index < len(fields): + status = fields[index].decode("ascii", errors="strict") + index += 1 + path_count = 2 if status[:1] in {"R", "C"} else 1 + if index + path_count > len(fields): + raise ConfigError("Git returned malformed NUL-delimited diff metadata.") + for raw_path in fields[index : index + path_count]: + path = os.fsdecode(raw_path) + if path and path not in paths: + paths.append(path) + index += path_count + return paths + + +def _decode_diff_body(payload: bytes) -> str: + """Decode Git text hunks and ASCII binary markers without ambiguity.""" + try: + return payload.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise ConfigError("Git diff body is not valid UTF-8.") from exc + + +def _reject_binary_capability_paths( + workspace: Path, + revspec: str, + *, + pathspec: list[str] | None = None, +) -> None: + """Fail closed when a source-like path is hidden behind a binary marker.""" + + payload = _run_git_bounded_output( + workspace, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "--no-renames", + "--numstat", + "--no-patch", + "-z", + revspec, + *(["--", *pathspec] if pathspec else []), + ], + max_output_bytes=_DIFF_METADATA_LIMIT, + ) + if payload is None: + raise ConfigError( + "Git binary-path metadata exceeded static output bounds or could " + "not be read." + ) + hidden: list[str] = [] + for record in payload.split(b"\0"): + if not record: + continue + added, separator, remainder = record.partition(b"\t") + removed, separator_two, raw_path = remainder.partition(b"\t") + if not separator or not separator_two: + raise ConfigError("Git returned malformed NUL-delimited numstat metadata.") + if added != b"-" or removed != b"-": + continue + path = os.fsdecode(raw_path) + if ( + Path(path).suffix.casefold() in _TEXT_CAPABILITY_SUFFIXES + or is_agent_boundary_path(path) + or trust_root_class_for(path) is not None + ): + hidden.append(path) + if hidden: + raise ConfigError( + "Git classified source-like changed paths as binary, so their " + "capability text cannot be evaluated statically: " + + ", ".join(sorted(hidden)[:3]) + ) + + +def read_file_at_ref(workspace: Path, ref: str, path: Path) -> str | None: + """Return one file's text at ``ref`` without materializing the tree.""" + + commit = commit_sha(workspace, ref) + if commit is None: + return None + payload = _run_git_bounded_output( + workspace, + ["show", f"{commit}:{path.as_posix()}"], + max_output_bytes=_MAX_MANIFEST_BYTES, + ) + if payload is None: + return None + try: + return payload.decode("utf-8", errors="strict") + except UnicodeDecodeError: return None - return [ - line - for line in result.stdout.splitlines() - if line.strip() and line.rsplit("/", maxsplit=1)[-1] in names - ] -# Suffixes a Shipgate manifest can plausibly carry. A rename away from one of -# these is the shape the adoption claim must not survive. +def resolve_tree_path_identity( + workspace: Path, + ref: str, + requested: Path, +) -> Path | None: + """Return the exact Git-tree spelling corresponding to ``requested``. + + Filesystems such as default APFS can materialize an NFC Git pathname with + an NFD directory-entry spelling. Verification must scan the local entry, + but receipts and archived-tree lookups must retain the exact Git identity + so they reproduce on case-sensitive, normalization-sensitive hosts. + + A portable-key collision is rejected rather than guessed. The archive + materializer enforces the same collision rule before writing any tree. + """ + + commit = commit_sha(workspace, ref) + if commit is None: + return None + requested_text = requested.as_posix() + listing = _run_git_bounded_output( + workspace, + ["ls-tree", "-r", "--name-only", "-z", commit], + max_output_bytes=_MAX_MANIFEST_LISTING_BYTES, + ) + if listing is None: + raise ConfigError( + "Git tree path identity could not be established within static " + "resource bounds." + ) + try: + paths = [ + raw.decode("utf-8", errors="strict") + for raw in listing.split(b"\0") + if raw + ] + except UnicodeDecodeError as exc: + raise ConfigError("Git tree contains a non-UTF-8 path") from exc + requested_key = _portable_tree_path_key(requested_text) + matches = [path for path in paths if _portable_tree_path_key(path) == requested_key] + if len(matches) > 1: + raise ConfigError( + "Git tree contains filesystem-colliding paths for configured " + f"manifest {requested_text!r}: {matches!r}" + ) + if not matches: + return None + matched = Path(matches[0]) + try: + requested_stat = (workspace / requested).stat(follow_symlinks=False) + matched_stat = (workspace / matched).stat(follow_symlinks=False) + except OSError: + return None + # A portable-key match is only an alias hint. On case-sensitive hosts, + # distinct files can legitimately differ only by case, normalization, or + # trailing spaces; rebinding them would hash one file under another path. + return matched if os.path.samestat(requested_stat, matched_stat) else None + +# Suffixes retained for the independent rename/deletion guard. Manifest +# discovery itself is deliberately suffix-agnostic because ``load_manifest`` +# accepts YAML content from any filename. _MANIFEST_SUFFIXES = (".yaml", ".yml") -# Bounds on the retained-manifest probe. A tree with more candidate YAML files -# than this, or a candidate larger than this, is not worth reading to decide a +# Bounds on the retained-manifest probe. A tree with more tracked files than +# this, or a candidate larger than this, is not worth reading to decide a # wording question — the probe reports "cannot prove" and the plainer copy wins. -_MAX_MANIFEST_CANDIDATES = 400 +# The process cost is now constant (one tree listing plus one cat-file batch), +# so ordinary repositories should not lose adoption detection merely because +# they contain a few hundred small tracked files. These are resource-safety +# bounds, not expected repository sizes. +_MAX_MANIFEST_CANDIDATES = 10_000 _MAX_MANIFEST_BYTES = 512 * 1024 +_MAX_MANIFEST_BATCH_BYTES = 64 * 1024 * 1024 +_MAX_MANIFEST_LISTING_BYTES = 8 * 1024 * 1024 # The keys every Shipgate manifest must carry. Matching on parsed structure # rather than on raw text is what makes quoted keys, differing indentation, and # flow mappings all read the same. -_MANIFEST_REQUIRED_KEYS = frozenset({"project", "agent"}) +_MANIFEST_REQUIRED_KEYS = frozenset({"version", "project", "agent"}) -def carries_manifest_like_yaml(workspace: Path, ref: str) -> bool | None: - """Whether ``ref`` contains any YAML that parses as a Shipgate manifest. +def carries_manifest_like_yaml( + workspace: Path, + ref: str, + *, + protected_names: frozenset[str] = frozenset(), +) -> bool | None: + """Whether ``ref`` contains any file that parses as a Shipgate manifest. A basename check cannot prove a base carries no gate: a manifest may be - called anything, so a base that keeps an operational ``old-gate.yml`` while + called anything, so a base that keeps an operational ``old-gate.json`` while the head adds ``new-gate.yml`` passes every name test. - The candidates are *parsed*, not grepped. A text probe for ``^project:`` - misses a valid manifest whose keys are quoted, indented, or written in flow - style — and a manifest that loads fine while the probe says "absent" is the - fail-open this exists to prevent. Over-matching is deliberate: an unrelated - YAML carrying both keys merely costs the adoption wording. - - ``None`` means the answer could not be established — an unreadable tree, an - unparseable candidate, or more candidates than the bounds above allow. - Callers must treat it as "cannot prove", never as absence. + Every tracked filename is eligible because :func:`load_manifest` selects + its parser by content, not suffix. The candidates are *parsed*, not + grepped. A text probe for ``^project:`` misses a valid manifest whose keys + are quoted, indented, or written in flow style — and a manifest that loads + fine while the probe says "absent" is the fail-open this exists to prevent. + Over-matching is deliberate: an unrelated document carrying both keys + merely costs the adoption wording. ``protected_names`` provides the + independent canonical-name guard from this same bounded tree listing, so + callers never need a second unbounded ``ls-tree`` process. + + ``None`` means the answer could not be established — an unreadable tree or + a tree/candidate beyond the bounds above. Files that are not UTF-8 YAML + objects are safely skipped because ``load_manifest`` cannot accept them. + Callers must treat ``None`` as "cannot prove", never as absence. """ - listing = _run_git(workspace, ["ls-tree", "-r", "--name-only", ref], check=False) - if listing.returncode != 0: + commit = commit_sha(workspace, ref) + if commit is None: return None - # Filtered here rather than with a pathspec: `git ls-tree -- '*.yml'` does - # not glob the way it reads — it matches nothing, which would make this - # probe silently answer "no manifest" for every tree. - candidates = [ - line.strip() - for line in listing.stdout.splitlines() - if line.strip().lower().endswith(_MANIFEST_SUFFIXES) - ] - if len(candidates) > _MAX_MANIFEST_CANDIDATES: + listing = _run_git_bounded_output( + workspace, + ["ls-tree", "-r", "-l", "-z", commit], + max_output_bytes=_MAX_MANIFEST_LISTING_BYTES, + ) + if listing is None: return None - for candidate in candidates: - object_name = f"{ref}:{candidate}" - size = _run_git( - workspace, - ["cat-file", "-s", object_name], - check=False, - ) - if size.returncode != 0: + records = [record for record in listing.split(b"\0") if record] + if len(records) > _MAX_MANIFEST_CANDIDATES: + return None + candidates: list[tuple[bytes, str, int]] = [] + total_bytes = 0 + for record in records: + try: + header, encoded_path = record.split(b"\t", 1) + _mode, object_type, object_id, encoded_size = header.split() + candidate = encoded_path.decode("utf-8") + except (UnicodeDecodeError, ValueError): return None + # Recursive ls-tree can still contain a submodule commit. It is not a + # file the manifest loader could open, so it is outside this probe. + if object_type != b"blob": + continue + if candidate.rsplit("/", maxsplit=1)[-1] in protected_names: + return True try: - byte_count = int(size.stdout.strip()) + byte_count = int(encoded_size) except (TypeError, ValueError): return None if byte_count > _MAX_MANIFEST_BYTES: return None - blob = _run_git( + total_bytes += byte_count + if total_bytes > _MAX_MANIFEST_BATCH_BYTES: + return None + candidates.append((object_id, candidate, byte_count)) + + if not candidates: + return False + + # Object IDs from ls-tree, rather than ref:path expressions, keep the + # batch protocol unambiguous even for unusual Git filenames. One bounded + # cat-file process replaces the former size+show pair per tracked file. + try: + batch = _run_git( workspace, - ["show", object_name], + ["cat-file", "--batch"], check=False, text=False, + input=b"".join( + object_id + b"\n" for object_id, _path, _size in candidates + ), ) - if blob.returncode != 0: + except (OSError, subprocess.TimeoutExpired): + return None + if batch.returncode != 0 or not isinstance(batch.stdout, bytes): + return None + + output = batch.stdout + offset = 0 + for expected_id, candidate, expected_size in candidates: + header_end = output.find(b"\n", offset) + if header_end < 0: + return None + try: + object_id, object_type, encoded_size = output[offset:header_end].split() + byte_count = int(encoded_size) + except (TypeError, ValueError): + return None + if ( + object_id != expected_id + or object_type != b"blob" + or byte_count != expected_size + ): return None - if not isinstance(blob.stdout, bytes) or len(blob.stdout) != byte_count: + content_start = header_end + 1 + content_end = content_start + byte_count + if content_end >= len(output) or output[content_end : content_end + 1] != b"\n": return None + blob = output[content_start:content_end] + offset = content_end + 1 try: - text = blob.stdout.decode("utf-8") + text = blob.decode("utf-8") except UnicodeDecodeError: - return None + if candidate.lower().endswith(_MANIFEST_SUFFIXES): + return None + continue try: document = yaml.safe_load(text) - except (RecursionError, yaml.YAMLError): - # Unparseable YAML cannot be ruled out as a manifest. - return None + except (RecursionError, ValueError, OverflowError, yaml.YAMLError): + if candidate.lower().endswith(_MANIFEST_SUFFIXES): + return None + # The real manifest loader rejects the same input. Preserve the + # long-standing fail-closed contract for declared YAML files while + # allowing unrelated source/binary files to be ruled out. + continue if isinstance(document, dict) and _MANIFEST_REQUIRED_KEYS <= { str(key) for key in document }: return True + if offset != len(output): + return None return False @@ -494,22 +826,52 @@ def removes_a_yaml_file(workspace: Path, base: str | None, head: str) -> bool | not claim an adoption. """ - args = ["diff", "--name-status", "--find-renames", "--diff-filter=DR"] - args.extend([f"{base}...{head}"] if base else ["HEAD"]) - result = _run_git(workspace, args, check=False) - if result.returncode != 0: + try: + _reject_unbound_diff_configuration(workspace) + if base is None: + _reject_executable_worktree_filters(workspace) + except ConfigError: return None - for line in result.stdout.splitlines(): - fields = [field for field in line.split("\t") if field.strip()] - if len(fields) < 2: - continue - source = fields[1].replace("\\", "/") - if source.lower().endswith(_MANIFEST_SUFFIXES): - return True - return False + args = [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "--name-status", + "--diff-filter=DR", + "-z", + ] + head_commit = commit_sha(workspace, head) + if head_commit is None: + return None + if base: + base_commit = commit_sha(workspace, base) + if base_commit is None: + return None + args.append(f"{base_commit}...{head_commit}") + else: + args.append(head_commit) + output = _run_git_bounded_output( + workspace, + args, + max_output_bytes=_DIFF_METADATA_LIMIT, + ) + if output is None: + return None + try: + paths = _paths_from_name_status(output) + except (UnicodeDecodeError, ConfigError): + return None + # Checking both rename sides is deliberately conservative: a destination + # YAML path can only suppress friendly adoption wording, never grant it. + return any(path.lower().endswith(_MANIFEST_SUFFIXES) for path in paths) -def working_tree_context(workspace: Path) -> tuple[list[str], str]: +def working_tree_context( + workspace: Path, + *, + exclude: Path | None = None, + reject_index_hidden: bool = False, +) -> tuple[list[str], str]: """Return uncommitted changed paths and tracked-file diff text. ``git diff HEAD`` includes staged and unstaged tracked changes. Untracked @@ -517,15 +879,242 @@ def working_tree_context(workspace: Path) -> tuple[list[str], str]: intentionally not read into the diff body. """ - names = _run_git(workspace, ["diff", "HEAD", "--name-only"]) - body = _run_git(workspace, ["diff", "HEAD"]) - paths = [line for line in names.stdout.splitlines() if line.strip()] - untracked = _run_git(workspace, ["ls-files", "--others", "--exclude-standard"]) - for line in untracked.stdout.splitlines(): - stripped = line.strip() - if stripped and stripped not in paths: - paths.append(stripped) - return paths, body.stdout + _reject_unbound_diff_configuration(workspace) + _reject_executable_worktree_filters(workspace) + pathspec = _worktree_pathspec(workspace, exclude) + if reject_index_hidden: + _reject_index_hidden_capability_paths(workspace, pathspec=pathspec) + names = _run_git_bounded_output( + workspace, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "HEAD", + "--name-status", + "-z", + "--", + *pathspec, + ], + max_output_bytes=_DIFF_METADATA_LIMIT, + ) + body = _run_git_bounded_output( + workspace, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "HEAD", + "--", + *pathspec, + ], + max_output_bytes=_DIFF_BODY_LIMIT, + ) + if names is None or body is None: + raise ConfigError( + "Git worktree diff exceeded static output bounds or could not be read." + ) + paths = sorted(_paths_from_name_status(names)) + _reject_binary_capability_paths(workspace, "HEAD", pathspec=pathspec) + diff_text = _decode_diff_body(body) + untracked = _run_git_bounded_output( + workspace, + [ + *_SAFE_DIFF_CONFIG, + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + *pathspec, + ], + max_output_bytes=_DIFF_METADATA_LIMIT, + ) + if untracked is None: + raise ConfigError( + "Git untracked-path inventory exceeded static output bounds." + ) + for raw_path in untracked.split(b"\0"): + if not raw_path: + continue + path = os.fsdecode(raw_path) + if path not in paths: + paths.append(path) + return paths, diff_text + + +def _worktree_pathspec(workspace: Path, exclude: Path | None) -> list[str]: + pathspec = [":(top)**"] + if exclude is None: + return pathspec + try: + relative = exclude.resolve().relative_to(workspace.resolve()).as_posix() + except ValueError as exc: + raise ConfigError("Verifier output directory must remain inside workspace") from exc + if relative in {"", "."}: + raise ConfigError("Verifier output directory cannot be the workspace root") + # ``--out`` is user-controlled. Literal magic prevents names such as + # ``*`` or ``[x]`` from becoming exclusion patterns that hide unrelated + # worktree changes. Matching the directory path excludes its descendants + # under Git's ordinary pathspec directory-prefix semantics. + pathspec.append(f":(top,literal,exclude){relative}") + return pathspec + + +def _reject_index_hidden_capability_paths( + workspace: Path, + *, + pathspec: list[str], +) -> None: + """Reject index flags that can conceal worktree inputs. + + Declared tool sources may use arbitrary extensions, so path heuristics + cannot prove a hidden entry irrelevant before the manifest is evaluated. + Reject every hidden entry in the bounded pathspec. + """ + + payload = _run_git_bounded_output( + workspace, + ["ls-files", "-v", "-z", "--", *pathspec], + max_output_bytes=_DIFF_METADATA_LIMIT, + ) + if payload is None: + raise ConfigError( + "Git index-visibility metadata exceeded static output bounds." + ) + hidden: list[str] = [] + for record in payload.split(b"\0"): + if not record: + continue + marker, separator, raw_path = record.partition(b" ") + if not separator or len(marker) != 1: + raise ConfigError("Git returned malformed index-visibility metadata.") + code = chr(marker[0]) + if code != "S" and not code.islower(): + continue + hidden.append(os.fsdecode(raw_path)) + if hidden: + raise ConfigError( + "Git index flags hide paths from worktree collection: " + + ", ".join(sorted(hidden)[:3]) + ) + + +def _reject_executable_worktree_filters(workspace: Path) -> None: + """Fail before Git can execute a configured clean/process filter.""" + + payload = _run_git_bounded_output( + workspace, + [ + "config", + "--includes", + "-z", + "--get-regexp", + r"^filter\..*\.(clean|process|smudge)$", + ], + max_output_bytes=_WORKTREE_FILTER_CONFIG_LIMIT, + allowed_returncodes=(0, 1), + ) + if payload is None: + raise ConfigError( + "Could not establish whether repository Git clean/process filters " + "are safe for static worktree collection." + ) + active: list[str] = [] + for record in payload.split(b"\0"): + if not record: + continue + raw_key, separator, raw_value = record.partition(b"\n") + if not separator: + raise ConfigError("Git returned malformed filter configuration.") + if raw_value.strip(): + active.append(raw_key.decode("utf-8", errors="replace")) + if active: + shown = ", ".join(sorted(active)[:3]) + raise ConfigError( + "Static worktree collection refuses executable Git " + f"filters ({shown}). Commit the intended changes and verify refs, " + "or provide an explicit inert diff artifact." + ) + attributed = _run_git_bounded_output( + workspace, + [ + *_SAFE_DIFF_CONFIG, + "ls-files", + "-z", + "--", + ":(top)**", + ":(exclude,attr:!filter)", + ":(exclude,attr:-filter)", + ], + max_output_bytes=_WORKTREE_ATTRIBUTE_LIST_LIMIT, + ) + if attributed is None: + raise ConfigError( + "Git filter-attribute metadata exceeded static resource bounds or " + "could not be inspected safely." + ) + attributed_paths = [ + os.fsdecode(raw) for raw in attributed.split(b"\0") if raw + ] + if attributed_paths: + shown = ", ".join(sorted(attributed_paths)[:3]) + raise ConfigError( + "Static worktree collection refuses Git filter attributes because " + f"their normalization driver is not receipt-bound ({shown}). " + "Commit the intended changes and verify refs instead." + ) + + +def _reject_unbound_diff_configuration(workspace: Path) -> None: + """Reject repository-local presentation state that can rewrite Git diffs.""" + + configured = _run_git_bounded_output( + workspace, + [ + "config", + "--includes", + "-z", + "--get-regexp", + r"^diff\.", + ], + max_output_bytes=_DIFF_CONFIG_LIMIT, + allowed_returncodes=(0, 1), + ) + if configured is None: + raise ConfigError( + "Could not establish whether repository-local Git diff drivers are " + "safe for deterministic collection." + ) + if any(record.partition(b"\n")[2].strip() for record in configured.split(b"\0")): + raise ConfigError( + "Deterministic diff collection refuses repository-local diff.* " + "configuration because it is not receipt-bound." + ) + + raw_info_path = _run_git( + workspace, + ["rev-parse", "--git-path", "info/attributes"], + check=False, + ) + if raw_info_path.returncode != 0 or not raw_info_path.stdout.strip(): + raise ConfigError("Could not inspect repository-local Git attributes.") + info_path = Path(raw_info_path.stdout.strip()) + if not info_path.is_absolute(): + info_path = workspace / info_path + try: + metadata = info_path.lstat() + except FileNotFoundError: + return + except OSError as exc: + raise ConfigError( + "Could not inspect repository-local Git attributes safely." + ) from exc + if not info_path.is_file() or info_path.is_symlink() or metadata.st_size: + raise ConfigError( + "Deterministic diff collection refuses non-empty or aliased " + ".git/info/attributes because it is not receipt-bound." + ) def archive_tree(workspace: Path, ref: str, destination: Path) -> None: @@ -628,13 +1217,7 @@ def _materialize_isolated_tree(git_dir: Path, *, commit: str, destination: Path) path_text = raw_path.decode("utf-8", errors="strict") if "\\" in path_text: raise ConfigError(f"Git tree path is not portable: {path_text}") - portable_parts = [ - unicodedata.normalize("NFKC", part).casefold().rstrip(" .") - for part in path_text.split("/") - ] - if any(not part for part in portable_parts): - raise ConfigError(f"Git tree path is not portable: {path_text}") - portable_key = "/".join(portable_parts) + portable_key = _portable_tree_path_key(path_text) prior = portable_paths.setdefault(portable_key, path_text) if prior != path_text: raise ConfigError( @@ -672,6 +1255,36 @@ def _materialize_isolated_tree(git_dir: Path, *, commit: str, destination: Path) raise ConfigError("Materialized Git tree differs from the verified object graph") +def _portable_tree_path_key(path_text: str) -> str: + if ( + "\\" in path_text + or ":" in path_text + or path_text.startswith("/") + or any(ord(character) < 32 for character in path_text) + ): + raise ConfigError(f"Git tree path is not portable: {path_text}") + raw_parts = path_text.split("/") + portable_parts: list[str] = [] + reserved = {"con", "prn", "aux", "nul"} + reserved.update(f"com{number}" for number in range(1, 10)) + reserved.update(f"lpt{number}" for number in range(1, 10)) + for raw_part in raw_parts: + part = unicodedata.normalize("NFKC", raw_part).casefold() + portable = part.rstrip(" .") + basename = portable.split(".", 1)[0] + if ( + not portable + or raw_part in {".", ".."} + or raw_part != raw_part.rstrip(" .") + or basename in reserved + ): + raise ConfigError(f"Git tree path is not portable: {path_text}") + portable_parts.append(portable) + if not portable_parts: + raise ConfigError(f"Git tree path is not portable: {path_text}") + return "/".join(portable_parts) + + def _git_object_id(kind: str, data: bytes, *, algorithm: str) -> str: digest = hashlib.new(algorithm) digest.update(f"{kind} {len(data)}\0".encode("ascii")) @@ -679,17 +1292,58 @@ def _git_object_id(kind: str, data: bytes, *, algorithm: str) -> str: return digest.hexdigest() +def _validate_ref_token(ref: str) -> None: + if not ref or ref.startswith("-") or any(char in ref for char in "\0\r\n"): + raise ConfigError( + "Git refs must be non-empty, must not begin with '-', and must not " + "contain control delimiters." + ) + + +def _resolved_diff_revspec(workspace: Path, revspec: str) -> str: + """Resolve a user rev expression to option-safe commit IDs.""" + + _validate_ref_token(revspec) + if "..." in revspec: + parts = revspec.split("...") + separator = "..." + elif ".." in revspec: + parts = revspec.split("..") + separator = ".." + else: + parts = [revspec] + separator = "" + # ``git diff `` compares that commit with the index/worktree, + # so it carries the same repository-configured execution hazards as + # the explicit worktree collector. + _reject_executable_worktree_filters(workspace) + if len(parts) not in {1, 2} or any(not part for part in parts): + raise ConfigError(f"Unsupported Git diff revision expression: {revspec!r}") + commits = [commit_sha(workspace, part) for part in parts] + if any(commit is None for commit in commits): + raise ConfigError(f"Git diff revision is unavailable: {revspec!r}") + return separator.join(commit for commit in commits if commit is not None) + + def _git_object_environment() -> dict[str, str]: - env = os.environ.copy() + env = { + key: value + for key, value in os.environ.items() + if not key.startswith("GIT_") + } env.update( { - "GIT_CONFIG_NOSYSTEM": "1", + "GIT_ATTR_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_SYSTEM": os.devnull, "GIT_ALLOW_PROTOCOL": "", "GIT_NO_LAZY_FETCH": "1", "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "GIT_PAGER": "cat", "GIT_PROTOCOL_FROM_USER": "0", + "GIT_TERMINAL_PROMPT": "0", } ) return env @@ -712,28 +1366,110 @@ def _run_git_dir( ) +def _run_git_bounded_output( + workspace: Path, + args: list[str], + *, + max_output_bytes: int, + timeout: int = 60, + allowed_returncodes: tuple[int, ...] = (0,), + input: bytes | None = None, +) -> bytes | None: + """Run read-only Git plumbing without buffering unbounded stdout.""" + + cmd = ["git", "--no-replace-objects", "-C", str(workspace), *args] + env = _git_object_environment() + try: + process = subprocess.Popen( # noqa: S603 - fixed local Git argv, no shell. + cmd, + env=env, + stderr=subprocess.DEVNULL, + stdin=subprocess.PIPE if input is not None else subprocess.DEVNULL, + stdout=subprocess.PIPE, + ) + except OSError: + return None + + output = bytearray() + exceeded = False + read_failed = False + + def _drain_stdout() -> None: + nonlocal exceeded, read_failed + assert process.stdout is not None + try: + while chunk := process.stdout.read(64 * 1024): + remaining = max_output_bytes + 1 - len(output) + if remaining > 0: + output.extend(chunk[:remaining]) + if len(output) > max_output_bytes: + exceeded = True + try: + process.kill() + except OSError: + pass + return + except OSError: + read_failed = True + + reader = threading.Thread(target=_drain_stdout, daemon=True) + reader.start() + write_failed = False + + def _write_stdin() -> None: + nonlocal write_failed + assert process.stdin is not None + try: + process.stdin.write(input or b"") + process.stdin.close() + except (BrokenPipeError, OSError): + write_failed = True + + writer = ( + threading.Thread(target=_write_stdin, daemon=True) + if input is not None + else None + ) + if writer is not None: + writer.start() + try: + returncode = process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + reader.join() + if writer is not None: + writer.join() + return None + reader.join() + if writer is not None: + writer.join() + if ( + returncode not in allowed_returncodes + or exceeded + or read_failed + or write_failed + ): + return None + return bytes(output) + + def _run_git( workspace: Path, args: list[str], *, check: bool = True, text: bool = True, + input: bytes | None = None, ) -> subprocess.CompletedProcess: cmd = ["git", "--no-replace-objects", "-C", str(workspace), *args] - env = os.environ.copy() - env.update( - { - "GIT_ALLOW_PROTOCOL": "", - "GIT_NO_LAZY_FETCH": "1", - "GIT_NO_REPLACE_OBJECTS": "1", - "GIT_PROTOCOL_FROM_USER": "0", - } - ) + env = _git_object_environment() return _run_process( cmd, capture_output=True, check=check, env=env, + input=input, text=text, timeout=60, ) @@ -782,7 +1518,18 @@ def staged_paths_under(workspace: Path, subdir: str) -> list[str]: """ prefix = subdir.rstrip("/") + "/" - result = _run_git(workspace, ["diff", "--cached", "--name-only", "--relative"], check=False) + result = _run_git( + workspace, + [ + *_SAFE_DIFF_CONFIG, + "diff", + *_DETERMINISTIC_DIFF_OPTIONS, + "--cached", + "--name-only", + "--relative", + ], + check=False, + ) if result.returncode != 0: return [] return [line.strip() for line in result.stdout.splitlines() if line.strip().startswith(prefix)] @@ -797,12 +1544,14 @@ def staged_paths_under(workspace: Path, subdir: str) -> list[str]: "detect_default_base", "detect_default_base_with_notes", "diff_context", + "diff_revspec_context", "ensure_git_workspace", "git_path", "GitPushEndpoint", "merge_base_sha", "read_file_at_ref", "repository_identity", + "resolve_tree_path_identity", "resolve_git_push_endpoint", "resolve_source_head_identity", "ref_exists", diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index d450bb2f..cd385f43 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -7,6 +7,7 @@ import re import shlex import shutil +import stat import tempfile from collections import Counter from datetime import date @@ -37,6 +38,18 @@ default_human_authorization_trust_policy_path, evaluate_human_authorization, ) +from agents_shipgate.core.static_inputs import ( + StaticInputSnapshot, + activate_static_input_snapshot, + active_static_input_snapshot, + read_static_input_bytes, + reset_static_input_snapshot, +) +from agents_shipgate.core.trust_roots import ( + inspect_lexical_path_identity, + is_configured_manifest, + read_identity_bound_text, +) from agents_shipgate.core.verification_identity import ( build_executor, build_terminal_receipt, @@ -83,7 +96,7 @@ from agents_shipgate.triggers import evaluate from .capability_review import build_capability_review -from .fix_task import FORBIDDEN_SHORTCUTS, build_fix_task +from .fix_task import FORBIDDEN_SHORTCUTS, build_fix_task, is_pure_adoption_review from .git import ( active_replace_refs, archive_tree, @@ -95,12 +108,12 @@ ensure_git_workspace, git_path, merge_base_sha, - paths_named_at_ref, read_file_at_ref, ref_exists, removes_a_yaml_file, repository_identity, resolve_source_head_identity, + resolve_tree_path_identity, tree_sha, working_tree_context, ) @@ -112,6 +125,8 @@ DEFAULT_OUT_DIR = Path("agents-shipgate-reports") BASE_CACHE_KEEP_ENTRIES = 16 MAX_HUMAN_AUTHORIZATION_BYTES = 1024 * 1024 +MAX_WORKTREE_MANIFEST_BYTES = 4 * 1024 * 1024 +MAX_WORKTREE_CHANGED_FILE_BYTES = 64 * 1024 * 1024 def run_verify( @@ -138,15 +153,56 @@ def run_verify( authorization: Path | None = None, ) -> tuple[VerifierArtifact, ReadinessReport | None, int]: git_root = ensure_git_workspace(workspace.resolve()) - config_path = _resolve_under_workspace(git_root, config) - config_relative = _relative_to_workspace(git_root, config_path, "--config") + config_path, config_relative = _resolve_config_under_workspace( + git_root, + config, + requested_workspace=workspace, + ) out_dir = _resolve_under_workspace(git_root, out or DEFAULT_OUT_DIR) - baseline_path = _resolve_under_workspace(git_root, baseline) if baseline else None + baseline_path = ( + _resolve_static_input_path( + git_root, + baseline, + label="--baseline", + ) + if baseline + else None + ) policy_pack_paths = ( - [_resolve_under_workspace(git_root, path) for path in policy_packs] + [ + _resolve_static_input_path( + git_root, + path, + label="--policy-pack", + ) + for path in policy_packs + ] if policy_packs else None ) + static_diff_from_path = ( + _resolve_static_input_path( + git_root, + diff_from, + label="--diff-from", + ) + if diff_from is not None + else None + ) + _reject_output_input_overlap( + git_root=git_root, + out_dir=out_dir, + inputs=[ + ("config", config_path), + *([("baseline", baseline_path)] if baseline_path is not None else []), + *[("policy pack", path) for path in (policy_pack_paths or [])], + *( + [("diff-from", static_diff_from_path)] + if static_diff_from_path is not None + else [] + ), + ], + ) out_dir.mkdir(parents=True, exist_ok=True) _clear_trusted_handoff(out_dir) verifier_path = out_dir / "verifier.json" @@ -173,6 +229,15 @@ def run_verify( ) if not config_path.is_file(): + preview_command = _preview_verify_command( + workspace=git_root, + config=config_relative, + base=base, + head=head if archive_head else None, + out=out, + pr_comment_style=pr_comment_style, + preview=True, + ) trigger = evaluate( paths=[], diff_text="", @@ -204,10 +269,11 @@ def run_verify( headline_override=message, first_next_action_override=CodingAgentCommandAction( kind="configure", - command="agents-shipgate verify --preview --json", + command=preview_command, why=( "Shipgate could not find the configured manifest; run verify " - "preview, then correct --config or initialize shipgate.yaml." + "preview, then correct --config or initialize the intended " + "manifest." ), ), worktree=not archive_head, @@ -222,6 +288,7 @@ def run_verify( report=None, git_root=git_root, config_path=config_path, + config_logical_path=config_relative.as_posix(), baseline_path=baseline_path, policy_pack_paths=policy_pack_paths or [], plugins_enabled=plugins_enabled, @@ -239,6 +306,7 @@ def run_verify( base_capability_lock: CapabilityLockFileV1 | None = None base_notes: list[str] = [] diff_unavailable = False + diff_failure_action: HumanControlAction | None = None head_exists = ref_exists(git_root, head) if not head_exists: @@ -284,6 +352,7 @@ def run_verify( report=None, git_root=git_root, config_path=config_path, + config_logical_path=config_relative.as_posix(), baseline_path=baseline_path, policy_pack_paths=policy_pack_paths or [], plugins_enabled=plugins_enabled, @@ -293,6 +362,28 @@ def run_verify( ) return verifier, None, 2 + tree_config_relative = resolve_tree_path_identity( + git_root, + head, + config_relative, + ) + if tree_config_relative is not None: + config_relative = tree_config_relative + + worktree_manifest_text: str | None = None + if not archive_head: + try: + worktree_manifest_text = read_identity_bound_text( + git_root, + config_relative, + max_bytes=MAX_WORKTREE_MANIFEST_BYTES, + ) + except (OSError, UnicodeDecodeError, ValueError) as exc: + raise ConfigError( + f"Configured manifest {config_relative.as_posix()} could not be " + f"captured as one identity-bound worktree input: {exc}" + ) from exc + verification_date = commit_date(git_root, head) if base is None and auto_base: @@ -314,7 +405,16 @@ def run_verify( except Exception as exc: # noqa: BLE001 - diff context degrades only. diff_unavailable = True base_status = "archive_failed" - base_notes.append(f"Could not collect diff for {base}...{head}: {exc}") + detail = f"Could not collect diff for {base}...{head}: {exc}" + base_notes.append(detail) + diff_failure_action = HumanControlAction( + kind="review", + why=( + f"{detail}. The refs are present; fetching cannot repair " + "this deterministic input failure. Inspect the reported " + "Git configuration/resource issue before rerunning." + ), + ) else: diff_unavailable = True base_status = "ref_missing" @@ -325,12 +425,31 @@ def run_verify( if not archive_head: try: - worktree_paths, worktree_diff = working_tree_context(git_root) + worktree_paths, worktree_diff = working_tree_context( + git_root, + exclude=out_dir, + reject_index_hidden=True, + ) changed_files = _dedupe_paths([*changed_files, *worktree_paths]) diff_text = _join_diff_text(diff_text, worktree_diff) + changed_files = _bind_worktree_config_to_head( + git_root=git_root, + head=head, + config_relative=config_relative, + worktree_text=worktree_manifest_text, + changed_files=changed_files, + ) except Exception as exc: # noqa: BLE001 - local context degrades only. diff_unavailable = True - base_notes.append(f"Could not collect working-tree diff context: {exc}") + detail = f"Could not collect working-tree diff context: {exc}" + base_notes.append(detail) + diff_failure_action = HumanControlAction( + kind="review", + why=( + f"{detail}. Inspect the deterministic worktree-input " + "failure before rerunning; fetching refs cannot repair it." + ), + ) trigger = evaluate( paths=changed_files, @@ -380,6 +499,7 @@ def run_verify( head_exit_code=2, out_dir=out_dir, ci_mode=ci_mode, + first_next_action_override=diff_failure_action, worktree=not archive_head, rerun_options=rerun_options, ) @@ -391,6 +511,7 @@ def run_verify( report=None, git_root=git_root, config_path=config_path, + config_logical_path=config_relative.as_posix(), baseline_path=baseline_path, policy_pack_paths=policy_pack_paths or [], plugins_enabled=plugins_enabled, @@ -431,6 +552,7 @@ def run_verify( report=None, git_root=git_root, config_path=config_path, + config_logical_path=config_relative.as_posix(), baseline_path=baseline_path, policy_pack_paths=policy_pack_paths or [], plugins_enabled=plugins_enabled, @@ -442,7 +564,8 @@ def run_verify( if diff_from is not None: base_status = "diff_from_provided" - base_report = _resolve_under_workspace(git_root, diff_from) + assert static_diff_from_path is not None + base_report = static_diff_from_path base_notes.append(f"Using explicit diff reference: {_display_path(base_report, git_root)}") elif base and base_exists: ( @@ -490,6 +613,55 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: nonlocal head_capability_lock head_capability_lock = lock + static_snapshot: StaticInputSnapshot | None = None + static_snapshot_token = None + if not archive_head: + assert worktree_manifest_text is not None + external_snapshot_paths = [ + path + for path in [ + baseline_path, + *list(policy_pack_paths or []), + static_diff_from_path, + ] + if path is not None + ] + static_snapshot = StaticInputSnapshot( + git_root, + external_paths=external_snapshot_paths, + ) + static_snapshot.preload( + config_path, + worktree_manifest_text.encode("utf-8"), + ) + for relative in changed_files: + candidate = Path( + os.path.abspath( + os.path.normpath(os.fspath(git_root / relative)) + ) + ) + try: + metadata = candidate.lstat() + except FileNotFoundError: + continue + except OSError as exc: + raise ConfigError( + f"Changed worktree input {relative!r} could not be inspected: {exc}" + ) from exc + if not stat.S_ISREG(metadata.st_mode): + continue + try: + static_snapshot.read_bytes( + candidate, + max_bytes=MAX_WORKTREE_CHANGED_FILE_BYTES, + ) + except (OSError, ValueError) as exc: + raise ConfigError( + f"Changed worktree input {relative!r} could not be captured " + f"for verification: {exc}" + ) from exc + static_snapshot_token = activate_static_input_snapshot(static_snapshot) + try: if archive_head: head_tmp = tempfile.TemporaryDirectory(prefix="agents-shipgate-verify-head-") @@ -498,6 +670,12 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: head_input_root = head_tree_dir head_tree = tree_sha(git_root, head) head_config_path = head_tree_dir / config_relative + _reject_symlink_components( + head_tree_dir, + config_relative, + label=f"Head manifest {config_relative.as_posix()}", + allow_filesystem_alias=True, + ) if not head_config_path.is_file(): raise ConfigError( f"Head tree {head!r} does not contain {config_relative.as_posix()}." @@ -538,10 +716,17 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: manifest_introduced=manifest_introduced, ), capability_lock_callback=capture_capability_lock, + manifest_text=worktree_manifest_text if not archive_head else None, ) head_exit_code = _apply_strict_plugins( report, head_exit_code, strict_plugins=strict_plugins ) + if archive_head: + _project_archived_report_paths( + report, + archived_config=head_config_path, + checkout_config=config_path, + ) head_status = "succeeded" if head_capability_lock is not None: try: @@ -572,6 +757,9 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: head_exit_code = 4 raise finally: + artifact_report = report if head_status == "succeeded" else None + if artifact_report is None: + _remove_scan_artifacts(out_dir) verifier = _build_verifier( git_root=git_root, config_path=config_path, @@ -585,7 +773,7 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: head_tree=head_tree, base_report=base_report, base_notes=base_notes, - report=report, + report=artifact_report, head_status=head_status, head_exit_code=head_exit_code, out_dir=out_dir, @@ -601,9 +789,10 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: verifier_path, verify_run_path, pr_comment_path, - report=report, + report=artifact_report, git_root=git_root, config_path=head_config_path, + config_logical_path=config_relative.as_posix(), baseline_path=head_baseline_path, policy_pack_paths=head_policy_pack_paths or [], plugins_enabled=plugins_enabled, @@ -637,6 +826,8 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: finally: if head_tmp is not None: head_tmp.cleanup() + if static_snapshot_token is not None: + reset_static_input_snapshot(static_snapshot_token) return verifier, report, head_exit_code @@ -699,10 +890,28 @@ def _prepare_base_report( base_tree, None, None, - [f"Could not materialize base tree {base!r}: {exc}"], + [ + f"Could not materialize base tree {base!r}: " + f"{_stable_archive_error(exc, archive_root=tmp_root, label='base tree')}" + ], ) base_config = base_tree_dir / config_relative + try: + _reject_symlink_components( + base_tree_dir, + config_relative, + label=f"Base manifest {config_relative.as_posix()}", + allow_filesystem_alias=True, + ) + except ConfigError as exc: + return ( + "scan_failed", + base_tree, + None, + None, + [str(exc)], + ) if not base_config.is_file(): return ( "missing_manifest", @@ -723,7 +932,7 @@ def capture_base_capability_lock(lock: CapabilityLockFileV1) -> None: _without_github_step_summary(), use_evaluation_date(date.fromisoformat(evaluation_date)), ): - _base_report, base_exit = run_scan( + base_report_model, base_exit = run_scan( config_path=base_config, output_dir=base_out, formats=["json"], @@ -755,7 +964,10 @@ def capture_base_capability_lock(lock: CapabilityLockFileV1) -> None: base_tree, None, None, - [f"Base scan failed without changing the head gate: {exc}"], + [ + "Base scan failed without changing the head gate: " + f"{_stable_archive_error(exc, archive_root=tmp_root, label='base tree')}" + ], ) if base_exit not in {0, 20}: return ( @@ -774,6 +986,15 @@ def capture_base_capability_lock(lock: CapabilityLockFileV1) -> None: None, ["Base scan did not produce report.json; diff enrichment disabled."], ) + # Base diff evidence is never patch-applicable. Strip checkout/output + # coordinates so identical commits produce identical cached inputs + # across worktrees and clones. + base_report_model.manifest_dir = None + base_report_model.generated_reports = {} + source_report.write_text( + json.dumps(report_json_payload(base_report_model), indent=2), + encoding="utf-8", + ) _copy_report_to_cache(source_report, cache_report) if base_capability_lock is not None: _write_capability_lock_to_cache(base_capability_lock, cache_report) @@ -796,7 +1017,7 @@ def _cache_report_path( evaluation_date: str, ) -> Path: payload = { - "version": 1, + "version": 2, "agents_shipgate_version": __version__, "base_tree": base_tree, "config": config_relative.as_posix(), @@ -926,7 +1147,7 @@ def _map_policy_packs( return None mapped: list[Path] = [] for path in policy_packs: - candidate = _resolve_under_workspace(git_root, path) + candidate = path try: relative = candidate.relative_to(git_root) except ValueError: @@ -944,7 +1165,7 @@ def _map_optional_tree_path( ) -> Path | None: if path is None: return None - candidate = _resolve_under_workspace(git_root, path) + candidate = path try: relative = candidate.relative_to(git_root) except ValueError: @@ -1021,8 +1242,14 @@ def _rerun_options( if no_heuristics: options.append("--no-heuristics") if authorization is not None: + # Authorization grants are intentionally external to the repository. + # A path relative to the invocation directory cannot be replayed from + # the workspace embedded above, so serialize its absolute *lexical* + # identity now. ``abspath`` normalizes ``..`` without following a + # symlink and silently changing the operator-supplied grant path. + authorization_path = Path(os.path.abspath(os.fspath(authorization))) options.extend( - ["--authorization", shlex.quote(_display_path(authorization, git_root))] + ["--authorization", shlex.quote(str(authorization_path))] ) return options @@ -1052,10 +1279,10 @@ def _manifest_introduced( repository may call its manifest anything, so both ``old-gate.yml`` renamed to ``new-gate.yml`` and a base that simply *keeps* ``old-gate.yml`` pass every name test. The base must carry no manifest under the configured or - default name, no tracked YAML that reads like a manifest at all, *and* the - evaluated diff must not delete or rename away any YAML file. All three are - fail-closed: a git command that cannot answer means "not an adoption", - never "proven absent". + default name, no tracked file under any name that reads like a manifest at + all, *and* the evaluated diff must not delete or rename away any YAML file. + All three are fail-closed: a git command that cannot answer means "not an + adoption", never "proven absent". Unknown bases (``ref_missing``, ``archive_failed``) are never treated as adoptions: absence of evidence is not evidence of absence. @@ -1067,9 +1294,14 @@ def _manifest_introduced( signal is the one machine consumers are left with. """ - if config_relative.as_posix() not in { - str(path).replace("\\", "/").strip() for path in changed_files - }: + if not any( + is_configured_manifest( + config_relative, + str(path).replace("\\", "/"), + workspace=git_root, + ) + for path in changed_files + ): return False if base_status == "missing_manifest" and base is not None: ref: str = base @@ -1080,13 +1312,19 @@ def _manifest_introduced( ref = head else: return False - names = _MANIFEST_FILE_NAMES | {config_relative.name} - existing = paths_named_at_ref(git_root, ref, frozenset(names)) - if existing is None or existing: - return False - # A name check cannot see a manifest called something else, so ask what the - # base actually contains. Both probes fail closed on an unreadable answer. - manifest_like = carries_manifest_like_yaml(git_root, ref) + # Only canonical default names are meaningful as a name-based safety + # signal. Treating an arbitrary configured basename as manifest identity + # would let an unrelated base file such as ``docs/release.gate`` suppress + # a genuine adoption of ``config/release.gate``. Custom manifests are + # detected by the suffix-agnostic structural content probe below. + # One bounded listing performs both the canonical-name guard and the + # suffix-agnostic structural probe. A separate ``ls-tree --name-only`` + # previously buffered an unbounded tree before this bounded probe ran. + manifest_like = carries_manifest_like_yaml( + git_root, + ref, + protected_names=_MANIFEST_FILE_NAMES, + ) if manifest_like is not False: return False removed = removes_a_yaml_file( @@ -1142,6 +1380,8 @@ def _self_approval_note( capability_review: VerifierCapabilityReview | None, *, manifest_introduced: bool = False, + pure_adoption_review: bool = False, + configured_manifest: str | None = None, ) -> str | None: """The explicit self-approval prohibition when this PR edits the rules that evaluate it. @@ -1153,28 +1393,36 @@ def _self_approval_note( A first adoption gets its own wording. The prohibition still holds (a coding agent cannot adopt a release policy on the repository's behalf), but - a PR that adds the manifest to a base that had none weakens nothing, and - saying it does is both wrong and the first thing every new adopter reads. - An adoption fires on ``manifest_introduced`` rather than on - ``policy_weakened``, which is honestly false during an adoption — that - keeps every caller reading this as "a trust root is in play" fail-closed. + a PR that adds the manifest to a base that had none does not weaken an + existing Shipgate manifest. An adoption fires on ``manifest_introduced`` + rather than on ``policy_weakened``, which is honestly false during a pure + adoption — that keeps every caller reading this as "a trust root is in + play" fail-closed. But a diff that introduces the manifest *and* weakens an existing policy file is not a pure adoption: ``policy_weakened`` is then true, and saying "there is no prior gate this change could weaken" would describe away the very finding that needs review. The weakening wording wins. """ - if manifest_introduced and not ( - capability_review is not None and capability_review.policy_weakened + if capability_review is None: + # Without a completed head scan there is no capability review whose + # trust-root facts can support adoption guidance. Scan-failure routing + # must remain the authoritative headline and next action. + return None + if ( + manifest_introduced + and pure_adoption_review + and not capability_review.policy_weakened ): + manifest = ( + f"the configured manifest {configured_manifest!r}" + if configured_manifest + else "the configured Shipgate manifest" + ) return ( - "This PR introduces Agents Shipgate to this repository: the base " - "carries no manifest, so there is no prior gate this change could " - "weaken. Adopting a release policy is a human decision — review the " - "generated shipgate.yaml (and the agent-instruction and CI files it " - "adds), then merge it through a human-reviewed PR." + "This PR introduces Agents Shipgate to this repository. Adopting a " + f"release policy is a human decision — review {manifest}, then " + "merge it through a human-reviewed PR." ) - if capability_review is None: - return None if capability_review.policy_weakened: return ( "This PR weakens the release policy that evaluates it; a coding " @@ -1294,11 +1542,42 @@ def _verifier_headline( head_status: str, capability_review: VerifierCapabilityReview | None = None, manifest_introduced: bool = False, + pure_adoption_review: bool = False, + configured_manifest: str | None = None, ) -> str | None: + # A failed scan has no adoption evidence to act on. Lead with the failure, + # even if the pre-scan git proof found a newly added manifest. + if head_status == "failed" or merge_verdict == "unknown": + return "Shipgate could not complete the scan; human review required." + # An adoption with another gating concern must lead with that real stop + # condition. "Review, then merge" is only truthful when the adoption + # finding is the sole review item. + if manifest_introduced and not pure_adoption_review and report is not None: + primary = ( + report.agent_summary.headline + if report.agent_summary is not None + else ( + report.release_decision.reason + if report.release_decision is not None + else "Shipgate requires human review." + ) + ) + manifest = ( + f"the configured manifest {configured_manifest!r}" + if configured_manifest + else "the configured Shipgate manifest" + ) + return ( + f"{primary} This PR also introduces {manifest}; adopting a release " + "policy is a separate human-review decision." + ) # An agent editing the rules that evaluate its own change must see the # self-approval prohibition first, ahead of the generic scan headline. note = _self_approval_note( - capability_review, manifest_introduced=manifest_introduced + capability_review, + manifest_introduced=manifest_introduced, + pure_adoption_review=pure_adoption_review, + configured_manifest=configured_manifest, ) if note is not None: return note @@ -1306,8 +1585,6 @@ def _verifier_headline( return report.agent_summary.headline if head_status == "skipped": return "No agent-capability changes detected; Shipgate did not need to run." - if merge_verdict == "unknown": - return "Shipgate could not complete the scan; human review required." return None @@ -1339,6 +1616,8 @@ def _derive_verifier_control( base_status: str, base_ref: str | None, manifest_introduced: bool = False, + pure_adoption_review: bool = False, + configured_manifest: str | None = None, ) -> AgentControl: """Project verifier facts through the shared operational control engine.""" @@ -1370,9 +1649,20 @@ def _derive_verifier_control( ) if fix_task is not None and fix_task.actor == "coding_agent" and fix_task.safe_to_attempt: - command = fix_task.verification_command - if not command: - raise ValueError("agent-safe verifier repair requires an exact rerun command") + repair_commands = [ + repair.command + for repair in fix_task.allowed_repairs + if repair.command + ] + commands = list(dict.fromkeys(repair_commands)) + if ( + fix_task.verification_command + and fix_task.verification_command not in commands + ): + commands.append(fix_task.verification_command) + if not commands: + raise ValueError("agent-safe verifier repair requires an exact repair command") + command = commands[0] return derive_agent_control( reason=reason, next_action=CodingAgentCommandAction( @@ -1381,7 +1671,7 @@ def _derive_verifier_control( why=fix_task.instructions[0] if fix_task.instructions else reason, ), verify_required=True, - allowed_next_commands=[command], + allowed_next_commands=commands, ) if execution == "failed" and base_status in {"ref_missing", "archive_failed"}: @@ -1396,10 +1686,20 @@ def _derive_verifier_control( verify_required=True, ) - review_reason = ( - _self_approval_note(capability_review, manifest_introduced=manifest_introduced) - or reason - ) + # For a mixed adoption, ``reason`` already leads with the actual blocker + # and appends the adoption review. Do not replace it with generic + # trust-root copy and hide the condition that stopped the release. + review_reason = reason + if not manifest_introduced or pure_adoption_review: + review_reason = ( + _self_approval_note( + capability_review, + manifest_introduced=manifest_introduced, + pure_adoption_review=pure_adoption_review, + configured_manifest=configured_manifest, + ) + or reason + ) unsafe_block = bool(release_decision is not None and release_decision.decision == "blocked") return derive_agent_control( reason=reason, @@ -1407,7 +1707,7 @@ def _derive_verifier_control( kind="stop" if unsafe_block or execution == "failed" else "review", why=review_reason, ), - verify_required=release_decision is not None, + verify_required=release_decision is not None or execution == "failed", human_review_required=True, unsafe_block=unsafe_block, human_review_why=review_reason, @@ -1457,6 +1757,10 @@ def _build_verifier( applicability = applicability_for(decision=decision, execution=head_status) agent_summary_model = report.agent_summary if report is not None else None capability_review = build_capability_review(report) if report is not None else None + pure_adoption_review = is_pure_adoption_review( + report, + manifest_introduced=manifest_introduced, + ) # ``ref_missing``/``archive_failed`` are unknown-base states: the run # already carries its own recovery action, and a repair task derived from a # comparison that never happened would be guesswork. ``missing_manifest`` @@ -1480,6 +1784,13 @@ def _build_verifier( config=_display_path(config_path, git_root), worktree=worktree, rerun_options=rerun_options, + report_path=str((out_dir / "report.json").resolve()), + repair_subject_available=_repair_subject_available( + report, + git_root=git_root, + head=head, + worktree=worktree, + ), ) ) can_merge = _can_merge_without_human( @@ -1493,6 +1804,8 @@ def _build_verifier( head_status=head_status, capability_review=capability_review, manifest_introduced=manifest_introduced, + pure_adoption_review=pure_adoption_review, + configured_manifest=_display_path(config_path, git_root), ) if headline_override is None: provenance = _gap_provenance_note(report=report, base_report=base_report) @@ -1509,6 +1822,8 @@ def _build_verifier( base_status=base_status, base_ref=base, manifest_introduced=manifest_introduced, + pure_adoption_review=pure_adoption_review, + configured_manifest=_display_path(config_path, git_root), ) return VerifierArtifact( workspace=str(git_root), @@ -1522,7 +1837,9 @@ def _build_verifier( base_tree_sha=base_tree, head_tree_sha=head_tree, base_report_json=( - _display_path(base_report, git_root) if base_report is not None else None + artifacts.get("verification_base_report_json") + if base_report is not None + else None ), base_notes=base_notes, execution=head_status, # type: ignore[arg-type] @@ -1837,6 +2154,7 @@ def _write_artifacts( report: ReadinessReport | None, git_root: Path, config_path: Path, + config_logical_path: str | None = None, baseline_path: Path | None, policy_pack_paths: list[Path], plugins_enabled: bool | None, @@ -1852,6 +2170,23 @@ def _write_artifacts( evaluation_date: str | None = None, ) -> None: verifier_path.parent.mkdir(parents=True, exist_ok=True) + portable_diff_from_path: Path | None = None + if diff_from_path is not None and diff_from_path.is_file(): + portable_diff_from_path = verifier_path.with_name( + "verification-base-report.json" + ) + source_bytes = read_static_input_bytes( + diff_from_path, + max_bytes=64 * 1024 * 1024, + ) + if portable_diff_from_path != diff_from_path: + portable_diff_from_path.write_bytes(source_bytes) + logical_base_report = _display_path( + portable_diff_from_path.resolve(), + git_root, + ) + verifier.artifacts["verification_base_report_json"] = logical_base_report + verifier.base_report_json = logical_base_report verifier_path.write_text( json.dumps(verifier.model_dump(mode="json"), indent=2), encoding="utf-8", @@ -1888,7 +2223,61 @@ def _write_artifacts( if not config_path.is_file() or not ref_exists(git_root, verifier.head_ref): return resolved_input_root = (input_root or git_root).resolve() - logical_config = ( + active_snapshot = active_static_input_snapshot() + original_paths = [ + *policy_pack_paths, + *([baseline_path] if baseline_path is not None else []), + *([diff_from_path] if diff_from_path is not None else []), + ] + if active_snapshot is not None: + try: + # Base-report enrichment can be generated after the snapshot was + # activated. Capture it now, before finalizing directory identity, + # so plan construction and the receipt consume exactly these bytes. + for path in original_paths: + if ( + path.is_file() + and active_snapshot.contains(path) + and not active_snapshot.has(path) + ): + active_snapshot.read_bytes(path, max_bytes=64 * 1024 * 1024) + active_snapshot.finish() + except (OSError, ValueError) as exc: + raise InputParseError( + f"Verification inputs changed while they were being evaluated: {exc}" + ) from exc + original_static_inputs = { + Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + for path in original_paths + } + captured_input_paths = ( + [ + path + for path in active_snapshot.paths() + if path not in original_static_inputs + ] + if active_snapshot is not None + else None + ) + external_input_root = verifier_path.parent + portable_policy_pack_paths = [ + _write_portable_static_input( + path, + root=external_input_root, + category="policy-packs", + ) + for path in policy_pack_paths + ] + portable_baseline_path = ( + _write_portable_static_input( + baseline_path, + root=external_input_root, + category="baseline", + ) + if baseline_path is not None and baseline_path.is_file() + else None + ) + logical_config = config_logical_path or ( config_path.resolve().relative_to(resolved_input_root).as_posix() if resolved_input_root in config_path.resolve().parents else _display_path(config_path.resolve(), git_root) @@ -1925,13 +2314,6 @@ def _write_artifacts( "source_head_relation": source_identity.relation, } ) - portable_diff_from_path: Path | None = None - if diff_from_path is not None and diff_from_path.is_file(): - portable_diff_from_path = verifier_path.with_name("verification-base-report.json") - shutil.copyfile(diff_from_path, portable_diff_from_path) - verifier.artifacts["verification_base_report_json"] = _display_path( - portable_diff_from_path.resolve(), git_root - ) plan = build_verification_plan( git_root=git_root, input_root=resolved_input_root, @@ -1955,9 +2337,9 @@ def _write_artifacts( ), changed_files=verifier.changed_files, diff_text=diff_text, - baseline_path=baseline_path, + baseline_path=portable_baseline_path, diff_from_path=portable_diff_from_path, - policy_pack_paths=policy_pack_paths, + policy_pack_paths=portable_policy_pack_paths, evaluation_date=resolved_date, options={ "ci_mode": verifier.mode, @@ -1967,6 +2349,8 @@ def _write_artifacts( **resolved_options, }, plugins_enabled=plugins_enabled, + external_input_root=external_input_root, + captured_input_paths=captured_input_paths, ) plan_path = verifier_path.with_name("verification-plan.json") diff_input_path = verifier_path.with_name("verification-input.diff") @@ -2289,23 +2673,263 @@ def _resolve_under_workspace(workspace: Path, path: Path) -> Path: return (workspace / path).resolve() -def _relative_to_workspace(workspace: Path, path: Path, label: str) -> Path: +def _resolve_static_input_path( + workspace: Path, + path: Path, + *, + label: str, +) -> Path: + """Bind a baseline/policy CLI spelling without following worktree aliases.""" + + candidate = path if path.is_absolute() else workspace / path + lexical = Path(os.path.abspath(os.path.normpath(os.fspath(candidate)))) + try: + relative = lexical.relative_to(workspace) + except ValueError: + anchor = Path(lexical.anchor) + relative = lexical.relative_to(anchor) + else: + anchor = workspace + _reject_symlink_components(anchor, relative, label=label) + try: + metadata = lexical.lstat() + except FileNotFoundError: + return lexical + except OSError as exc: + raise ConfigError(f"{label} could not be inspected safely: {path}") from exc + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ConfigError( + f"{label} must identify one singly-linked regular file: {path}" + ) + return lexical + + +def _resolve_config_under_workspace( + workspace: Path, + path: Path, + *, + requested_workspace: Path | None = None, +) -> tuple[Path, Path]: + """Resolve config spelling without following repository symlinks. + + The repository-relative path is part of verification identity. Calling + ``Path.resolve`` first lets a tracked symlink replace that identity with + whichever target happens to exist in the current worktree, so the diff can + name the link while the scan evaluates an unmentioned target. + """ + + requested_anchor = ( + requested_workspace.resolve() + if requested_workspace is not None + else workspace + ) + candidate = path if path.is_absolute() else requested_anchor / path + if path.is_absolute() and requested_workspace is not None: + lexical_requested = Path( + os.path.abspath(os.path.normpath(os.fspath(requested_workspace))) + ) + canonical_requested = requested_workspace.resolve() + lexical_path = Path(os.path.normpath(os.fspath(path))) + try: + requested_tail = lexical_path.relative_to(lexical_requested) + except ValueError: + requested_tail = None + if requested_tail is not None: + # The absolute config was spelled under the caller's workspace + # anchor. Map only that relative tail onto the anchor's canonical + # target; this supports a symlink that jumps directly to a nested + # repository directory without fabricating a lexical repo root. + candidate = canonical_requested / requested_tail + else: + try: + workspace_tail = canonical_requested.relative_to(workspace) + except ValueError: + workspace_tail = None + if workspace_tail is not None: + lexical_root = lexical_requested + for _part in workspace_tail.parts: + lexical_root = lexical_root.parent + try: + proven_root = lexical_root.resolve() + except OSError: + proven_root = None + if proven_root == workspace: + try: + config_tail = lexical_path.relative_to(lexical_root) + except ValueError: + pass + else: + candidate = workspace / config_tail + lexical = Path(os.path.normpath(os.fspath(candidate))) try: - return path.resolve().relative_to(workspace) + relative = lexical.relative_to(workspace) except ValueError as exc: - raise ConfigError(f"{label} must resolve inside --workspace: {path}") from exc + raise ConfigError(f"--config must be inside --workspace: {path}") from exc + _reject_symlink_components(workspace, relative, label="--config") + try: + metadata = lexical.lstat() + except FileNotFoundError: + return lexical, relative + except OSError as exc: + raise ConfigError(f"--config could not be inspected safely: {path}") from exc + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ConfigError( + "--config must identify one singly-linked regular file; " + f"non-regular or hardlinked manifest refused: {path}" + ) + return lexical, relative + + +def _reject_symlink_components( + root: Path, + relative: Path, + *, + label: str, + allow_filesystem_alias: bool = False, +) -> None: + """Reject a manifest reached through a symlink or filesystem alias.""" + + issue = inspect_lexical_path_identity(root, relative) + if issue is None: + return + requested = _display_path(issue.requested, root) + if issue.kind == "symlink": + raise ConfigError(f"{label} must not contain symlink components: {requested}") + if issue.kind == "alias": + if allow_filesystem_alias and issue.actual is not None: + # Git can materialize the same tracked path with a precomposed + # Unicode or index-canonical case spelling. The worktree-side + # config identity was already validated before archiving, and + # checks compare the two spellings by same-entry identity. An + # unresolved alias (``actual is None``) did not prove that + # same-entry relationship and therefore remains fail-closed. + return + actual = ( + _display_path(issue.actual, root) + if issue.actual is not None + else "a differently spelled filesystem entry" + ) + raise ConfigError( + f"{label} must use the exact filesystem spelling: " + f"{requested} resolves to {actual}" + ) + raise ConfigError( + f"{label} could not be inspected safely: {requested}: {issue.detail}" + ) def _dedupe_paths(paths: list[str]) -> list[str]: return sorted({path for path in paths if path}) +def _bind_worktree_config_to_head( + *, + git_root: Path, + head: str, + config_relative: Path, + worktree_text: str, + changed_files: list[str], +) -> list[str]: + """Force the manifest into context when its loaded bytes differ from HEAD. + + Git's ordinary worktree diff intentionally honors ignore rules and index + flags such as ``assume-unchanged``/``skip-worktree``. Those are useful for + local development but cannot hide the release policy that verify actually + loads. Compare the manifest independently and add its exact logical path + whenever the bytes differ or the file is absent from HEAD. + """ + + try: + head_text = read_file_at_ref(git_root, head, config_relative) + except (OSError, UnicodeDecodeError) as exc: + raise ConfigError( + f"Configured manifest {config_relative.as_posix()} could not be " + f"read from {head!r}: {exc}" + ) from exc + if head_text == worktree_text: + return changed_files + return _dedupe_paths([*changed_files, config_relative.as_posix()]) + + def _join_diff_text(left: str, right: str) -> str: if left and right: return f"{left}\n{right}" return left or right +def _project_archived_report_paths( + report: ReadinessReport, + *, + archived_config: Path, + checkout_config: Path, +) -> None: + """Project temporary archive paths onto stable checkout coordinates. + + Current machine patches are manifest-only. Any future patch against a + different archived file must add an explicit portable mapping here rather + than leaking a temporary path into receipt-bound evidence. + """ + + archived_target = archived_config.resolve() + checkout_target = checkout_config.resolve() + report.manifest_dir = str(checkout_target.parent) + for finding in report.findings: + for patch in finding.patches or []: + target_file = getattr(patch, "target_file", None) + if target_file is None: + continue + try: + patch_target = Path(target_file).resolve() + except OSError as exc: + raise ConfigError( + "Archived suggested-patch target could not be mapped safely" + ) from exc + if patch_target != archived_target: + raise ConfigError( + "Archived suggested patch targets an unsupported file: " + f"{target_file}" + ) + patch.target_file = str(checkout_target) + + +def _repair_subject_available( + report: ReadinessReport | None, + *, + git_root: Path, + head: str, + worktree: bool, +) -> bool: + """Whether an agent-safe repair can be reverified without changing refs. + + A ref-bound run evaluates committed objects. ``apply-patches`` mutates the + checkout only, so its exact ref-bound rerun would continue to scan the old + commit and could never validate the repair. Until the control contract can + model an intentional commit as a separate reviewed transition, only a + working-tree run may advertise the autonomous mechanical route. + """ + + return worktree + + +def _stable_archive_error( + exc: BaseException, + *, + archive_root: Path, + label: str, +) -> str: + """Remove random temporary-root spellings from public verifier evidence.""" + + detail = str(exc) or type(exc).__name__ + spellings = {str(archive_root), str(archive_root.absolute())} + try: + spellings.add(str(archive_root.resolve())) + except OSError: + pass + for spelling in sorted(spellings, key=len, reverse=True): + detail = detail.replace(spelling, f"<{label}>") + return detail + + def _display_path(path: Path, root: Path) -> str: try: return path.relative_to(root).as_posix() @@ -2313,17 +2937,61 @@ def _display_path(path: Path, root: Path) -> str: return str(path) +def _write_portable_static_input( + path: Path, + *, + root: Path, + category: str, +) -> Path: + """Copy one captured external input into the reproducible artifact graph.""" + + data = read_static_input_bytes(path, max_bytes=64 * 1024 * 1024) + digest = hashlib.sha256(data).hexdigest() + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", path.name).strip("._") + if not safe_name: + safe_name = "input" + target = root / "verification-inputs" / category / f"{digest[:16]}-{safe_name}" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return target + + +def _reject_output_input_overlap( + *, + git_root: Path, + out_dir: Path, + inputs: list[tuple[str, Path]], +) -> None: + """Keep generated artifacts from replacing verifier inputs.""" + + root = git_root.resolve() + output = out_dir.resolve() + if output == root: + raise ConfigError("Verifier --out cannot be the workspace root.") + for label, candidate in inputs: + resolved = candidate.resolve() + try: + resolved.relative_to(output) + except ValueError: + continue + raise ConfigError( + f"Verifier --out overlaps the {label} input at " + f"{_display_path(candidate, git_root)}." + ) + + def _shell_join(parts: list[str]) -> str: return " ".join(shlex.quote(part) for part in parts) def _preview_init_command(workspace: Path) -> str: + command_workspace = workspace if workspace.is_absolute() else Path.cwd() / workspace return _shell_join( [ "shipgate", "init", "--workspace", - str(workspace), + str(command_workspace), "--write", "--json", ] @@ -2337,22 +3005,31 @@ def _preview_verify_command( base: str | None, head: str | None, out: Path | None, + pr_comment_style: str = "capability-review", + preview: bool = False, ) -> str: + command_workspace = workspace if workspace.is_absolute() else Path.cwd() / workspace parts = [ "agents-shipgate", "verify", "--workspace", - str(workspace), + str(command_workspace), "--config", str(config), ] + if preview: + parts.append("--preview") if base is not None: parts.extend(["--base", base]) if head is not None: parts.extend(["--head", head]) if out is not None: parts.extend(["--out", str(out)]) - parts.extend(["--ci-mode", "advisory", "--json"]) + if pr_comment_style and pr_comment_style != "capability-review": + parts.extend(["--pr-comment-style", pr_comment_style]) + if not preview: + parts.extend(["--ci-mode", "advisory"]) + parts.append("--json") return _shell_join(parts) @@ -2376,21 +3053,38 @@ def run_preview( pr_comment_style: str = "capability-review", ) -> tuple[VerifierArtifact, None, int]: """Lightweight relevance check for ``agents-shipgate verify --preview``.""" - root = workspace.resolve() - config_path = _resolve_under_workspace(root, config) + requested_root = workspace.resolve() + try: + root = ensure_git_workspace(requested_root) + except ConfigError: + # Preview remains useful before a project has a Git repository. When + # one does exist, use the same repository root and path coordinates as + # the full verifier so its authorized command evaluates the same gate. + root = requested_root + config_path, config_relative = _resolve_config_under_workspace( + root, + config, + requested_workspace=workspace, + ) out_dir = _resolve_under_workspace(root, out or DEFAULT_OUT_DIR) + _reject_output_input_overlap( + git_root=root, + out_dir=out_dir, + inputs=[("config", config_path)], + ) out_dir.mkdir(parents=True, exist_ok=True) _clear_trusted_handoff(out_dir) verifier_path = out_dir / "verifier.json" verify_run_path = out_dir / "verify-run.json" agent_handoff_path = out_dir / "agent-handoff.json" pr_comment_path = out_dir / "pr-comment.md" - manifest_present = config_path.exists() + manifest_present = config_path.is_file() changed_files: list[str] = [] diff_text = "" notes: list[str] = [] diff_unavailable = False + diff_failure_requires_review = False if base or head: try: git_root = ensure_git_workspace(root) @@ -2405,6 +3099,7 @@ def run_preview( ) except Exception as exc: # noqa: BLE001 - preview must never crash. diff_unavailable = True + diff_failure_requires_review = True notes.append(f"Preview diff unavailable: {exc}") trigger = evaluate( @@ -2426,9 +3121,18 @@ def run_preview( base=base, head=head, out=out, + pr_comment_style=pr_comment_style, ) - if diff_unavailable and manifest_present: + if diff_unavailable and manifest_present and diff_failure_requires_review: + why = ( + "Preview could not collect the requested deterministic diff even " + "though ref availability was not the problem. Inspect the reported " + "Git configuration/resource failure before rerunning." + ) + next_action = HumanControlAction(kind="review", why=why) + headline = "Shipgate preview could not safely inspect the requested PR diff." + elif diff_unavailable and manifest_present: next_action: AgentControlAction = CodingAgentFetchBaseAction( kind="fetch_base", expects=base or head or "the requested base and head refs", @@ -2521,6 +3225,7 @@ def run_preview( report=None, git_root=root, config_path=config_path, + config_logical_path=config_relative.as_posix(), baseline_path=None, policy_pack_paths=[], plugins_enabled=None, diff --git a/src/agents_shipgate/config/loader.py b/src/agents_shipgate/config/loader.py index 1591dbb4..fb33ed6e 100644 --- a/src/agents_shipgate/config/loader.py +++ b/src/agents_shipgate/config/loader.py @@ -11,6 +11,7 @@ from agents_shipgate.inputs.common import ( PositionIndex, load_structured_file_with_positions, + load_structured_text_with_positions, ) from agents_shipgate.schemas.manifest import AgentsShipgateManifest @@ -36,6 +37,23 @@ def load_manifest(path: str | Path) -> AgentsShipgateManifest: return _validate_manifest_data(data, config_path) +def load_manifest_text( + text: str, + *, + source: str | Path = "shipgate.yaml", +) -> AgentsShipgateManifest: + """Parse manifest bytes that were already read through a trusted snapshot.""" + + config_path = Path(source) + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise ConfigError(f"Invalid YAML in {config_path}: {exc}") from exc + if not isinstance(data, dict): + raise ConfigError(f"Config file must contain a YAML object: {config_path}") + return _validate_manifest_data(data, config_path) + + def load_manifest_with_positions( path: str | Path, ) -> tuple[AgentsShipgateManifest, PositionIndex]: @@ -65,6 +83,25 @@ def load_manifest_with_positions( return manifest, positions +def load_manifest_text_with_positions( + text: str, + *, + source: str | Path = "shipgate.yaml", +) -> tuple[AgentsShipgateManifest, PositionIndex]: + """Load a manifest and positions from one already-captured byte snapshot.""" + + manifest = load_manifest_text(text, source=source) + config_path = Path(source) + try: + _, positions = load_structured_text_with_positions( + text, + source=config_path, + ) + except InputParseError as exc: + raise ConfigError(f"Invalid YAML in {config_path}: {exc}") from exc + return manifest, positions + + def _validate_manifest_data( data: dict[str, Any], config_path: Path ) -> AgentsShipgateManifest: diff --git a/src/agents_shipgate/core/agent_boundary.py b/src/agents_shipgate/core/agent_boundary.py index bcdb627c..d4234819 100644 --- a/src/agents_shipgate/core/agent_boundary.py +++ b/src/agents_shipgate/core/agent_boundary.py @@ -18,6 +18,7 @@ from agents_shipgate.core.boundary_diff import ( BoundaryInputIssue, DiffFile, + git_diff_path_token, parse_unified_diff, ) from agents_shipgate.core.boundary_registry import ( @@ -42,8 +43,10 @@ _required_reviewers_for, _risk_for, _violation_fingerprint, + detect_command_for, evaluate_codex_boundary_result, load_codex_boundary_policy, + preview_command_for, verify_command_for, violations_within_agent_actionable_band, ) @@ -61,6 +64,8 @@ ) from agents_shipgate.core.trust_roots import ( is_configured_manifest, + is_portable_repo_path, + read_absolute_identity_bound_text, trust_root_class_for, ) from agents_shipgate.schemas.agent_boundary import ( @@ -172,12 +177,29 @@ def evaluate_agent_boundary( input_mode: Literal["worktree", "git_range", "provided_diff"] = "provided_diff", input_issues: list[BoundaryInputIssue] | None = None, host_snapshot: HostBoundarySnapshot | None = None, + changed_files_override: list[str] | None = None, config_path: Path | None = None, requested_workspace: Path | None = None, + base: str | None = None, + head: str | None = None, + verification_replayable: bool = False, + base_manifest_absent: bool | None = None, ) -> AgentBoundaryAssessment: # The command this assessment authorizes must evaluate the target that was # actually checked, not the default manifest in the current directory. - verify_command = verify_command_for(requested_workspace, config_path) + verify_command = verify_command_for( + requested_workspace, + config_path, + base=base, + head=head, + ) + detect_command = detect_command_for(requested_workspace) + preview_command = preview_command_for( + requested_workspace, + config_path, + base=base, + head=head, + ) workspace = workspace.resolve() host_snapshot = host_snapshot or build_host_boundary_snapshot( workspace, @@ -186,19 +208,29 @@ def evaluate_agent_boundary( diff_files = parse_unified_diff(diff_text) changed_files = sorted( { - path - for item in diff_files - for path in (item.path, item.old_path) - if path - and ( - path == item.path - or is_agent_boundary_path(path) - or is_configured_manifest(config_path, path, workspace=workspace) - ) + *(changed_files_override or []), + *( + path + for item in diff_files + for path in (item.new_path, item.old_path) + if path + ), } ) input_issues = [ *(input_issues or []), + *[ + BoundaryInputIssue( + code=f"host_inventory_{item.get('kind', 'unresolved')}", + path=str(item.get("source") or ""), + message=str( + item.get("message") + or "A repository host-boundary source could not be inventoried." + ), + ) + for item in host_snapshot.inventory.get("issues", []) + if item.get("blocking") + ], *_structural_diff_issues( workspace=workspace, diff_files=diff_files, @@ -220,9 +252,15 @@ def evaluate_agent_boundary( policy_override=policies.codex, policy_diagnostics=list(policies.diagnostics), diff_files_override=diff_files, + changed_files_override=changed_files, resolved_text_cache=resolved_text_cache, static_read_cache=host_snapshot.cache, verify_command=verify_command, + detect_command=detect_command, + preview_command=preview_command, + verification_replayable=verification_replayable, + discovery_replayable=input_mode != "git_range", + manifest_label=_manifest_label(config_path, workspace), ) host_violations, host_diagnostics = evaluate_host_boundary( workspace=workspace, @@ -251,8 +289,14 @@ def evaluate_agent_boundary( combined = _with_unclassified_protected_changes( changed_files=changed_files, violations=combined, - adoption_paths=_manifest_adoption_paths(diff_files, config_path, workspace), + adoption_paths=_manifest_adoption_paths( + diff_files, + config_path, + workspace, + base_manifest_absent=base_manifest_absent, + ), config_path=config_path, + policy_path=policy_path, workspace=workspace, evaluated_paths={ item.path @@ -369,14 +413,49 @@ def evaluate_agent_boundary( diagnostics=diagnostics, policy_set=policies, release_decision=release_decision, + detect_command=detect_command, + input_mode=input_mode, + verification_replayable=verification_replayable, + discovery_replayable=input_mode != "git_range", + diff_text=diff_text, ) + invocation_shared_paths = { + path + for path in changed_files + if ( + _is_invocation_path( + policy_path, + path, + workspace=workspace, + ) + or _is_invocation_path( + config_path, + path, + workspace=workspace, + ) + ) + } affected_hosts = tuple( - sorted({host for path in changed_files for host in boundary_hosts_for_path(path)}) + sorted( + { + *( + host + for path in changed_files + for host in boundary_hosts_for_path(path) + ), + *( + {"codex", "claude-code", "cursor"} + if invocation_shared_paths + else set() + ), + } + ) ) coverage = _coverage_for( changed_files=changed_files, violations=combined, issues=issue_codes, + invocation_shared_paths=invocation_shared_paths, ) input_coverage: Literal["complete", "partial", "unknown"] = ( "partial" @@ -456,7 +535,9 @@ def assessment_for_scan_context(context) -> AgentBoundaryAssessment: if verification is None: raise ValueError("boundary assessment requires verification context") diff_text = verification.diff_text or "\n".join( - f"diff --git a/{path} b/{path}" for path in verification.changed_files + "diff --git " + f"{git_diff_path_token('a/', path)} {git_diff_path_token('b/', path)}" + for path in verification.changed_files ) configured_manifest = ( Path(verification.configured_manifest_path) @@ -471,10 +552,16 @@ def assessment_for_scan_context(context) -> AgentBoundaryAssessment: diff_text=diff_text, trigger=verification.trigger_result, input_mode="provided_diff", + changed_files_override=list(verification.changed_files), # Without this a custom-named manifest produced the protected-surface # boundary finding under local `check` but not under full `verify`, so # the two public surfaces published different evidence for one diff. config_path=configured_manifest, + verification_replayable=True, + # Verify already proved this fact from the comparison base. Reusing it + # keeps the cached boundary projection from re-inferring adoption from + # diff shape alone. + base_manifest_absent=verification.manifest_introduced, ) return context.agent_boundary @@ -518,6 +605,11 @@ def _project_legacy( policy_set: _PolicySet, release_decision: dict[str, Any] | None, verify_command: str | None = None, + detect_command: str | None = None, + input_mode: Literal["worktree", "git_range", "provided_diff"] = "provided_diff", + verification_replayable: bool = True, + discovery_replayable: bool = True, + diff_text: str = "", ) -> CodexBoundaryResultV2: needs_reprojection = violations != legacy.violated_rules or bool(policy_set.issues) if needs_reprojection: @@ -526,7 +618,16 @@ def _project_legacy( repair = _repair_for(decision, violations, legacy.agent) human = _human_review_for(decision, violations, repair) summary = _boundary_summary(decision, violations) - first_action = _next_action_for(decision, violations, repair) + undeclared_gap = any( + item.code == "undeclared_capability_surface" + for item in diagnostics + ) + first_action = ( + legacy.control.next_action + if undeclared_gap + and getattr(legacy.control.next_action, "command", None) + else _next_action_for(decision, violations, repair) + ) control = _control_for_result( verify_command=verify_command, decision=decision, @@ -535,9 +636,7 @@ def _project_legacy( human_review=human, repair=repair, verify_required=legacy.control.verify_required, - undeclared_gap=any( - item.code == "undeclared_capability_surface" for item in diagnostics - ), + undeclared_gap=undeclared_gap, coverage_gap=any( item.code == "capability_change_requires_verify" for item in diagnostics ), @@ -545,13 +644,32 @@ def _project_legacy( legacy.trigger and legacy.trigger.get("force_run") ), violations=violations, + detect_command=detect_command, + verification_replayable=verification_replayable, + discovery_replayable=discovery_replayable, ) else: decision = legacy.decision risk = legacy.risk_level repair = legacy.repair summary = _boundary_summary(decision, violations) - control = legacy.control.model_copy(update={"reason": summary}) + control = legacy.control.model_copy( + update={ + "reason": ( + legacy.control.reason + if ( + (not verification_replayable or not discovery_replayable) + and legacy.control.verify_required + ) + else summary + ) + } + ) + if ( + (not verification_replayable or not discovery_replayable) + and control.state == "human_review_required" + ): + summary = control.reason fingerprints = [_violation_fingerprint(item) for item in violations] aggregate = AgentResultPolicy( id="agent-boundary", @@ -571,6 +689,12 @@ def _project_legacy( changed_files=legacy.changed_files, fingerprints=fingerprints, policy_digest=policy_set.digest, + input_mode=input_mode, + verification_replayable=verification_replayable, + trigger=legacy.trigger, + control=control.model_dump(mode="json", exclude_none=True), + diff_text=diff_text, + legacy_audit_id=legacy.audit_id, ) trace = [ AgentResultTraceEvent( @@ -615,6 +739,12 @@ def _agent_boundary_audit_id( changed_files: list[str], fingerprints: list[str], policy_digest: str, + input_mode: Literal["worktree", "git_range", "provided_diff"], + verification_replayable: bool, + trigger: dict[str, Any] | None = None, + control: dict[str, Any] | None = None, + diff_text: str = "", + legacy_audit_id: str | None = None, ) -> str: """Identity of one audited evaluation. @@ -623,11 +753,14 @@ def _agent_boundary_audit_id( detecting the actor changed the label while leaving every Claude Code and Cursor run indistinguishable from a codex run in the audit trail. - It is folded in only for a non-default actor. Every id issued before actor - detection existed was a codex run, and rotating those would break stored - references for a change that tells them nothing new. + Actor is folded in only for a non-default actor. The input binding is + omitted only for the legacy verify projection (a replayable provided diff) + so established ids for that exact substrate stay stable. Worktree, ref + range, and detached provided-diff evaluations can produce different + controls from identical text and therefore must be distinct audit rows. """ + legacy_subject = input_mode == "provided_diff" and verification_replayable payload = { "schema": AGENT_BOUNDARY_RESULT_SCHEMA_VERSION, # Added only for a non-default actor, so every id issued before actor @@ -635,6 +768,42 @@ def _agent_boundary_audit_id( # would break anyone who stored one, and the identity contract is not # versioned separately from the schema. **({"actor": actor} if actor != _LEGACY_AUDIT_ACTOR else {}), + **( + { + "input_mode": input_mode, + "verification_replayable": verification_replayable, + } + if not legacy_subject + else {} + ), + **( + { + "trigger": { + key: trigger.get(key) + for key in ( + "should_run", + "run_shipgate", + "force_run", + "dry_run_recommended", + "skip_reason", + ) + if trigger is not None and key in trigger + }, + "control": { + "state": (control or {}).get("state"), + "verify_required": (control or {}).get("verify_required"), + "next_action_kind": ( + ((control or {}).get("next_action") or {}).get("kind") + if isinstance((control or {}).get("next_action"), dict) + else None + ), + }, + "subject_sha256": hashlib.sha256(diff_text.encode()).hexdigest(), + "legacy_audit_id": legacy_audit_id, + } + if not legacy_subject + else {} + ), "changed_files": sorted(changed_files), "fingerprints": sorted(fingerprints), "policy_set_sha256": policy_digest, @@ -705,14 +874,32 @@ def _load_policy_set(*, workspace: Path, explicit: Path | None) -> _PolicySet: codex_source = host_source = "packaged_default" discovery = ["packaged_default:agent-boundary"] + shared_policy_text: str | None = None + if codex_path is not None: + codex_candidate = ( + codex_path if codex_path.is_absolute() else workspace / codex_path + ) + host_candidate = host_path if host_path.is_absolute() else workspace / host_path + if codex_candidate == host_candidate and codex_candidate.is_file(): + try: + shared_policy_text = read_absolute_identity_bound_text( + codex_candidate, + max_bytes=1024 * 1024, + ) + except (OSError, UnicodeDecodeError, ValueError): + # Family loaders retain their established diagnostics and + # default-policy fallback when the shared capture fails. + shared_policy_text = None codex, codex_diagnostics = load_codex_boundary_policy( workspace=workspace, policy_path=codex_path, allow_foreign_rules=True, + policy_text=shared_policy_text, ) host, host_diagnostics = load_host_boundary_policy( workspace=workspace, policy_path=host_path, + policy_text=shared_policy_text, ) diagnostics.extend(codex_diagnostics) diagnostics.extend(host_diagnostics) @@ -777,6 +964,7 @@ def _coverage_for( changed_files: list[str], violations: list[AgentResultViolatedRule], issues: list[str], + invocation_shared_paths: set[str] | None = None, ) -> list[BoundaryHostCoverage]: failure_paths = { item.path @@ -791,6 +979,8 @@ def _coverage_for( coverage: list[BoundaryHostCoverage] = [] for adapter in BOUNDARY_ADAPTERS: paths = sorted(path for path in changed_files if adapter.matches(path)) + if adapter.id == "shared" and invocation_shared_paths: + paths = sorted({*paths, *invocation_shared_paths}) if any(path in failure_paths for path in paths) or (paths and issues): status = "partial" elif paths and adapter.experimental: @@ -832,6 +1022,8 @@ def _manifest_adoption_paths( diff_files: list[DiffFile], config_path: Path | None = None, workspace: Path | None = None, + *, + base_manifest_absent: bool | None = None, ) -> frozenset[str]: """Paths where this diff *introduces* a Shipgate manifest. @@ -848,6 +1040,13 @@ def _manifest_adoption_paths( so the local decision and the control state are identical either way. """ + # A plain added file proves only that this path is new. It does not prove + # that the repository had no operational manifest under another name. + # Callers with a git/worktree subject supply the separately established + # base fact; raw diff callers receive neutral protected-surface wording. + if base_manifest_absent is not True: + return frozenset() + records = [ item for item in diff_files @@ -873,6 +1072,7 @@ def _with_unclassified_protected_changes( evaluated_paths: set[str], adoption_paths: frozenset[str] = frozenset(), config_path: Path | None = None, + policy_path: Path | None = None, workspace: Path | None = None, ) -> list[AgentResultViolatedRule]: covered = {item.path for item in violations if item.path} @@ -884,6 +1084,7 @@ def _with_unclassified_protected_changes( or normalized in evaluated_paths or not ( is_agent_boundary_path(normalized) + or trust_root_class_for(normalized) is not None # The manifest this invocation loaded is a protected surface # whatever it is called: a repository run with # ``--config new-gate.yml`` otherwise got ``allow`` and no @@ -891,6 +1092,14 @@ def _with_unclassified_protected_changes( or is_configured_manifest( config_path, normalized, workspace=workspace ) + # An explicit ``--policy`` is the live boundary gate for this + # invocation even when it has a custom name outside + # ``policies/*.shipgate.yaml``. + or _is_invocation_path( + policy_path, + normalized, + workspace=workspace, + ) ) ): continue @@ -909,6 +1118,14 @@ def _with_unclassified_protected_changes( ) is None and is_configured_manifest( config_path, normalized, workspace=workspace ) + configured_policy = ( + trust_root_class_for(normalized) is None + and _is_invocation_path( + policy_path, + normalized, + workspace=workspace, + ) + ) additions.append( AgentResultViolatedRule( id=rule.id, @@ -929,10 +1146,16 @@ def _with_unclassified_protected_changes( if kind == "STATIC-REQUIREMENTS-CHANGED" else "protected_surface_unclassified" ), - **({"trust_root_class": "manifest"} if configured_manifest else {}), + **( + {"trust_root_class": "manifest"} + if configured_manifest + else {"trust_root_class": "policy"} + if configured_policy + else {} + ), }, recommendation=( - "Review the generated shipgate.yaml and merge the adoption " + f"Review {normalized} and merge the adoption " "through a human-reviewed PR; a coding agent cannot adopt a " "release policy on the repository's behalf." if adopting @@ -943,6 +1166,32 @@ def _with_unclassified_protected_changes( return _dedupe_violations([*violations, *additions]) +def _is_invocation_path( + selected_path: Path | None, + changed_path: str, + *, + workspace: Path, +) -> bool: + """Match one CLI-selected repo file to its exact changed-path identity.""" + + return is_configured_manifest( + selected_path, + changed_path, + workspace=workspace, + ) + + +def _manifest_label(config_path: Path | None, workspace: Path) -> str: + if config_path is None: + return "shipgate.yaml" + if not config_path.is_absolute(): + return config_path.as_posix() + try: + return config_path.relative_to(workspace).as_posix() + except ValueError: + return config_path.as_posix() + + def _sanitize_violations( violations: list[AgentResultViolatedRule], ) -> list[AgentResultViolatedRule]: @@ -1044,6 +1293,7 @@ def _structural_diff_issues( diff_text: str, ) -> list[BoundaryInputIssue]: issues: list[BoundaryInputIssue] = [] + seen_paths: set[str] = set() if diff_text.strip() and not diff_files: issues.append( BoundaryInputIssue( @@ -1053,6 +1303,58 @@ def _structural_diff_issues( ) ) for item in diff_files: + record_paths = { + path for path in (item.old_path, item.new_path) if path is not None + } + duplicate_paths = sorted(record_paths.intersection(seen_paths)) + seen_paths.update(record_paths) + if duplicate_paths: + issues.extend( + BoundaryInputIssue( + code="boundary_diff_shape_invalid", + path=path, + message=( + "The supplied diff contains more than one file record " + "for the same path; one coherent record per path is required." + ), + ) + for path in duplicate_paths + ) + continue + shape_errors = [ + *( + ["new-file record retains an old path"] + if item.is_new and item.old_path is not None + else [] + ), + *( + ["deleted-file record retains a new path"] + if item.is_deleted and item.new_path is not None + else [] + ), + *( + ["record is both new and deleted"] + if item.is_new and item.is_deleted + else [] + ), + *( + ["new/deleted record is also marked as a rename"] + if item.is_rename and (item.is_new or item.is_deleted) + else [] + ), + ] + if shape_errors: + issues.append( + BoundaryInputIssue( + code="boundary_diff_shape_invalid", + path=item.path or "", + message=( + "A diff record has contradictory file-mode and path " + f"headers: {', '.join(shape_errors)}." + ), + ) + ) + continue invalid_paths = [ path for path in (item.old_path, item.new_path) @@ -1105,11 +1407,7 @@ def _structural_diff_issues( def _is_canonical_diff_path(path: str) -> bool: - if not path or "\x00" in path or "\\" in path: - return False - if path.startswith(("/", "./")) or "//" in path: - return False - return ".." not in Path(path).parts + return is_portable_repo_path(path) def _validate_renamed_boundary_head( diff --git a/src/agents_shipgate/core/agent_controls.py b/src/agents_shipgate/core/agent_controls.py index da3a8e2a..60ceeac2 100644 --- a/src/agents_shipgate/core/agent_controls.py +++ b/src/agents_shipgate/core/agent_controls.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import shlex from pathlib import Path @@ -38,6 +39,8 @@ def verify_command_for( workspace: Path | None, config: Path | None, *, + base: str | None = None, + head: str | None = None, extra: tuple[str, ...] = (), ) -> str: """The verify invocation that evaluates the given target. @@ -53,31 +56,89 @@ def verify_command_for( ``verify`` can reject a config outside the requested workspace instead of silently selecting a same-named file there. - The workspace is emitted as given: it is the anchor the caller already - proved resolvable from where they are standing. + The workspace is anchored lexically to the invocation directory. Control + commands are commonly executed after a tool/cwd transition; retaining a + relative spelling would silently retarget the authorized operation. """ - if workspace is None and config is None: + if (base is None) != (head is None): + raise ValueError("verify command requires both base and head, or neither") + if workspace is None and config is None and base is None and not extra: return DEFAULT_VERIFY_COMMAND parts = ["agents-shipgate", "verify"] if workspace is not None: - parts.extend(["--workspace", shlex.quote(str(workspace))]) + parts.extend(["--workspace", shlex.quote(_cwd_anchored(workspace))]) if config is not None: parts.extend(["--config", shlex.quote(_config_for_verify(workspace, config))]) + if base is not None and head is not None: + parts.extend(["--base", shlex.quote(base), "--head", shlex.quote(head)]) parts.extend(extra) parts.append("--json") return " ".join(parts) +def detect_command_for(workspace: Path | None) -> str: + """The detect invocation for the same checkout as a boundary check.""" + + requested_workspace = workspace or Path(".") + return ( + "shipgate detect --workspace " + f"{shlex.quote(_cwd_anchored(requested_workspace))} --json" + ) + + +def _cwd_anchored(path: Path) -> str: + """Absolute lexical spelling that preserves symlink/``..`` traversal.""" + + return str(path if path.is_absolute() else Path.cwd() / path) + + +def preview_command_for( + workspace: Path | None, + config: Path | None, + *, + base: str | None = None, + head: str | None = None, +) -> str: + """The verify-preview invocation for the same target and ref range.""" + + return verify_command_for( + workspace, + config, + base=base, + head=head, + extra=("--preview",), + ) + + def _config_for_verify(workspace: Path | None, config: Path) -> str: requested_workspace = workspace or Path(".") - absolute = config if config.is_absolute() else requested_workspace / config + lexical_workspace = Path( + os.path.abspath(os.path.normpath(os.fspath(requested_workspace))) + ) try: - absolute = absolute.resolve() + workspace_anchor = requested_workspace.resolve() except OSError: - return config.as_posix() + workspace_anchor = lexical_workspace + if config.is_absolute(): + lexical_config = Path(os.path.normpath(os.fspath(config))) + try: + workspace_tail = lexical_config.relative_to(lexical_workspace) + except ValueError: + absolute = lexical_config + else: + absolute = workspace_anchor / workspace_tail + else: + absolute = workspace_anchor / config + # Normalize lexical ``.``/``..`` components without following the + # configured manifest itself. Following a symlink here rewrites the + # authorized command to its target and bypasses verify's lexical + # configured-manifest rejection. Only the workspace anchor is canonical: + # callers may spell that anchor through a symlink or filesystem alias, but + # the manifest's own relative tail remains the identity verify must inspect. + absolute = Path(os.path.abspath(os.path.normpath(os.fspath(absolute)))) config_root = git_root_for(absolute.parent) - workspace_root = git_root_for(requested_workspace) + workspace_root = git_root_for(workspace_anchor) if config_root is not None and config_root == workspace_root: try: return absolute.relative_to(config_root).as_posix() @@ -89,6 +150,8 @@ def _config_for_verify(workspace: Path | None, config: Path) -> str: __all__ = [ "DEFAULT_VERIFY_COMMAND", "FORBIDDEN_SHORTCUTS", + "detect_command_for", "git_root_for", + "preview_command_for", "verify_command_for", ] diff --git a/src/agents_shipgate/core/baseline.py b/src/agents_shipgate/core/baseline.py index 4c4f275c..c837ed25 100644 --- a/src/agents_shipgate/core/baseline.py +++ b/src/agents_shipgate/core/baseline.py @@ -23,6 +23,7 @@ from agents_shipgate.core.errors import InputParseError from agents_shipgate.core.evaluation_clock import trust_expiry_date from agents_shipgate.core.findings.identity import legacy_policy_routing_fingerprint +from agents_shipgate.core.static_inputs import read_static_input_text from agents_shipgate.schemas.baseline import ( BaselineFile, BaselineFinding, @@ -232,8 +233,8 @@ def load_baseline(path: Path) -> BaselineFile: if not path.exists(): raise InputParseError(f"Baseline file not found: {path}") try: - return BaselineFile.model_validate_json(path.read_text(encoding="utf-8")) - except (OSError, ValidationError, ValueError) as exc: + return BaselineFile.model_validate_json(read_static_input_text(path)) + except (OSError, UnicodeDecodeError, ValidationError, ValueError) as exc: raise InputParseError(f"Invalid baseline file {path}: {exc}") from exc diff --git a/src/agents_shipgate/core/boundary_diff.py b/src/agents_shipgate/core/boundary_diff.py index 7e7e4a99..4199289c 100644 --- a/src/agents_shipgate/core/boundary_diff.py +++ b/src/agents_shipgate/core/boundary_diff.py @@ -68,6 +68,37 @@ class BoundaryChangeSet: diff_text: str changed_paths: tuple[str, ...] issues: tuple[BoundaryInputIssue, ...] = () + manifest_text_snapshot: str | None = None + + +def git_diff_path_token(prefix: str, path: str) -> str: + """Render one repository path as a Git-compatible diff token. + + Raw diff headers are whitespace-delimited. Synthetic headers therefore + must C-quote names with leading whitespace, controls, non-ASCII bytes, or + Git quoting characters; otherwise a legal repository name can be parsed as + a different path and fall into the invalid-path sentinel. + """ + + raw = f"{prefix}{path}".encode() + if raw and all(0x21 <= byte <= 0x7E and byte not in {0x22, 0x5C} for byte in raw): + return raw.decode("ascii") + escapes = { + 0x07: r"\a", + 0x08: r"\b", + 0x09: r"\t", + 0x0A: r"\n", + 0x0B: r"\v", + 0x0C: r"\f", + 0x0D: r"\r", + 0x22: r"\"", + 0x5C: r"\\", + } + rendered = "".join( + escapes.get(byte, chr(byte) if 0x20 <= byte <= 0x7E else f"\\{byte:03o}") + for byte in raw + ) + return f'"{rendered}"' def parse_unified_diff(diff_text: str) -> list[DiffFile]: @@ -99,12 +130,23 @@ def finish() -> None: for raw_line in diff_text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): if raw_line.startswith("diff --git "): finish() - parts = raw_line.split() - old_path = _strip_diff_prefix(parts[2]) if len(parts) > 2 else None - new_path = _strip_diff_prefix(parts[3]) if len(parts) > 3 else old_path + try: + old_token, remainder = _parse_git_path_token( + raw_line[len("diff --git ") :] + ) + new_token, trailing = _parse_git_path_token(remainder) + if trailing.strip(): + raise ValueError("unexpected diff-header suffix") + old_path = _strip_diff_prefix(old_token) + new_path = _strip_diff_prefix(new_token) + except (UnicodeDecodeError, ValueError): + old_path = "\0invalid-diff-path" + new_path = "\0invalid-diff-path" current = { "old_path": old_path, "new_path": new_path, + "header_old_path": old_path, + "header_new_path": new_path, "added_lines": [], "removed_lines": [], "hunks": [], @@ -121,17 +163,47 @@ def finish() -> None: elif raw_line.startswith("new file mode"): current["is_new"] = True elif raw_line.startswith("rename from "): - current["old_path"] = raw_line[len("rename from ") :].strip() + value = _parse_git_path_value(raw_line[len("rename from ") :]) + current["old_path"] = ( + value + if value == current.get("header_old_path") + else "\0invalid-diff-path" + ) current["is_rename"] = True elif raw_line.startswith("rename to "): - current["new_path"] = raw_line[len("rename to ") :].strip() + value = _parse_git_path_value(raw_line[len("rename to ") :]) + current["new_path"] = ( + value + if value == current.get("header_new_path") + else "\0invalid-diff-path" + ) current["is_rename"] = True elif raw_line.startswith("--- "): - value = raw_line[4:].strip() - current["old_path"] = None if value == "/dev/null" else _strip_diff_prefix(value) + value = _parse_git_path_value(raw_line[4:]) + parsed = None if value == "/dev/null" else _strip_diff_prefix(value) + if ( + (parsed is None and not current.get("is_new")) + or ( + parsed is not None + and parsed != current.get("header_old_path") + ) + ): + current["old_path"] = "\0invalid-diff-path" + else: + current["old_path"] = parsed elif raw_line.startswith("+++ "): - value = raw_line[4:].strip() - current["new_path"] = None if value == "/dev/null" else _strip_diff_prefix(value) + value = _parse_git_path_value(raw_line[4:]) + parsed = None if value == "/dev/null" else _strip_diff_prefix(value) + if ( + (parsed is None and not current.get("is_deleted")) + or ( + parsed is not None + and parsed != current.get("header_new_path") + ) + ): + current["new_path"] = "\0invalid-diff-path" + else: + current["new_path"] = parsed if value == "/dev/null": current["is_deleted"] = True elif raw_line.startswith("@@ "): @@ -166,21 +238,104 @@ def _parse_hunk_header(line: str) -> DiffHunk: def _strip_diff_prefix(value: str) -> str: - value = value.strip() if value.startswith("a/") or value.startswith("b/"): return value[2:] return value +def _parse_git_path_value(value: str) -> str: + """Decode one Git pathname, failing into a structural-review sentinel.""" + + try: + candidate = value + if not value.lstrip(" ").startswith('"'): + # Generic unified diffs conventionally append a timestamp after a + # TAB. Split it before token parsing; otherwise the TAB becomes + # part of the repository pathname and can hide a protected file. + candidate = value.partition("\t")[0] + token, trailing = _parse_git_path_token(candidate) + if trailing: + raise ValueError("unexpected path suffix") + return token + except (UnicodeDecodeError, ValueError): + return "\0invalid-diff-path" + + +def _parse_git_path_token(value: str) -> tuple[str, str]: + """Parse Git's raw or C-quoted pathname token exactly.""" + + value = value.lstrip(" ") + if not value: + raise ValueError("missing Git path") + if not value.startswith('"'): + token, separator, remainder = value.partition(" ") + return token, remainder.lstrip(" ") if separator else "" + + data = bytearray() + index = 1 + escapes = { + "a": 0x07, + "b": 0x08, + "f": 0x0C, + "n": 0x0A, + "r": 0x0D, + "t": 0x09, + "v": 0x0B, + "\\": 0x5C, + '"': 0x22, + } + while index < len(value): + char = value[index] + if char == '"': + token = bytes(data).decode("utf-8", errors="strict") + return token, value[index + 1 :].lstrip(" ") + if char != "\\": + data.extend(char.encode("utf-8")) + index += 1 + continue + index += 1 + if index >= len(value): + raise ValueError("truncated Git path escape") + escaped = value[index] + if escaped in escapes: + data.append(escapes[escaped]) + index += 1 + continue + if escaped in "01234567": + end = index + while end < len(value) and end < index + 3 and value[end] in "01234567": + end += 1 + data.append(int(value[index:end], 8)) + index = end + continue + raise ValueError("unsupported Git path escape") + raise ValueError("unterminated Git path quote") + + def _resolve_changed_file_text( workspace: Path, diff_file: DiffFile, diagnostics: list[AgentResultDiagnostic], read_cache: Any | None = None, + *, + preserve_rename_source: bool = False, ) -> ResolvedFileText: path = diff_file.path if diff_file.is_deleted: old_text = _old_text_from_hunks(diff_file) + workspace_text, workspace_present = _existing_workspace_text( + workspace, + path, + read_cache=read_cache, + ) + if workspace_present and ( + workspace_text is None or workspace_text != old_text + ): + resolved = _unresolved_text("deleted_file_workspace_mismatch") + diagnostics.append( + _content_source_diagnostic(path, resolved, level="warning") + ) + return resolved resolved = ResolvedFileText( old_text=old_text, new_text="" if old_text is not None else None, @@ -192,6 +347,19 @@ def _resolve_changed_file_text( return resolved if diff_file.is_new: new_text = _new_text_from_hunks(diff_file) + workspace_text, workspace_present = _existing_workspace_text( + workspace, + path, + read_cache=read_cache, + ) + if workspace_present and ( + workspace_text is None or workspace_text != new_text + ): + resolved = _unresolved_text("new_file_workspace_mismatch") + diagnostics.append( + _content_source_diagnostic(path, resolved, level="warning") + ) + return resolved resolved = ResolvedFileText( old_text="", new_text=new_text, @@ -202,6 +370,68 @@ def _resolve_changed_file_text( diagnostics.append(_content_source_diagnostic(path, resolved)) return resolved + if diff_file.is_rename and preserve_rename_source: + # A rename diff still has two content-bearing sides. The previous + # resolver treated every rename as ``old_text=""`` because a head + # worktree only retains the destination path. That converted a + # byte-identical rename into a capability addition and lost removals + # when a recognized source was renamed out of an adapter path. + # + # Prefer the destination when the workspace contains the head tree and + # reverse the hunks to reconstruct the source. Also support a base-tree + # workspace by reading the source and applying the hunks forward. + new_path = _safe_workspace_path(workspace, diff_file.new_path or "") + if new_path is not None and new_path.is_file(): + new_text = _read_changed_text( + new_path, + workspace=workspace, + read_cache=read_cache, + ) + if new_text is not None: + old_text = ( + _apply_hunks(new_text, diff_file.hunks, direction="reverse") + if diff_file.hunks + else new_text + ) + if old_text is not None: + resolved = ResolvedFileText( + old_text=old_text, + new_text=new_text, + source="workspace_renamed_head_file", + old_sha256=_sha256_text(old_text), + new_sha256=_sha256_text(new_text), + ) + diagnostics.append(_content_source_diagnostic(path, resolved)) + return resolved + + old_path = _safe_workspace_path(workspace, diff_file.old_path or "") + if old_path is not None and old_path.is_file(): + old_text = _read_changed_text( + old_path, + workspace=workspace, + read_cache=read_cache, + ) + if old_text is not None: + new_text = ( + _apply_hunks(old_text, diff_file.hunks, direction="forward") + if diff_file.hunks + else old_text + ) + if new_text is not None: + resolved = ResolvedFileText( + old_text=old_text, + new_text=new_text, + source="workspace_renamed_base_file", + old_sha256=_sha256_text(old_text), + new_sha256=_sha256_text(new_text), + ) + diagnostics.append(_content_source_diagnostic(path, resolved)) + return resolved + + resolved = _unresolved_text("renamed_file_unresolved") + diagnostics.append(_content_source_diagnostic(path, resolved, level="warning")) + return resolved + if diff_file.is_rename: head_path = _safe_workspace_path(workspace, path) if head_path is None or head_path.is_symlink() or not head_path.is_file(): @@ -318,6 +548,58 @@ def _unresolved_text(source: str) -> ResolvedFileText: ) +def _read_changed_text( + path: Path, + *, + workspace: Path, + read_cache: Any | None, +) -> str | None: + """Read one contained diff side without converting failures to content.""" + + try: + if read_cache is None: + return path.read_text(encoding="utf-8") + text, error = read_cache.read(path, containment_root=workspace) + return text if error is None else None + except (OSError, UnicodeDecodeError): + return None + + +def _existing_workspace_text( + workspace: Path, + path: str, + *, + read_cache: Any | None, +) -> tuple[str | None, bool]: + """Return a present workspace side without treating unsafe aliases as text.""" + + lexical = workspace / path + try: + present = lexical.exists() or lexical.is_symlink() + except OSError: + return None, True + if not present: + return None, False + safe = _safe_workspace_path(workspace, path) + try: + if ( + safe is None + or lexical.is_symlink() + or not lexical.is_file() + ): + return None, True + except OSError: + return None, True + return ( + _read_changed_text( + lexical, + workspace=workspace, + read_cache=read_cache, + ), + True, + ) + + def _content_source_diagnostic( path: str, resolved: ResolvedFileText, @@ -426,7 +708,10 @@ def _canonical_json(value: Any) -> str: def _safe_workspace_path(workspace: Path, value: str) -> Path | None: - candidate = (workspace / value).resolve() + try: + candidate = (workspace / value).resolve() + except (OSError, RuntimeError, ValueError): + return None try: candidate.relative_to(workspace) except ValueError: diff --git a/src/agents_shipgate/core/boundary_registry.py b/src/agents_shipgate/core/boundary_registry.py index a2c41440..e9452032 100644 --- a/src/agents_shipgate/core/boundary_registry.py +++ b/src/agents_shipgate/core/boundary_registry.py @@ -52,6 +52,7 @@ def matches(self, path: str) -> bool: ), globs=( "**/.mcp.json", + "**/CLAUDE.md", ".claude/commands/*", ".claude/commands/**", ".claude/skills/*/SKILL.md", @@ -83,6 +84,8 @@ def matches(self, path: str) -> bool: "policies/host-boundary.shipgate.yaml", ), globs=( + "**/AGENTS.md", + "**/AGENTS.override.md", ".agents/skills/*/SKILL.md", ".agents/skills/**/SKILL.md", ".github/workflows/*.yml", diff --git a/src/agents_shipgate/core/bounded_io.py b/src/agents_shipgate/core/bounded_io.py new file mode 100644 index 00000000..3dc2da60 --- /dev/null +++ b/src/agents_shipgate/core/bounded_io.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import TextIO + +from agents_shipgate.core.errors import InputParseError +from agents_shipgate.core.trust_roots import read_identity_bound_text + +MAX_EXPLICIT_DIFF_BYTES = 32 * 1024 * 1024 +MAX_EXPLICIT_JSON_BYTES = 40 * 1024 * 1024 +MAX_EXPLICIT_PATH_LIST_BYTES = 8 * 1024 * 1024 + + +def ensure_bounded_utf8_text( + text: str, + *, + max_bytes: int, + label: str, +) -> str: + size = len(text.encode("utf-8")) + if size > max_bytes: + raise InputParseError( + f"{label} exceeds the {max_bytes}-byte static input limit" + ) + return text + + +def read_bounded_utf8_file( + path: Path, + *, + max_bytes: int, + label: str, +) -> str: + try: + lexical = Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + return read_identity_bound_text( + lexical.parent, + Path(lexical.name), + max_bytes=max_bytes, + ) + except (OSError, UnicodeDecodeError, ValueError) as exc: + raise InputParseError(f"Could not read {label} {path}: {exc}") from exc + + +def read_bounded_utf8_stdin( + *, + max_bytes: int, + label: str, + stream: TextIO | None = None, +) -> str: + source = stream or sys.stdin + binary = getattr(source, "buffer", None) + if binary is not None: + raw = binary.read(max_bytes + 1) + if len(raw) > max_bytes: + raise InputParseError( + f"{label} exceeds the {max_bytes}-byte static input limit" + ) + try: + return raw.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise InputParseError(f"{label} is not valid UTF-8: {exc}") from exc + # Test/embedded streams may expose text only. Bound characters first, then + # enforce the authoritative UTF-8 byte ceiling. + text = source.read(max_bytes + 1) + return ensure_bounded_utf8_text(text, max_bytes=max_bytes, label=label) + + +__all__ = [ + "MAX_EXPLICIT_DIFF_BYTES", + "MAX_EXPLICIT_JSON_BYTES", + "MAX_EXPLICIT_PATH_LIST_BYTES", + "ensure_bounded_utf8_text", + "read_bounded_utf8_file", + "read_bounded_utf8_stdin", +] diff --git a/src/agents_shipgate/core/codex_boundary.py b/src/agents_shipgate/core/codex_boundary.py index ea05b551..bedc9fcc 100644 --- a/src/agents_shipgate/core/codex_boundary.py +++ b/src/agents_shipgate/core/codex_boundary.py @@ -12,6 +12,12 @@ import yaml from agents_shipgate.core.agent_control import derive_agent_control +from agents_shipgate.core.agent_controls import ( + detect_command_for as _shared_detect_command_for, +) +from agents_shipgate.core.agent_controls import ( + preview_command_for as _shared_preview_command_for, +) from agents_shipgate.core.agent_controls import ( verify_command_for as _shared_verify_command_for, ) @@ -48,7 +54,10 @@ 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.core.trust_roots import ( + read_absolute_identity_bound_text, + trust_root_class_for, +) from agents_shipgate.schemas.agent_control import ( CodingAgentCommandAction, HumanControlAction, @@ -137,12 +146,13 @@ # 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 +# 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"} ) +_GATE_INSTRUCTION_BASENAMES = frozenset({"agents.md", "claude.md"}) _SHIPGATE_TERMS = ( "agents-shipgate", @@ -441,9 +451,15 @@ def evaluate_codex_boundary_result( policy_override: CodexBoundaryPolicy | None = None, policy_diagnostics: list[AgentResultDiagnostic] | None = None, diff_files_override: list[DiffFile] | None = None, + changed_files_override: list[str] | None = None, resolved_text_cache: dict[str, ResolvedFileText] | None = None, static_read_cache: Any | None = None, verify_command: str | None = None, + detect_command: str | None = None, + preview_command: str | None = None, + verification_replayable: bool = False, + discovery_replayable: bool = True, + manifest_label: str = "shipgate.yaml", ) -> AgentResultV2: """Return the local Codex boundary-result projection for a unified diff. @@ -469,13 +485,25 @@ def evaluate_codex_boundary_result( # agents_shipgate.ci.agent_result.build_agent_result; both produce # boundary-result routing fields for different substrates. resolved_verify_command = verify_command or _VERIFY_COMMAND + resolved_detect_command = detect_command or _DETECT_COMMAND + resolved_preview_command = preview_command or _VERIFY_PREVIEW_COMMAND workspace = workspace.resolve() diff_files = ( diff_files_override if diff_files_override is not None else parse_unified_diff(diff_text) ) - changed_files = sorted({item.path for item in diff_files if item.path}) + changed_files = sorted( + { + *(changed_files_override or []), + *( + path + for item in diff_files + for path in (item.new_path, item.old_path) + if path + ), + } + ) if policy_override is None: policy, diagnostics = load_codex_boundary_policy( workspace=workspace, @@ -616,10 +644,22 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None: policy=policy, finding_fingerprints=finding_fingerprints, evaluated_files=evaluated_files, + verification_replayable=verification_replayable, + discovery_replayable=discovery_replayable, ) if undeclared_gap: - first_next_action = _undeclared_next_action(manifest_present=is_adopted_repo) - summary = _undeclared_summary(undeclared_surfaces, manifest_present=is_adopted_repo) + first_next_action = _undeclared_next_action( + manifest_present=is_adopted_repo, + detect_command=resolved_detect_command, + preview_command=resolved_preview_command, + manifest_label=manifest_label, + ) + summary = _undeclared_summary( + undeclared_surfaces, + manifest_present=is_adopted_repo, + manifest_label=manifest_label, + discovery_replayable=discovery_replayable, + ) diagnostics = [*diagnostics, _undeclared_diagnostic(undeclared_surfaces)] trace = [ *_trace_for(policy, decision, violations), @@ -627,15 +667,15 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None: ] suggested_fixes = ( [ - _DETECT_COMMAND, - "Add the surface to shipgate.yaml tool_sources", + *([resolved_detect_command] if discovery_replayable else []), + f"Add the surface to {manifest_label} tool_sources", resolved_verify_command, ] if is_adopted_repo - else [_VERIFY_PREVIEW_COMMAND, resolved_verify_command] + else [resolved_preview_command, resolved_verify_command] ) elif coverage_gap: - first_next_action = _coverage_next_action() + first_next_action = _coverage_next_action(resolved_verify_command) summary = _coverage_summary(coverage_surfaces) diagnostics = [*diagnostics, _coverage_diagnostic(coverage_surfaces)] trace = [*_trace_for(policy, decision, violations), _coverage_trace(coverage_surfaces)] @@ -661,7 +701,20 @@ def add(rule_id: str, *, path: str | None, evidence: dict[str, Any]) -> None: coverage_gap=coverage_gap, trigger_verify_required=trigger_verify_required, violations=violations, + detect_command=resolved_detect_command, + verification_replayable=verification_replayable, + discovery_replayable=discovery_replayable, ) + # A detached caller-provided diff can describe a verification obligation + # without binding it to bytes that ``verify`` can reconstruct. In that + # case the operational control correctly stops, so the top-level prose + # must not keep advertising the now-forbidden verify command. + if ( + (not verification_replayable or not discovery_replayable) + and control.state == "human_review_required" + ): + summary = control.reason + suggested_fixes = [control.reason] return AgentResultV2( agent=agent, # type: ignore[arg-type] subject=AgentResultSubject(agent=agent), @@ -749,7 +802,14 @@ def _governs_the_gate(item: AgentResultViolatedRule) -> bool: return True if not item.path: return False - return trust_root_class_for(item.path) in BAND_EXCLUDED_TRUST_ROOT_CLASSES + classified = trust_root_class_for(item.path) + if classified in BAND_EXCLUDED_TRUST_ROOT_CLASSES: + return True + return ( + classified == "agent_instructions" + and item.path.replace("\\", "/").rsplit("/", 1)[-1].casefold() + in _GATE_INSTRUCTION_BASENAMES + ) def _pending_review_for( @@ -789,22 +849,92 @@ def _control_for_result( trigger_verify_required: bool, violations: Sequence[AgentResultViolatedRule] = (), verify_command: str | None = None, + detect_command: str | None = None, + verification_replayable: bool = True, + discovery_replayable: bool = True, ): """Translate boundary facts into the one shared operational projector.""" # Resolved here rather than as a default: the constant is defined further # down the module. verify_command = verify_command or _VERIFY_COMMAND + detect_command = detect_command or _DETECT_COMMAND - # 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 ( + undeclared_gap + and first_next_action.command == detect_command + and not discovery_replayable + ): + why = ( + "The undeclared surface was evaluated from a ref range, but detect " + "can inspect only the checked-out worktree. A human must select the " + "matching checkout before discovery can continue." + ) + return derive_agent_control( + reason=why, + next_action=HumanControlAction(kind="review", why=why), + verify_required=True, + human_review_required=True, + human_review_why=why, + stop_reason=why, + ) + + graded_review = ( decision == "require_review" and not repair.safe_to_attempt + and not undeclared_gap and violations_within_agent_actionable_band(violations) - ): + ) + command_obligation = verify_required or repair.safe_to_attempt or graded_review + if not verification_replayable and command_obligation: + why = ( + "The supplied diff is not bound to a checkout state that verify can " + "reconstruct. Re-run check against the intended worktree or with both " + "--base and --head before following a verification command." + ) + return derive_agent_control( + reason=why, + next_action=HumanControlAction(kind="review", why=why), + verify_required=True, + human_review_required=True, + human_review_why=why, + stop_reason=why, + ) + + if undeclared_gap: + command = first_next_action.command + if not command: + why = ( + "An undeclared capability surface requires discovery and a " + "manifest coverage decision before verification can continue." + ) + return derive_agent_control( + reason=why, + next_action=HumanControlAction(kind="review", why=why), + verify_required=True, + human_review_required=True, + human_review_why=why, + stop_reason=why, + ) + return derive_agent_control( + reason=summary, + next_action=CodingAgentCommandAction( + kind="discover" if command == detect_command else "configure", + command=command, + why=( + first_next_action.why + or "Discover and declare the uncovered capability surface." + ), + ), + verify_required=True, + allowed_next_commands=[command], + ) + + # 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 graded_review: return derive_agent_control( reason=summary, next_action=CodingAgentCommandAction( @@ -854,7 +984,7 @@ def _control_for_result( if verify_required: command = first_next_action.command or verify_command if undeclared_gap: - kind = "discover" if command == _DETECT_COMMAND else "configure" + kind = "discover" if command == detect_command else "configure" elif coverage_gap or trigger_verify_required: kind = "verify" # Advisory warnings may have a non-verification next action; the @@ -887,6 +1017,7 @@ def load_codex_boundary_policy( workspace: Path, policy_path: Path | None, allow_foreign_rules: bool = False, + policy_text: str | None = None, ) -> tuple[CodexBoundaryPolicy, list[AgentResultDiagnostic]]: diagnostics: list[AgentResultDiagnostic] = [] discovery: list[str] = [] @@ -913,7 +1044,14 @@ def load_codex_boundary_policy( raw_text: str | None = None if candidate is not None and candidate.is_file(): try: - raw_text = candidate.read_text(encoding="utf-8") + raw_text = ( + policy_text + if policy_text is not None + else read_absolute_identity_bound_text( + candidate, + max_bytes=1024 * 1024, + ) + ) loaded = yaml.safe_load(raw_text) or {} if isinstance(loaded, dict): data = loaded @@ -926,7 +1064,7 @@ def load_codex_boundary_policy( path=display_path, ) ) - except (OSError, yaml.YAMLError) as exc: + except (OSError, UnicodeDecodeError, ValueError, yaml.YAMLError) as exc: diagnostics.append( AgentResultDiagnostic( level="error" if explicit else "warning", @@ -1795,7 +1933,13 @@ def _risk_for(violations: list[AgentResultViolatedRule]) -> AgentResultRiskLevel _DETECT_COMMAND = "shipgate detect --workspace . --json" -def verify_command_for(workspace: object | None, config: object | None) -> str: +def verify_command_for( + workspace: object | None, + config: object | None, + *, + base: str | None = None, + head: str | None = None, +) -> str: """The verify invocation that evaluates *this* check's target. The bare default drops both the workspace and the manifest, so a check run @@ -1806,18 +1950,48 @@ def verify_command_for(workspace: object | None, config: object | None) -> str: return _shared_verify_command_for( Path(str(workspace)) if workspace is not None else None, Path(str(config)) if config is not None else None, + base=base, + head=head, + ) + + +def detect_command_for(workspace: object | None) -> str: + return _shared_detect_command_for( + Path(str(workspace)) if workspace is not None else None ) -def _undeclared_next_action(*, manifest_present: bool) -> AgentResultNextAction: +def preview_command_for( + workspace: object | None, + config: object | None, + *, + base: str | None = None, + head: str | None = None, +) -> str: + return _shared_preview_command_for( + Path(str(workspace)) if workspace is not None else None, + Path(str(config)) if config is not None else None, + base=base, + head=head, + ) + + +def _undeclared_next_action( + *, + manifest_present: bool, + detect_command: str = _DETECT_COMMAND, + preview_command: str = _VERIFY_PREVIEW_COMMAND, + manifest_label: str = "shipgate.yaml", +) -> AgentResultNextAction: if manifest_present: return AgentResultNextAction( actor="coding_agent", kind="warn", - command=_DETECT_COMMAND, + command=detect_command, why=( - "This diff changes a tool/capability surface that shipgate.yaml does " - "not declare, so verify cannot gate it yet. Run detect for " + "This diff changes a tool/capability surface that the configured " + f"manifest {manifest_label!r} does not declare, so verify cannot " + "gate it yet. Run detect for " "suggested_sources, add the missing surface to tool_sources, then " "run verify before completing." ), @@ -1825,29 +1999,43 @@ def _undeclared_next_action(*, manifest_present: bool) -> AgentResultNextAction: return AgentResultNextAction( actor="coding_agent", kind="warn", - command=_VERIFY_PREVIEW_COMMAND, + command=preview_command, why=( - "This diff changes a tool/capability surface that shipgate.yaml does not " - "declare because no manifest is configured yet. Run verify preview so it " - "can route setup when Shipgate is relevant, then run verify before " - "completing." + "This diff changes a tool/capability surface, but no manifest is " + f"configured at {manifest_label!r}. Run verify preview so it can route " + "setup when Shipgate is relevant, then run verify before completing." ), ) -def _undeclared_summary(surfaces: list[str], *, manifest_present: bool) -> str: +def _undeclared_summary( + surfaces: list[str], + *, + manifest_present: bool, + manifest_label: str = "shipgate.yaml", + discovery_replayable: bool = True, +) -> str: if manifest_present: + if not discovery_replayable: + return ( + "No Codex boundary rule fired, but the diff changes a " + f"tool/capability surface ({', '.join(surfaces[:5])}) that the " + f"configured manifest {manifest_label!r} does not declare. The " + "evaluated ref is not the checked-out worktree, so local detect " + "cannot inspect the same subject; a human must select the matching " + "checkout before discovery continues." + ) return ( "No Codex boundary rule fired, but the diff changes a tool/capability " - f"surface ({', '.join(surfaces[:5])}) that shipgate.yaml does not " - "declare, so verify cannot gate it yet. Run detect for suggested_sources, " - "add the missing surface to tool_sources, then run verify before reporting " - "completion." + f"surface ({', '.join(surfaces[:5])}) that the configured manifest " + f"{manifest_label!r} does not declare, so verify cannot gate it yet. " + "Run detect for suggested_sources, add the missing surface to " + "tool_sources, then run verify before reporting completion." ) return ( - "No Codex boundary rule fired, but the diff changes a tool/capability surface " - f"({', '.join(surfaces[:5])}) that shipgate.yaml does not declare, so verify " - "cannot gate it yet. Run verify preview to route setup when Shipgate is " + "No Codex boundary rule fired, but the diff changes a tool/capability " + f"surface ({', '.join(surfaces[:5])}) and no manifest is configured at " + f"{manifest_label!r}. Run verify preview to route setup when Shipgate is " "relevant, then run verify before reporting completion." ) @@ -1874,11 +2062,13 @@ def _undeclared_trace(surfaces: list[str]) -> AgentResultTraceEvent: ) -def _coverage_next_action() -> AgentResultNextAction: +def _coverage_next_action( + verify_command: str = _VERIFY_COMMAND, +) -> AgentResultNextAction: return AgentResultNextAction( actor="coding_agent", kind="warn", - command=_VERIFY_COMMAND, + command=verify_command, why=( "shipgate check is boundary-only and did not evaluate the changed tool " "surface; run verify for the capability merge gate before completing." @@ -2102,9 +2292,20 @@ def _audit_id( policy: CodexBoundaryPolicy, finding_fingerprints: list[str], evaluated_files: list[dict[str, Any]], + verification_replayable: bool, + discovery_replayable: bool, ) -> str: payload = { "schema_version": CODEX_BOUNDARY_RESULT_SCHEMA_VERSION, + # Preserve the established id shape for a checkout-bound evaluation. + # Detached text can produce a different operational control from the + # same findings, so it is a distinct audit row. + **( + {"verification_replayable": False} + if not verification_replayable + else {} + ), + **({"discovery_replayable": False} if not discovery_replayable else {}), # The evaluating agent is part of the audited identity: the result # records it, and two runs that differ only by actor are two different # audit rows. Hardcoding "codex" made them collide, which is precisely @@ -2346,7 +2547,10 @@ def _run_script_invokes_shipgate(value: str) -> bool: def _is_codex_config_path(path: str) -> bool: - return _has_boundary_adapter(path, "codex") and Path(path).name == "config.toml" + return ( + _has_boundary_adapter(path, "codex") + and Path(path).name.casefold() == "config.toml" + ) def is_codex_config_path(path: str) -> bool: @@ -2354,23 +2558,39 @@ def is_codex_config_path(path: str) -> bool: def is_mcp_json_path(path: str) -> bool: - return Path(path).name == ".mcp.json" and bool(boundary_adapters_for_path(path)) + return ( + Path(path).name.casefold() == ".mcp.json" + and bool(boundary_adapters_for_path(path)) + ) def resolve_changed_file_text( workspace: Path, diff_file: DiffFile, diagnostics: list[AgentResultDiagnostic], + *, + preserve_rename_source: bool = False, ) -> ResolvedFileText: - return _resolve_changed_file_text(workspace, diff_file, diagnostics) + return _resolve_changed_file_text( + workspace, + diff_file, + diagnostics, + preserve_rename_source=preserve_rename_source, + ) def _is_codex_hooks_path(path: str) -> bool: - return _has_boundary_adapter(path, "codex") and Path(path).name == "hooks.json" + return ( + _has_boundary_adapter(path, "codex") + and Path(path).name.casefold() == "hooks.json" + ) def _is_codex_requirements_path(path: str) -> bool: - return _has_boundary_adapter(path, "codex") and Path(path).name == "requirements.toml" + return ( + _has_boundary_adapter(path, "codex") + and Path(path).name.casefold() == "requirements.toml" + ) def _has_boundary_adapter(path: str, adapter_id: str) -> bool: @@ -2380,25 +2600,31 @@ def _has_boundary_adapter(path: str, adapter_id: str) -> bool: def _is_agent_instructions_path(path: str) -> bool: if not boundary_adapters_for_path(path): return False - name = Path(path).name - return name in {"AGENTS.md", "AGENTS.override.md", "CLAUDE.md"} or path.startswith( + folded = path.casefold() + name = Path(path).name.casefold() + return name in {"agents.md", "agents.override.md", "claude.md"} or folded.startswith( ".cursor/rules/" ) def _is_shipgate_workflow_path(path: str) -> bool: - return _has_boundary_adapter(path, "shared") and Path(path).name in { + return _has_boundary_adapter(path, "shared") and Path(path).name.casefold() in { "agents-shipgate.yml", "agents-shipgate.yaml", } def _is_codex_boundary_policy_path(path: str) -> bool: - return _has_boundary_adapter(path, "shared") and path.startswith("policies/") + return _has_boundary_adapter(path, "shared") and path.casefold().startswith( + "policies/" + ) def _is_codex_skill_path(path: str) -> bool: - return Path(path).name == "SKILL.md" and bool(boundary_adapters_for_path(path)) + return ( + Path(path).name.casefold() == "skill.md" + and bool(boundary_adapters_for_path(path)) + ) def is_boundary_path(path: str) -> bool: diff --git a/src/agents_shipgate/core/errors.py b/src/agents_shipgate/core/errors.py index b9bb5ba5..6349a587 100644 --- a/src/agents_shipgate/core/errors.py +++ b/src/agents_shipgate/core/errors.py @@ -9,3 +9,6 @@ class ConfigError(AgentsShipgateError): class InputParseError(AgentsShipgateError): """Raised when a declared input source cannot be parsed.""" + +class DiscoveryError(AgentsShipgateError): + """Raised when workspace discovery cannot establish bounded input coverage.""" diff --git a/src/agents_shipgate/core/host_boundary.py b/src/agents_shipgate/core/host_boundary.py index 59e54c7e..265a10e4 100644 --- a/src/agents_shipgate/core/host_boundary.py +++ b/src/agents_shipgate/core/host_boundary.py @@ -47,6 +47,7 @@ _dedupe_violations, _display_path, ) +from agents_shipgate.core.trust_roots import read_absolute_identity_bound_text from agents_shipgate.schemas.agent_result_v1 import ( AgentResultDiagnostic, AgentResultRiskLevel, @@ -256,16 +257,24 @@ def load_host_boundary_policy( *, workspace: Path, policy_path: Path, + policy_text: str | None = None, ) -> tuple[HostBoundaryPolicy, list[AgentResultDiagnostic]]: diagnostics: list[AgentResultDiagnostic] = [] candidate = policy_path if policy_path.is_absolute() else workspace / policy_path data: dict[str, Any] | None = None if candidate.is_file(): try: - loaded = yaml.safe_load(candidate.read_text(encoding="utf-8")) or {} + loaded = yaml.safe_load( + policy_text + if policy_text is not None + else read_absolute_identity_bound_text( + candidate, + max_bytes=1024 * 1024, + ) + ) or {} if isinstance(loaded, dict): data = loaded - except (OSError, yaml.YAMLError) as exc: + except (OSError, UnicodeDecodeError, ValueError, yaml.YAMLError) as exc: diagnostics.append( AgentResultDiagnostic( level="warning", diff --git a/src/agents_shipgate/core/host_grants.py b/src/agents_shipgate/core/host_grants.py index 3e662127..7be85a0d 100644 --- a/src/agents_shipgate/core/host_grants.py +++ b/src/agents_shipgate/core/host_grants.py @@ -8,10 +8,12 @@ from __future__ import annotations +import errno import hashlib import json import os import re +import stat import sys import tomllib from dataclasses import dataclass, field @@ -33,6 +35,12 @@ _trigger_names, ) from agents_shipgate.core.privacy import SENSITIVE_VALUE_KEYS +from agents_shipgate.core.trust_roots import ( + IdentityBoundReadSession, + IdentityReadBudget, + IdentityReadBudgetExceeded, + inspect_lexical_path_identity, +) from agents_shipgate.schemas.host_grants import ( HOST_GRANTS_BASELINE_SCHEMA_VERSION, HOST_GRANTS_DRIFT_SCHEMA_VERSION, @@ -51,6 +59,10 @@ HostScope = Literal["repository", "local_static"] MAX_HOST_CONFIG_BYTES = 1024 * 1024 +MAX_HOST_BASELINE_BYTES = 16 * 1024 * 1024 +MAX_HOST_REPOSITORY_ENTRIES = 100_000 +MAX_HOST_STATIC_ENTRIES = 300_000 +MAX_HOST_STATIC_TOTAL_BYTES = 64 * 1024 * 1024 _SECRET_KEY_MARKERS = frozenset(SENSITIVE_VALUE_KEYS) | { "authorization", @@ -83,6 +95,8 @@ class HostStaticParseCache: """Invocation-local cache proving each static source is read/parsed once.""" + max_entries: int = MAX_HOST_STATIC_ENTRIES + max_total_bytes: int = MAX_HOST_STATIC_TOTAL_BYTES _reads: dict[tuple[str, str], tuple[str | None, str | None]] = field( default_factory=dict ) @@ -91,6 +105,20 @@ class HostStaticParseCache: ] = field(default_factory=dict) read_counts: dict[str, int] = field(default_factory=dict) parse_counts: dict[str, int] = field(default_factory=dict) + _budget: IdentityReadBudget = field(init=False, repr=False) + _sessions: dict[str, IdentityBoundReadSession] = field( + default_factory=dict, + init=False, + repr=False, + ) + _resource_bound_error: str | None = field(default=None, init=False, repr=False) + _finished: bool = field(default=False, init=False, repr=False) + + def __post_init__(self) -> None: + self._budget = IdentityReadBudget( + max_entries=self.max_entries, + max_total_bytes=self.max_total_bytes, + ) @staticmethod def _key(path: Path, containment_root: Path) -> tuple[str, str]: @@ -103,9 +131,64 @@ def read( if key not in self._reads: display = str(path.absolute()) self.read_counts[display] = self.read_counts.get(display, 0) + 1 - self._reads[key] = _safe_read(path, containment_root=containment_root) + if self._resource_bound_error is not None: + self._reads[key] = (None, self._resource_bound_error) + else: + try: + self._reads[key] = _safe_read( + path, + containment_root=containment_root, + reader=self.reader_for(containment_root), + ) + except IdentityReadBudgetExceeded as exc: + self._resource_bound_error = ( + "static host-boundary inventory exceeded its aggregate " + f"resource bound ({exc})" + ) + self._reads[key] = (None, self._resource_bound_error) + except (OSError, NotImplementedError, ValueError): + self._reads[key] = ( + None, + "symbolic links or filesystem aliases in configuration " + "paths are not followed", + ) return self._reads[key] + @property + def resource_bound_error(self) -> str | None: + return self._resource_bound_error + + def finish(self) -> None: + """Perform one final exact-name/identity pass for every read root.""" + + if self._finished: + return + if self._resource_bound_error is not None: + raise IdentityReadBudgetExceeded(self._resource_bound_error) + for key in sorted(self._sessions): + try: + self._sessions[key].finish() + except IdentityReadBudgetExceeded as exc: + self._resource_bound_error = ( + "static host-boundary inventory exceeded its aggregate " + f"resource bound ({exc})" + ) + raise + self._finished = True + + def reader_for(self, containment_root: Path) -> IdentityBoundReadSession: + """Return the shared-budget reader for one lexical containment root.""" + + key = str(containment_root.absolute()) + session = self._sessions.get(key) + if session is None: + session = IdentityBoundReadSession( + containment_root, + budget=self._budget, + ) + self._sessions[key] = session + return session + def parse( self, path: Path, *, containment_root: Path ) -> tuple[Any, str | None, str | None]: @@ -300,30 +383,51 @@ def _grant_base( } -def _safe_read(path: Path, *, containment_root: Path) -> tuple[str | None, str | None]: - lexical_root = containment_root.absolute() - lexical_path = path.absolute() +def _safe_read( + path: Path, + *, + containment_root: Path, + reader: IdentityBoundReadSession | None = None, +) -> tuple[str | None, str | None]: + lexical_root = Path(os.path.abspath(os.path.normpath(os.fspath(containment_root)))) + lexical_path = Path(os.path.abspath(os.path.normpath(os.fspath(path)))) try: relative = lexical_path.relative_to(lexical_root) except ValueError: return None, "path is outside the allowlisted static configuration root" - current = lexical_root - if current.is_symlink(): - return None, "symbolic-link configuration roots are not followed" - for part in relative.parts: - current /= part - if current.is_symlink(): - return None, "symbolic links in configuration paths are not followed" - try: - path.resolve().relative_to(containment_root.resolve()) - except (OSError, ValueError): - return None, "resolved path is outside the allowlisted static configuration root" + owns_reader = reader is None + if reader is None: + reader = IdentityBoundReadSession( + lexical_root, + max_entries=MAX_HOST_STATIC_ENTRIES, + max_total_bytes=MAX_HOST_CONFIG_BYTES, + ) try: - if path.stat().st_size > MAX_HOST_CONFIG_BYTES: + raw = reader.read_bytes(relative, max_bytes=MAX_HOST_CONFIG_BYTES) + if owns_reader: + reader.finish() + except IdentityReadBudgetExceeded: + if not owns_reader: + raise + return None, "static host-boundary read exceeded its aggregate resource bound" + except (OSError, NotImplementedError, ValueError) as exc: + message = str(exc) + if "singly-linked regular file" in message: + return None, "configuration path is not one singly-linked regular file" + if "file exceeds" in message: return None, f"file exceeds the {MAX_HOST_CONFIG_BYTES}-byte static audit limit" - return path.read_text(encoding="utf-8"), None - except (OSError, UnicodeError) as exc: - return None, f"file could not be read as UTF-8 ({exc.__class__.__name__})" + if "changed" in message: + return None, "configuration path changed identity while it was read" + if isinstance(exc, (OSError, NotImplementedError)): + return None, f"file could not be read as UTF-8 ({exc.__class__.__name__})" + return None, ( + "symbolic links or filesystem aliases in configuration paths are " + "not followed" + ) + try: + return raw.decode("utf-8", errors="strict"), None + except UnicodeDecodeError: + return None, "file could not be read as UTF-8 (UnicodeDecodeError)" def _load_structured( @@ -475,11 +579,15 @@ def _claude_grants(data: Any, *, scope: HostScope, source: str) -> list[dict[str risk="critical" if risky else "medium", )) for path in sorted(_string_entries(permissions.get("additionalDirectories")) + _string_entries(data.get("additionalDirectories"))): + projected_path = _privacy_projected_path(path) grant = _grant_base( host="claude-code", scope=scope, source=source, kind="additional_path", - identity=path, config={"path": path}, access="write", risk="high", + identity=projected_path, + config={"path": projected_path}, + access="write", + risk="high", ) - grants.append({**grant, "path": path}) + grants.append({**grant, "path": projected_path}) sandbox = data.get("sandbox") if isinstance(sandbox, dict): for setting, value in sorted(sandbox.items()): @@ -503,6 +611,22 @@ def _claude_grants(data: Any, *, scope: HostScope, source: str) -> list[dict[str return grants +def _privacy_projected_path(value: str) -> str: + """Keep grant identity useful without publishing machine-local paths.""" + + expanded = Path(value).expanduser() + if not expanded.is_absolute(): + return Path(os.path.normpath(value)).as_posix() + try: + relative = expanded.relative_to(Path.home()) + except ValueError: + digest = hashlib.sha256( + os.path.normcase(os.path.normpath(value)).encode("utf-8") + ).hexdigest()[:16] + return f"" + return f"~/{relative.as_posix()}" + + def _codex_grants(data: Any, *, scope: HostScope, source: str) -> list[dict[str, Any]]: if not isinstance(data, dict): return [] @@ -759,48 +883,110 @@ def _collect_file( def _source_kind(path: str) -> str: - if path.startswith(".github/workflows/"): + folded = path.casefold() + if folded.startswith(".github/workflows/"): return "workflow" - if path.endswith((".mcp.json", "/mcp.json")): + if folded.endswith((".mcp.json", "/mcp.json")): return "mcp" - if path.endswith("hooks.json"): + if folded.endswith("hooks.json"): return "hooks" - if path.endswith("requirements.toml"): + if folded.endswith("requirements.toml"): return "requirements" if ( - path.endswith((".md", ".mdc", "/SKILL.md")) - or path.startswith(".cursor/rules/") - or path.startswith(".agents/skills/") - or path.startswith("policies/") - or path == "shipgate.yaml" + folded.endswith((".md", ".mdc", "/skill.md")) + or folded.startswith(".cursor/rules/") + or folded.startswith(".agents/skills/") + or folded.startswith("policies/") + or folded == "shipgate.yaml" ): return "instructions" return "config" def _audit_hosts(adapter_id: str, path: str, hosts: tuple[str, ...]) -> tuple[str, ...]: - if path.startswith(".github/workflows/"): + if path.casefold().startswith(".github/workflows/"): return ("github",) if adapter_id == "vscode_mcp": return ("vscode",) return hosts -def _repository_paths(root: Path) -> list[tuple[Path, str, str, str]]: +def _repository_paths( + root: Path, + *, + reader: IdentityBoundReadSession, +) -> tuple[list[tuple[Path, str, str, str]], int]: """Enumerate repository sources exclusively from the boundary registry.""" indexed: dict[tuple[str, str], tuple[Path, str, str, str]] = {} + skipped = {".git", ".hg", ".svn", "node_modules", "site-packages", ".venv", "venv"} + candidates: list[tuple[Path, str]] = [] + symlink_directories: list[str] = [] + visited = 0 + pending = [Path()] + while pending: + relative_directory = pending.pop() + directory = root / relative_directory + try: + names = reader.directory_entries( + relative_directory, + max_entries=MAX_HOST_REPOSITORY_ENTRIES - visited, + ) + except IdentityReadBudgetExceeded as exc: + raise RuntimeError( + "repository host-boundary inventory exceeded its static " + "filesystem-entry bound" + ) from exc + except (OSError, NotImplementedError, ValueError) as exc: + raise RuntimeError( + "repository host-boundary inventory could not inspect the " + "workspace safely" + ) from exc + visited += len(names) + child_directories: list[Path] = [] + for name in names: + candidate = directory / name + relative = candidate.relative_to(root).as_posix() + try: + metadata = candidate.lstat() + if stat.S_ISLNK(metadata.st_mode): + if name not in skipped: + candidates.append((candidate, relative)) + symlink_directories.append(relative) + continue + if stat.S_ISDIR(metadata.st_mode): + if name not in skipped: + child_directories.append(Path(relative)) + continue + except (OSError, ValueError) as exc: + raise RuntimeError( + "repository host-boundary inventory could not inspect a " + "workspace entry safely" + ) from exc + candidates.append((candidate, relative)) + pending.extend(reversed(child_directories)) + for adapter in BOUNDARY_ADAPTERS: - candidates: list[tuple[Path, str]] = [] - for relative in adapter.exact_paths: - path = root / relative - if path.exists() or path.is_symlink(): - candidates.append((path, relative)) - for pattern in adapter.globs: - for path in sorted(root.glob(pattern)): - if path.is_file() or path.is_symlink(): - candidates.append((path, path.relative_to(root).as_posix())) - for path, relative in candidates: + for expected in adapter.exact_paths: + if any( + expected.casefold().startswith(f"{prefix.casefold()}/") + for prefix in symlink_directories + ): + candidates.append((root / expected, expected)) + + for path, relative in candidates: + for adapter in BOUNDARY_ADAPTERS: + if not ( + adapter.matches(relative) + or ( + relative in symlink_directories + and any( + _symlink_may_hide_boundary_glob(relative, pattern) + for pattern in adapter.globs + ) + ) + ): + continue for host in _audit_hosts(adapter.id, relative, adapter.hosts): indexed[(host, relative)] = ( path, @@ -808,7 +994,22 @@ def _repository_paths(root: Path) -> list[tuple[Path, str, str, str]]: host, _source_kind(relative), ) - return [indexed[key] for key in sorted(indexed)] + return [indexed[key] for key in sorted(indexed)], visited + + +def _symlink_may_hide_boundary_glob(relative: str, pattern: str) -> bool: + """Whether a symlink directory can conceal descendants of ``pattern``.""" + + path = relative.casefold().strip("/") + candidate_pattern = pattern.casefold().strip("/") + if candidate_pattern.startswith("**/"): + return bool(path) + fixed_prefix = candidate_pattern.split("*", 1)[0].rstrip("/") + return bool(fixed_prefix) and ( + path == fixed_prefix + or path.startswith(f"{fixed_prefix}/") + or fixed_prefix.startswith(f"{path}/") + ) def _repository_sources_expected(host: str) -> list[str]: @@ -982,7 +1183,17 @@ def build_host_boundary_snapshot( grants: list[dict[str, Any]] = [] issues: list[dict[str, Any]] = [] - for path, source, host, kind in _repository_paths(root): + resource_bound_error: str | None = None + identity_snapshot_error: str | None = None + try: + repository_paths, _inventory_entries = _repository_paths( + root, + reader=cache.reader_for(root), + ) + except (RuntimeError, IdentityReadBudgetExceeded) as exc: + repository_paths = [] + resource_bound_error = str(exc) + for path, source, host, kind in repository_paths: _collect_file( path=path, source=source, host=host, scope="repository", kind=kind, containment_root=root, cache=cache, @@ -1015,6 +1226,48 @@ def build_host_boundary_snapshot( if scope == "local_static": issues.extend(_local_precedence_issues(artifacts)) + try: + cache.finish() + except IdentityReadBudgetExceeded as exc: + resource_bound_error = cache.resource_bound_error or str(exc) + except (OSError, NotImplementedError, ValueError) as exc: + identity_snapshot_error = ( + "static host-boundary inventory changed identity while its bounded " + f"snapshot was captured ({exc})" + ) + if cache.resource_bound_error is not None: + resource_bound_error = cache.resource_bound_error + if resource_bound_error is not None: + # Aggregate exhaustion can prevent the final identity pass. Do not + # retain a partially validated projection as diagnostic grant data. + artifacts.clear() + grants.clear() + for host in ("codex", "claude-code", "cursor", "vscode", "github"): + issues.append( + _inventory_issue( + kind="unreadable", + host=host, + source="", + message=resource_bound_error, + blocking=True, + ) + ) + if identity_snapshot_error is not None: + # No parsed projection is trustworthy when the final exact-name pass + # cannot bind it to the entries that were opened. + artifacts.clear() + grants.clear() + for host in ("codex", "claude-code", "cursor", "vscode", "github"): + issues.append( + _inventory_issue( + kind="unreadable", + host=host, + source="", + message=identity_snapshot_error, + blocking=True, + ) + ) + artifacts.sort(key=lambda item: (item["host"], item["scope"], item["path"], item["kind"])) grants.sort(key=lambda item: item["grant_id"]) issues.sort(key=lambda item: item["issue_id"]) @@ -1100,41 +1353,208 @@ def build_host_grants_baseline(inventory: dict[str, Any]) -> dict[str, Any]: def load_host_grants_baseline(path: Path) -> dict[str, Any]: - rerun = "shipgate audit --host --scope repository --save-baseline" + baseline, _text = load_host_grants_baseline_with_text(path) + return baseline + + +def load_host_grants_baseline_with_text( + path: Path, +) -> tuple[dict[str, Any], str]: + """Return validated baseline data and the exact descriptor-bound text.""" + + display_path = path + path = _exact_baseline_read_path(path) try: - data = json.loads(path.read_text(encoding="utf-8")) + text = _read_exact_baseline_text(path, display_path=display_path) + data = json.loads(text) except OSError as exc: - raise ValueError(f"No host-grants baseline at {path} ({exc}). Record one first: {rerun}") from exc + raise ValueError( + f"No readable host-grants baseline at {path} ({exc}). A human must " + "review the current grants before creating or replacing a baseline." + ) from exc except json.JSONDecodeError as exc: - raise ValueError(f"Host-grants baseline {path} is not valid JSON ({exc}). Re-record it: {rerun}") from exc + raise ValueError( + f"Host-grants baseline {path} is not valid JSON ({exc}). Inspect and " + "repair or replace it deliberately; do not overwrite it with the " + "current grants." + ) from exc if not isinstance(data, dict): - raise ValueError(f"Host-grants baseline {path} must be a JSON object. Re-record it: {rerun}") + raise ValueError( + f"Host-grants baseline {path} must be a JSON object. Inspect and " + "repair or replace it deliberately." + ) version = data.get("host_grants_schema_version") if version == "0.1": # A valid-looking v0.1 artifact is intentionally not projected into v0.2: # it lacks scope and typed grant identities, so any diff would be lossy. if not isinstance(data.get("inventory"), dict): - raise ValueError(f"Host-grants baseline {path} is missing its inventory. Re-record it: {rerun}") - return data + raise ValueError( + f"Host-grants baseline {path} is missing its inventory. Inspect " + "and repair or replace it deliberately." + ) + return data, text if version != HOST_GRANTS_BASELINE_SCHEMA_VERSION: raise ValueError( - f"Host-grants baseline {path} has unsupported schema version {version!r}. Re-record it: {rerun}" + f"Host-grants baseline {path} has unsupported schema version " + f"{version!r}. A human must review migration or replacement." ) try: parsed = HostGrantsBaselineV2.model_validate(data).model_dump(mode="json") except ValidationError: - return { - "host_grants_schema_version": "0.2-invalid", - "_load_error": "malformed_v0.2_baseline", - } + return ( + { + "host_grants_schema_version": "0.2-invalid", + "_load_error": "malformed_v0.2_baseline", + }, + text, + ) stored = parsed["inventory_sha256"] recomputed = host_grants_sha256(parsed["inventory"]) if stored != recomputed: raise ValueError( f"Host-grants baseline {path} failed its integrity check: stored " - f"inventory_sha256 {stored!r} does not match {recomputed}. After human review, re-record it: {rerun}" + f"inventory_sha256 {stored!r} does not match {recomputed}. Inspect " + "the existing evidence and repair or replace it deliberately." + ) + return parsed, text + + +def _read_exact_baseline_text(path: Path, *, display_path: Path) -> str: + """Read the validated baseline through one identity-bound descriptor.""" + + flags = ( + os.O_RDONLY + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise ValueError( + f"No readable host-grants baseline at {display_path} ({exc})." + ) from exc + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1: + raise ValueError( + f"Host-grants baseline {display_path} must be one exact, " + "singly-linked regular file." + ) + if opened.st_size > MAX_HOST_BASELINE_BYTES: + raise ValueError( + f"Host-grants baseline {display_path} exceeds the " + f"{MAX_HOST_BASELINE_BYTES}-byte static read limit." + ) + raw = bytearray() + while chunk := os.read( + descriptor, + min(1024 * 1024, MAX_HOST_BASELINE_BYTES + 1 - len(raw)), + ): + raw.extend(chunk) + if len(raw) > MAX_HOST_BASELINE_BYTES: + raise ValueError( + f"Host-grants baseline {display_path} exceeds the " + f"{MAX_HOST_BASELINE_BYTES}-byte static read limit." + ) + after_read = os.fstat(descriptor) + text = bytes(raw).decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError( + f"Host-grants baseline {display_path} is not valid UTF-8 ({exc})." + ) from exc + finally: + if descriptor >= 0: + os.close(descriptor) + + # Recheck the lexical components and final directory entry after the read. + # Together with O_NOFOLLOW and the descriptor metadata, this detects a + # symlink/rename swap between validation and use instead of accepting bytes + # from a different trust-evidence artifact. + anchor = Path(path.anchor) + relative = path.relative_to(anchor) + issue = inspect_lexical_path_identity(anchor, relative) + try: + current = path.lstat() + except OSError as exc: + raise ValueError( + f"Host-grants baseline {display_path} changed while it was read." + ) from exc + if ( + issue is not None + or _stable_file_metadata(opened) != _stable_file_metadata(after_read) + or _stable_file_metadata(opened) != _stable_file_metadata(current) + or not stat.S_ISREG(current.st_mode) + or current.st_nlink != 1 + ): + raise ValueError( + f"Host-grants baseline {display_path} changed identity while it " + "was read; retry only after a human verifies the artifact." + ) + return text + + +def _stable_file_metadata(metadata: os.stat_result) -> tuple[int, ...]: + """Return the identity and mutation fields that must remain read-stable.""" + + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_nlink, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) + + +def _exact_baseline_read_path(path: Path) -> Path: + """Return one exact regular, singly-linked baseline path. + + Baseline bytes are acknowledged trust evidence. Reading through a symlink + can silently substitute an external artifact, while a hardlink can mutate + the committed baseline through another name. Normalize lexical ``..`` + components and then inspect the exact path that will be read. + """ + + lexical = Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + anchor = Path(lexical.anchor) + try: + relative = lexical.relative_to(anchor) + except ValueError as exc: + raise ValueError( + f"Host-grants baseline {path} has an unsupported path identity; " + "select one exact regular file." + ) from exc + issue = inspect_lexical_path_identity(anchor, relative) + if issue is not None: + detail = f": {issue.detail}" if issue.detail else "" + raise ValueError( + f"Host-grants baseline {path} must use one exact non-symlink " + f"filesystem identity ({issue.kind} at {issue.requested}{detail})." + ) + try: + metadata = lexical.lstat() + except FileNotFoundError: + return lexical + except OSError as exc: + raise ValueError( + f"Could not inspect host-grants baseline {path}: {exc}" + ) from exc + if not stat.S_ISREG(metadata.st_mode): + cause = IsADirectoryError( + errno.EISDIR, + "baseline path is not a regular file", + str(lexical), + ) + raise ValueError( + f"Host-grants baseline {path} must be a regular file." + ) from cause + if metadata.st_nlink != 1: + raise ValueError( + f"Host-grants baseline {path} must not be hardlinked; select one " + "independently stored reviewed artifact." ) - return parsed + return lexical def diff_host_grants(baseline: dict[str, Any], current: dict[str, Any]) -> list[dict[str, Any]]: @@ -1230,7 +1650,7 @@ def _incomparable_payload( # meaning cannot be trusted. Advertising --save-baseline here would # replace that evidence with the current grants and silently # acknowledge them. Missing baselines are handled before this builder - # and may still receive an explicit save command. + # and also route to a human before any first acknowledgement. "next_action": None, } return HostGrantsDriftV2.model_validate(payload).model_dump(mode="json") @@ -1368,6 +1788,7 @@ def render_host_drift_markdown(payload: dict[str, Any]) -> str: "host_grants_sha256", "inventory_is_complete", "load_host_grants_baseline", + "load_host_grants_baseline_with_text", "normalized_host_grants", "redacted_config_sha256", "render_host_audit_markdown", diff --git a/src/agents_shipgate/core/lenses/tool_surface.py b/src/agents_shipgate/core/lenses/tool_surface.py index 28070441..593c1429 100644 --- a/src/agents_shipgate/core/lenses/tool_surface.py +++ b/src/agents_shipgate/core/lenses/tool_surface.py @@ -17,6 +17,7 @@ from agents_shipgate.core.findings.identity import _canonicalize_for_fingerprint from agents_shipgate.core.heuristics import is_broad_scope from agents_shipgate.core.risk_hints import HIGH_RISK_TAGS, risk_tags +from agents_shipgate.core.static_inputs import read_static_input_text from agents_shipgate.core.tool_identity import ToolSelectorIndex from agents_shipgate.core.toolkit_scope import toolkit_bound_facts from agents_shipgate.schemas.baseline import BaselineFile @@ -291,8 +292,8 @@ def load_tool_surface_diff_reference( if not path.exists(): raise InputParseError(f"Diff reference file not found: {path}") try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + payload = json.loads(read_static_input_text(path)) + except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc: raise InputParseError(f"Invalid diff reference file {path}: {exc}") from exc if not isinstance(payload, dict): raise InputParseError(f"Invalid diff reference file {path}: expected object") diff --git a/src/agents_shipgate/core/manifest_proposals.py b/src/agents_shipgate/core/manifest_proposals.py index 5a9854b4..a25f516a 100644 --- a/src/agents_shipgate/core/manifest_proposals.py +++ b/src/agents_shipgate/core/manifest_proposals.py @@ -47,6 +47,7 @@ def assess_coverage_increasing_tool_source_proposal( workspace: Path, diff_file: DiffFile, resolved: ResolvedFileText | None = None, + manifest_dir: Path | None = None, ) -> ToolSourceProposalAssessment: """Recognize an append-only, coverage-increasing manifest proposal. @@ -56,8 +57,8 @@ def assess_coverage_increasing_tool_source_proposal( * every non-``tool_sources`` value must remain identical; * all existing source rows must remain identical and in the same order; * added rows may use only built-in adapters and non-authority fields; and - * every added path must resolve to an existing, non-symlink workspace - artifact of the expected broad shape. + * every added path must resolve from the manifest directory to an + existing, non-symlink workspace artifact of the expected broad shape. A safe result authorizes proposal authorship only. It never supplies or asserts approval, action semantics, bindings, policy evidence, or release @@ -65,6 +66,12 @@ def assess_coverage_increasing_tool_source_proposal( """ root = workspace.resolve() + source_root = manifest_dir if manifest_dir is not None else root + source_root = source_root if source_root.is_absolute() else root / source_root + try: + source_root.relative_to(root) + except ValueError: + return _unsafe("manifest directory resolves outside the workspace") if resolved is None: resolved = _resolve_changed_file_text(root, diff_file, []) if resolved.old_text is None or resolved.new_text is None: @@ -102,7 +109,11 @@ def assess_coverage_increasing_tool_source_proposal( return _unsafe(added_rows_reason) added_ids: list[str] = [] for row in additions: - reason = _validate_added_source(root, row) + reason = _validate_added_source( + containment_root=root, + source_root=source_root, + row=row, + ) if reason is not None: return _unsafe(reason) assert isinstance(row, dict) # proved by _validate_added_source @@ -127,7 +138,12 @@ def _load_yaml_mapping(text: str) -> dict[str, Any]: return dict(payload) -def _validate_added_source(root: Path, row: Any) -> str | None: +def _validate_added_source( + *, + containment_root: Path, + source_root: Path, + row: Any, +) -> str | None: if not isinstance(row, dict): return "added tool source must be a mapping" if not set(row).issubset(_SAFE_SOURCE_KEYS): @@ -152,11 +168,17 @@ def _validate_added_source(root: Path, row: Any) -> str | None: pure = PurePosixPath(normalized) if pure.is_absolute() or normalized in {"", "."} or ".." in pure.parts: return "added tool source path must be a contained non-root relative path" - candidate = root.joinpath(*pure.parts) - if _path_has_symlink(root, pure): + candidate = source_root.joinpath(*pure.parts) + try: + candidate_relative = PurePosixPath( + candidate.relative_to(containment_root).as_posix() + ) + except ValueError: + return "added tool source path resolves outside the workspace" + if _path_has_symlink(containment_root, candidate_relative): return "added tool source path must not traverse a symlink" try: - candidate.resolve().relative_to(root) + candidate.resolve().relative_to(containment_root) except (OSError, ValueError): return "added tool source path resolves outside the workspace" if not candidate.exists(): @@ -167,10 +189,12 @@ def _validate_added_source(root: Path, row: Any) -> str | None: if source_type == "codex_plugin": if mode == "package": plugin_manifest = candidate / ".codex-plugin" / "plugin.json" - plugin_manifest_relative = PurePosixPath(plugin_manifest.relative_to(root).as_posix()) + plugin_manifest_relative = PurePosixPath( + plugin_manifest.relative_to(containment_root).as_posix() + ) if ( not candidate.is_dir() - or _path_has_symlink(root, plugin_manifest_relative) + or _path_has_symlink(containment_root, plugin_manifest_relative) or not plugin_manifest.is_file() ): return "Codex plugin package lacks .codex-plugin/plugin.json" diff --git a/src/agents_shipgate/core/preflight.py b/src/agents_shipgate/core/preflight.py index 58d39783..7ee90432 100644 --- a/src/agents_shipgate/core/preflight.py +++ b/src/agents_shipgate/core/preflight.py @@ -3,14 +3,17 @@ import hashlib import json import os +import posixpath +import shlex +import stat from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any from pydantic import ValidationError from agents_shipgate.checks.verify import TRUST_ROOT_SURFACES -from agents_shipgate.config.loader import load_manifest +from agents_shipgate.config.loader import load_manifest_text from agents_shipgate.core.agent_control import derive_agent_control from agents_shipgate.core.agent_controls import ( FORBIDDEN_SHORTCUTS, @@ -34,7 +37,15 @@ from agents_shipgate.core.manifest_proposals import ( assess_coverage_increasing_tool_source_proposal, ) -from agents_shipgate.core.trust_roots import is_configured_manifest +from agents_shipgate.core.trust_roots import ( + IdentityBoundReadSession, + IdentityReadBudget, + IdentityReadBudgetExceeded, + inspect_lexical_path_identity, + is_configured_manifest, + is_portable_repo_path, + read_identity_bound_text, +) from agents_shipgate.schemas.agent_control import ( CodingAgentCommandAction, HumanControlAction, @@ -76,6 +87,7 @@ "financial_write", "production_operation", "destructive", + "external_communication", "code_execution", "identity_access", "privileged_data_access", @@ -123,15 +135,16 @@ ".venv", "__pycache__", "agents-shipgate-reports", - "build", - "dist", - "env", "node_modules", "site-packages", - "target", "venv", } ) +_MAX_TRUST_ROOT_INVENTORY_ENTRIES = 100_000 +_MAX_TRUST_ROOT_GRAPH_ENTRIES = 200_000 +_MAX_TRUST_ROOT_GRAPH_BYTES = 64 * 1024 * 1024 +_MAX_TRUST_ROOT_FILE_BYTES = 16 * 1024 * 1024 +_MAX_MANIFEST_BYTES = 4 * 1024 * 1024 _VERIFY_COMMAND = ( "agents-shipgate verify --workspace . --config shipgate.yaml --ci-mode advisory --json" ) @@ -258,18 +271,33 @@ def build_preflight_result( host_baseline: Path | None = None, ) -> PreflightResultV3: root = workspace.resolve() - config_path = config if config.is_absolute() else root / config - config_path = config_path.resolve() + config_path = _lexical_config_path( + root, + config, + requested_workspace=workspace, + ) request_plan = _coerce_plan(plan) + _reject_mixed_plan_inputs( + plan=request_plan, + changed_files=changed_files, + capability_request=capability_request, + capability_requests=capability_requests, + host_permission_requests=host_permission_requests, + diff_text=diff_text, + base_preflight=base_preflight, + ) effective_diff_text = request_plan.diff_text if request_plan is not None else diff_text changed_inputs = list(changed_files or []) if request_plan is not None: changed_inputs.extend(request_plan.changed_files) if effective_diff_text: changed_inputs.extend(_changed_files_from_diff_text(effective_diff_text)) - changed = _normalize_changed_files(changed_inputs) - graph = build_trust_root_graph(root) - policy_hash, notes = _policy_hash_for_config(config_path) + changed, path_identity_touches = _normalize_planned_paths(root, changed_inputs) + graph, graph_identity_touches = _build_trust_root_graph( + root, + config_path=config_path, + ) + policy_hash, notes = _policy_hash_for_config(config_path, root=root) surfaces = [ PreflightProtectedSurface( @@ -278,12 +306,34 @@ def build_preflight_result( scope_type=node.scope_type, present=bool(node.present_paths), present_paths=node.present_paths, - description=_spec_by_key()[(node.kind, node.pattern)].description, + description=( + _spec_by_key().get((node.kind, node.pattern)).description + if (node.kind, node.pattern) in _spec_by_key() + else "The exact configured Agents Shipgate manifest." + ), ) for node in graph.nodes ] verify_command = _verify_command(workspace, config) - touches = classify_protected_touches(changed, config_path, root) + identity_paths = {touch.path for touch in path_identity_touches} + classified_touches = classify_protected_touches(changed, config_path, root) + touches = [ + *path_identity_touches, + *( + touch + for touch in graph_identity_touches + if touch.path not in identity_paths + ), + *( + touch + for touch in classified_touches + if touch.path + not in { + *identity_paths, + *(item.path for item in graph_identity_touches), + } + ), + ] touches = _classify_proposal_safe_manifest_touch( workspace=root, config_path=config_path, @@ -321,12 +371,19 @@ def build_preflight_result( signals = _sorted_signals( [ *signals_for_protected_touches(touches), - *signals_for_host_grant_drift(host_grant_drift), + *signals_for_host_grant_drift( + host_grant_drift, + workspace=root, + baseline=host_baseline, + ), *signals_for_capability_requests(requests, verify_command), *least_privilege_signals(requests), *signals_for_host_permission_requests(host_requests), *signals_for_policy_drift( - policy_drift, trust_root_graph_diff, verify_command + policy_drift, + trust_root_graph_diff, + verify_command, + manifest_path=_display_path(config_path, root), ), ] ) @@ -382,12 +439,170 @@ def build_preflight_result( ) +def _lexical_config_path( + root: Path, + config: Path, + *, + requested_workspace: Path | None = None, +) -> Path: + """Return the configured manifest identity without following aliases.""" + + candidate = config if config.is_absolute() else root / config + if config.is_absolute() and requested_workspace is not None: + lexical_workspace = Path( + os.path.abspath(os.path.normpath(os.fspath(requested_workspace))) + ) + lexical_config = Path(os.path.normpath(os.fspath(config))) + try: + requested_tail = lexical_config.relative_to(lexical_workspace) + except ValueError: + pass + else: + # Preserve an external workspace alias while keeping every + # repository-relative component lexical for symlink inspection. + candidate = root / requested_tail + lexical = Path(os.path.normpath(os.fspath(candidate))) + try: + relative = lexical.relative_to(root) + except ValueError as exc: + raise ConfigError(f"--config must be inside --workspace: {config}") from exc + + issue = inspect_lexical_path_identity(root, relative) + if issue is None: + try: + metadata = lexical.lstat() + except FileNotFoundError: + return lexical + except OSError as exc: + raise ConfigError( + f"--config could not be inspected safely: {config}" + ) from exc + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ConfigError( + "--config must identify one singly-linked regular file; " + f"non-regular or hardlinked manifest refused: {config}" + ) + return lexical + requested = _display_path(issue.requested, root) + if issue.kind == "symlink": + raise ConfigError( + f"--config must not contain symlink components: {requested}" + ) + if issue.kind == "alias": + actual = ( + _display_path(issue.actual, root) + if issue.actual is not None + else "a differently spelled filesystem entry" + ) + raise ConfigError( + "--config must use the exact filesystem spelling: " + f"{requested} resolves to {actual}" + ) + raise ConfigError( + f"--config could not be inspected safely: {requested}: {issue.detail}" + ) + + +def _reject_mixed_plan_inputs( + *, + plan: PreflightPlanV1 | None, + changed_files: list[str] | None, + capability_request: CapabilityRequestV1 | dict[str, Any] | None, + capability_requests: list[CapabilityRequestV1 | dict[str, Any]] | None, + host_permission_requests: list[HostPermissionRequestV1 | dict[str, Any]] | None, + diff_text: str | None, + base_preflight: ( + PreflightResultV1 | PreflightResultV2 | PreflightResultV3 | dict[str, Any] | None + ), +) -> None: + """Keep plan and direct-input request shapes mutually exclusive.""" + + if plan is None: + return + mixed = [ + name + for name, value in ( + ("changed_files", changed_files), + ("diff_text", diff_text), + ("capability_request", capability_request), + ("capability_requests", capability_requests), + ("host_permission_requests", host_permission_requests), + ("base_preflight", base_preflight), + ) + if value is not None + ] + if mixed: + raise ConfigError( + "plan cannot be combined with " + + ", ".join(mixed) + + "; put those inputs in the plan object or omit plan." + ) + + def build_trust_root_graph(workspace: Path) -> TrustRootGraphV1: root = workspace.resolve() - candidate_paths = _walk_trust_root_files(root) + graph, _unsafe = _build_trust_root_graph(root) + return graph + + +def _build_trust_root_graph( + root: Path, + *, + config_path: Path | None = None, +) -> tuple[TrustRootGraphV1, list[PreflightProtectedSurfaceTouch]]: + budget = IdentityReadBudget( + max_entries=_MAX_TRUST_ROOT_GRAPH_ENTRIES, + max_total_bytes=_MAX_TRUST_ROOT_GRAPH_BYTES, + ) + reader = IdentityBoundReadSession(root, budget=budget) + candidate_paths, _inventory_entries = _walk_trust_root_files( + root, + reader=reader, + max_entries=_MAX_TRUST_ROOT_INVENTORY_ENTRIES, + ) nodes: list[TrustRootNodeV1] = [] - for spec in sorted(protected_surface_specs(), key=lambda item: (item.kind, item.pattern)): - present_paths = _present_paths(candidate_paths, spec.pattern) + unsafe: dict[str, PreflightProtectedSurfaceTouch] = {} + hash_cache: dict[str, str] = {} + + def file_hashes_for(paths: list[str]) -> dict[str, str]: + file_hashes: dict[str, str] = {} + for path in paths: + if path not in hash_cache: + try: + hash_cache[path] = _file_sha256(reader, Path(path)) + except IdentityReadBudgetExceeded as exc: + raise ConfigError( + "Trust-root graph exceeded its aggregate static " + "resource bound." + ) from exc + except (OSError, ValueError, NotImplementedError): + hash_cache[path] = "unavailable:path_identity" + unsafe[path] = PreflightProtectedSurfaceTouch( + path=path, + kind="path_identity", + pattern="exact-non-symlink-single-link-workspace-path", + scope_type="whole_file", + ) + file_hashes[path] = hash_cache[path] + return file_hashes + + specs = list(protected_surface_specs()) + configured_relative = "" + if config_path is not None: + try: + configured_relative = config_path.relative_to(root).as_posix() + except ValueError: + configured_relative = "" + configured_is_catalogued = bool(configured_relative) and any( + spec.kind == "manifest" + and ( + glob_match(spec.pattern, configured_relative) + or glob_match(spec.pattern.casefold(), configured_relative.casefold()) + ) + for spec in specs + ) + for spec in sorted(specs, key=lambda item: (item.kind, item.pattern)): + present_paths = _present_paths(root, candidate_paths, spec.pattern) nodes.append( TrustRootNodeV1( id=_node_id(spec.kind, spec.pattern), @@ -395,15 +610,42 @@ def build_trust_root_graph(workspace: Path) -> TrustRootGraphV1: pattern=spec.pattern, scope_type=spec.scope_type, present_paths=present_paths, - file_hashes={ - path: _file_sha256(root / path) - for path in present_paths - if (root / path).is_file() - }, + file_hashes=file_hashes_for(present_paths), + ) + ) + if configured_relative and not configured_is_catalogued: + # The configured manifest is an exact identity, not a glob. Legal Git + # filenames may themselves contain ``*``, ``?``, ``[]``, or ``\``. + present_paths = ( + [configured_relative] if configured_relative in candidate_paths else [] + ) + nodes.append( + TrustRootNodeV1( + id=_node_id("manifest", configured_relative), + kind="manifest", + pattern=configured_relative, + scope_type=_scope_type_for_kind("manifest"), + present_paths=present_paths, + file_hashes=file_hashes_for(present_paths), ) ) + nodes.sort(key=lambda item: (item.kind, item.pattern)) + try: + reader.finish() + except IdentityReadBudgetExceeded as exc: + raise ConfigError( + "Trust-root graph exceeded its aggregate static resource bound." + ) from exc + except (OSError, ValueError, NotImplementedError) as exc: + raise ConfigError( + "Trust-root graph changed identity while its bounded snapshot " + "was captured." + ) from exc graph_hash = _stable_hash([node.model_dump(mode="json") for node in nodes]) - return TrustRootGraphV1(nodes=nodes, graph_hash=graph_hash) + return ( + TrustRootGraphV1(nodes=nodes, graph_hash=graph_hash), + sorted(unsafe.values(), key=lambda item: item.path), + ) def classify_protected_touches( @@ -422,7 +664,7 @@ def classify_protected_touches( touches: list[PreflightProtectedSurfaceTouch] = [] seen: set[str] = set() for raw in changed_files: - path = raw.replace("\\", "/").strip() + path = raw.replace("\\", "/") if not path or path in seen: continue seen.add(path) @@ -589,7 +831,7 @@ def signals_for_protected_touches( "required verifier and route its concrete review evidence to a human." ) ), - related_command="agents-shipgate preflight --workspace . --plan - --json", + related_command=None, ) for touch in touches ] @@ -624,6 +866,7 @@ def _classify_proposal_safe_manifest_touch( assessment = assess_coverage_increasing_tool_source_proposal( workspace=workspace, diff_file=matching_diff_files[0], + manifest_dir=config_path.parent, ) if not assessment.proposal_safe: return touches @@ -691,7 +934,7 @@ def least_privilege_signals( "Replace broad scopes with operation-specific scopes or route " "the expansion to a human reviewer." ), - related_command="agents-shipgate preflight --workspace . --plan - --json", + related_command=None, ) ) return signals @@ -708,7 +951,7 @@ def signals_for_host_permission_requests( "actor": "human", "subject": subject, "path": request.path, - "related_command": "agents-shipgate preflight --workspace . --plan - --json", + "related_command": None, } if _host_request_has_wildcard_allow(text): signals.append( @@ -761,6 +1004,8 @@ def signals_for_policy_drift( policy_drift: PreflightDriftSummary | None, trust_root_graph_diff: PreflightDriftSummary | None, verify_command: str = _VERIFY_COMMAND, + *, + manifest_path: str = "shipgate.yaml", ) -> list[PreflightSignalV1]: signals: list[PreflightSignalV1] = [] if policy_drift is not None and policy_drift.changed: @@ -771,7 +1016,7 @@ def signals_for_policy_drift( severity="high", actor="human", subject="effective_policy", - path="shipgate.yaml", + path=manifest_path, reason="Effective release policy hash differs from the supplied base preflight.", recommendation="Have a human review the policy change; preflight cannot prove it is a safe strengthening.", related_command=verify_command, @@ -796,6 +1041,9 @@ def signals_for_policy_drift( def signals_for_host_grant_drift( host_grant_drift: dict[str, Any] | None, + *, + workspace: Path | None = None, + baseline: Path | None = None, ) -> list[PreflightSignalV1]: if not _host_grant_drift_requires_review(host_grant_drift): return [] @@ -814,14 +1062,29 @@ def signals_for_host_grant_drift( expansion = host_grant_drift.get("expansion_signals") or [] if expansion: reason += " Expansion signals: " + ", ".join(str(item) for item in expansion[:5]) - rerun = ( - str( - host_grant_drift.get("next_action") - or "shipgate audit --host --drift --fail-on-drift" + rerun = None + if comparable and workspace is not None: + baseline_path = baseline or DEFAULT_BASELINE_FILE + exact_baseline = ( + baseline_path + if baseline_path.is_absolute() + else workspace / baseline_path + ) + rerun = shlex.join( + [ + "shipgate", + "audit", + "--host", + "--workspace", + str(workspace), + "--scope", + "repository", + "--drift", + "--baseline-file", + str(exact_baseline), + "--fail-on-drift", + ] ) - if comparable - else None - ) recommendation = ( "Route the host-grant comparison to a human. After review, " f"run `{rerun}`." @@ -875,7 +1138,10 @@ def _spec_by_key() -> dict[tuple[str, str], ProtectedSurfaceSpec]: def _classify(path: str) -> ProtectedSurfaceSpec | None: for spec in protected_surface_specs(): - if glob_match(spec.pattern, path): + if glob_match(spec.pattern, path) or glob_match( + spec.pattern.casefold(), + path.casefold(), + ): return spec return None @@ -899,47 +1165,189 @@ def _configured_manifest_spec( ) -def _normalize_changed_files(paths: list[str]) -> list[str]: - return sorted({path.replace("\\", "/").strip() for path in paths if path.strip()}) +def _normalize_planned_paths( + root: Path, + paths: list[str], +) -> tuple[list[str], list[PreflightProtectedSurfaceTouch]]: + """Map planned paths to exact entries or route ambiguous writes to humans.""" + + normalized: set[str] = set() + unsafe: dict[str, PreflightProtectedSurfaceTouch] = {} + + def mark_unsafe(path: str) -> None: + normalized.add(path) + unsafe[path] = PreflightProtectedSurfaceTouch( + path=path, + kind="path_identity", + pattern="exact-non-symlink-single-link-workspace-path", + scope_type="whole_file", + ) + + for raw in paths: + path = raw + if not path: + continue + pure = PurePosixPath(path) + if ( + not is_portable_repo_path(path) + or pure.is_absolute() + or ".." in pure.parts + ): + mark_unsafe(path) + continue + relative = Path(PurePosixPath(posixpath.normpath(path)).as_posix()) + converged = False + for _attempt in range(len(relative.parts) + 1): + issue = inspect_lexical_path_identity(root, relative) + if issue is None: + converged = True + break + if issue.kind != "alias" or issue.actual is None: + mark_unsafe(path) + break + requested = root / relative + try: + suffix = requested.relative_to(issue.requested) + relative = (issue.actual / suffix).relative_to(root) + except ValueError: + mark_unsafe(path) + break + if not converged: + if path not in unsafe: + mark_unsafe(path) + continue + + candidate = root / relative + try: + metadata = candidate.lstat() + except FileNotFoundError: + normalized.add(relative.as_posix()) + continue + except OSError: + mark_unsafe(path) + continue + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + ): + mark_unsafe(path) + continue + normalized.add(relative.as_posix()) + + return ( + sorted(normalized), + sorted(unsafe.values(), key=lambda item: item.path), + ) -def _walk_trust_root_files(root: Path) -> tuple[str, ...]: +def _walk_trust_root_files( + root: Path, + *, + reader: IdentityBoundReadSession, + max_entries: int, +) -> tuple[tuple[str, ...], int]: """Return workspace files considered for trust-root graph presence. The trust-root graph must use the same glob semantics as touch classification. ``Path.glob("**")`` has Python-version-dependent trailing globstar behavior, so walk files once and classify with ``glob_match``. + ``os.walk`` materializes a complete directory before yielding, which cannot + enforce a bound on one hostile, very large directory; the explicit scandir + stack stops as soon as the aggregate graph inventory budget is exhausted. """ + if max_entries < 0: + raise ConfigError( + "Trust-root graph inventory exceeded its aggregate filesystem " + "entry bound." + ) out: list[str] = [] - for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [dirname for dirname in dirnames if dirname not in _TRUST_ROOT_WALK_SKIP_DIRS] - dirnames.sort() - root_path = Path(dirpath) - for filename in sorted(filenames): - path = root_path / filename + visited = 0 + pending = [Path()] + while pending: + relative_directory = pending.pop() + directory = root / relative_directory + try: + names = reader.directory_entries( + relative_directory, + max_entries=max_entries - visited, + ) + except IdentityReadBudgetExceeded as exc: + raise ConfigError( + "Trust-root graph inventory exceeded its aggregate filesystem " + "entry bound." + ) from exc + except (OSError, NotImplementedError, ValueError) as exc: + raise ConfigError( + "Trust-root graph inventory could not inspect the workspace " + "safely." + ) from exc + visited += len(names) + child_directories: list[Path] = [] + for name in names: + path = directory / name try: - if not path.is_file(): + relative = path.relative_to(root).as_posix() + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + if name not in _TRUST_ROOT_WALK_SKIP_DIRS: + out.append(relative) continue - rel = path.relative_to(root).as_posix() - except OSError: - continue - except ValueError: - continue - out.append(rel) - return tuple(out) + if stat.S_ISDIR(metadata.st_mode): + if name not in _TRUST_ROOT_WALK_SKIP_DIRS: + child_directories.append(Path(relative)) + continue + except (OSError, ValueError) as exc: + raise ConfigError( + "Trust-root graph inventory could not inspect a workspace " + "entry safely." + ) from exc + out.append(relative) + pending.extend(reversed(child_directories)) + return tuple(sorted(set(out))), visited + + +def _present_paths( + root: Path, + candidate_paths: tuple[str, ...], + pattern: str, +) -> list[str]: + folded_pattern = pattern.casefold() + return [ + path + for path in candidate_paths + if glob_match(pattern, path) + or glob_match(folded_pattern, path.casefold()) + or ( + (root / path).is_symlink() + and _symlink_may_hide_pattern(path, pattern) + ) + ] + +def _symlink_may_hide_pattern(relative: str, pattern: str) -> bool: + """Whether a traversable symlink directory can hide a matching descendant.""" -def _present_paths(candidate_paths: tuple[str, ...], pattern: str) -> list[str]: - return [path for path in candidate_paths if glob_match(pattern, path)] + path = relative.casefold().strip("/") + candidate_pattern = pattern.casefold().strip("/") + if candidate_pattern.startswith("**/"): + return bool(path) + fixed_prefix = candidate_pattern.split("*", 1)[0].rstrip("/") + return bool(fixed_prefix) and ( + path == fixed_prefix + or path.startswith(f"{fixed_prefix}/") + or fixed_prefix.startswith(f"{path}/") + ) -def _file_sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return f"sha256:{digest.hexdigest()}" +def _file_sha256(reader: IdentityBoundReadSession, relative: Path) -> str: + """Hash one exact workspace file through a no-symlink descriptor chain.""" + + raw = reader.read_bytes( + relative, + max_bytes=_MAX_TRUST_ROOT_FILE_BYTES, + ) + return f"sha256:{hashlib.sha256(raw).hexdigest()}" def _stable_hash(payload: Any) -> str: @@ -947,11 +1355,33 @@ def _stable_hash(payload: Any) -> str: return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}" -def _policy_hash_for_config(config_path: Path) -> tuple[str | None, list[str]]: - if not config_path.is_file(): +def _policy_hash_for_config( + config_path: Path, + *, + root: Path | None = None, +) -> tuple[str | None, list[str]]: + identity_root = root or config_path.parent.resolve() + try: + relative = config_path.relative_to(identity_root) + except ValueError as exc: + raise ConfigError( + f"Configured manifest is outside the policy snapshot root: {config_path}" + ) from exc + try: + raw_text = read_identity_bound_text( + identity_root, + relative, + max_bytes=_MAX_MANIFEST_BYTES, + ) + except FileNotFoundError: return None, [f"No manifest found at {config_path}; policy snapshot unavailable."] + except (OSError, UnicodeDecodeError, ValueError) as exc: + raise ConfigError( + f"Could not read manifest for preflight through an identity-bound " + f"snapshot: {exc}" + ) from exc try: - manifest = load_manifest(config_path) + manifest = load_manifest_text(raw_text, source=config_path) except (ConfigError, InputParseError): raise except Exception as exc: # noqa: BLE001 - normalize loader boundary. @@ -996,10 +1426,16 @@ def _changed_files_from_diff_text(diff_text: str) -> list[str]: moved the gate out from under itself. """ + parsed = parse_unified_diff(diff_text) + if diff_text.strip() and not parsed: + raise ConfigError( + "Preflight diff_text is non-empty but is not a complete unified " + "diff with diff --git file records." + ) return sorted( { path - for item in parse_unified_diff(diff_text) + for item in parsed for path in (item.new_path, item.old_path) if path } @@ -1071,7 +1507,11 @@ def _host_grant_drift_payload( if baseline is None: baseline_path = workspace / DEFAULT_BASELINE_FILE baseline_display = DEFAULT_BASELINE_FILE.as_posix() - if not baseline_path.is_file(): + # ``Path.is_file`` follows symlinks and treats a broken link as + # absence. A lexically present default is acknowledged trust evidence; + # if its identity or bytes are unsafe, preflight must fail closed + # rather than silently behaving as though no baseline were configured. + if not os.path.lexists(baseline_path): return None, None else: baseline_path = baseline if baseline.is_absolute() else workspace / baseline @@ -1081,9 +1521,16 @@ def _host_grant_drift_payload( except ValueError as exc: if not explicit_baseline: return ( - None, + build_host_drift_payload( + baseline={ + "host_grants_schema_version": "unreadable", + "_load_error": "baseline_unreadable_or_invalid", + }, + inventory=host_audit_inventory(workspace), + baseline_file=baseline_display, + ), f"Host-grants baseline {baseline_display} could not be loaded; " - f"host-grant drift skipped: {exc}", + f"host-grant drift is incomparable and requires review: {exc}", ) raise ConfigError(str(exc)) from exc return ( diff --git a/src/agents_shipgate/core/static_inputs.py b/src/agents_shipgate/core/static_inputs.py new file mode 100644 index 00000000..685039c5 --- /dev/null +++ b/src/agents_shipgate/core/static_inputs.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import os +import stat +from collections.abc import Iterable +from contextvars import ContextVar, Token +from pathlib import Path + +from agents_shipgate.core.trust_roots import ( + IdentityBoundReadSession, + IdentityReadBudget, +) + +DEFAULT_STATIC_INPUT_FILE_BYTES = 32 * 1024 * 1024 +DEFAULT_STATIC_INPUT_TOTAL_BYTES = 256 * 1024 * 1024 +DEFAULT_STATIC_INPUT_FILES = 100_000 + + +class StaticInputSnapshot: + """Identity-bound, graph-scoped bytes used by one worktree verification. + + Every path is read at most once. Later scan phases and receipt construction + receive the same cached bytes, so a concurrent path replacement cannot make + the decision evaluate one object while the request identity attests to + another. Aggregate limits keep the snapshot deterministic and bounded. + """ + + def __init__( + self, + root: Path, + *, + external_paths: Iterable[Path] = (), + max_total_bytes: int = DEFAULT_STATIC_INPUT_TOTAL_BYTES, + max_files: int = DEFAULT_STATIC_INPUT_FILES, + ) -> None: + self.root = Path(os.path.abspath(os.path.normpath(os.fspath(root)))) + self.max_total_bytes = max_total_bytes + self.max_files = max_files + self._external_paths = { + Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + for path in external_paths + } + self._entries: dict[Path, bytes] = {} + self._total_bytes = 0 + self._budget = IdentityReadBudget( + max_entries=max_files * 32, + max_total_bytes=max_total_bytes, + ) + self._sessions: dict[Path, IdentityBoundReadSession] = {} + self._finished = False + + def preload(self, path: Path, data: bytes) -> None: + key, _relative = self._key(path) + existing = self._entries.get(key) + if existing is not None: + if existing != data: + raise ValueError(f"static input changed before snapshot: {key}") + return + session, relative = self._session_for(key) + captured = session.read_bytes( + relative, + max_bytes=max(len(data), DEFAULT_STATIC_INPUT_FILE_BYTES), + ) + if captured != data: + raise ValueError(f"static input changed before snapshot: {key}") + self._record(key, data) + + def contains(self, path: Path) -> bool: + raw = path if path.is_absolute() else self.root / path + key = Path(os.path.abspath(os.path.normpath(os.fspath(raw)))) + try: + key.relative_to(self.root) + except ValueError: + return key in self._external_paths + return key != self.root + + def has(self, path: Path) -> bool: + raw = path if path.is_absolute() else self.root / path + key = Path(os.path.abspath(os.path.normpath(os.fspath(raw)))) + return key in self._entries + + def paths_under(self, path: Path) -> list[Path]: + raw = path if path.is_absolute() else self.root / path + directory = Path(os.path.abspath(os.path.normpath(os.fspath(raw)))) + return sorted( + key + for key in self._entries + if key != directory and directory in key.parents + ) + + def paths(self) -> list[Path]: + return sorted(self._entries) + + def read_bytes( + self, + path: Path, + *, + max_bytes: int = DEFAULT_STATIC_INPUT_FILE_BYTES, + ) -> bytes: + key, relative = self._key(path) + cached = self._entries.get(key) + if cached is not None: + if len(cached) > max_bytes: + raise ValueError( + f"static input exceeds the {max_bytes}-byte read limit: {key}" + ) + return cached + if self._finished: + raise ValueError("static input snapshot is already finalized") + session, relative = self._session_for(key) + data = session.read_bytes( + relative, + max_bytes=max_bytes, + ) + self._record(key, data) + return data + + def bind_directory(self, path: Path) -> tuple[str, ...]: + """Bind one directory inventory to the graph-scoped snapshot.""" + + if self._finished: + raise ValueError("static input snapshot is already finalized") + key = Path( + os.path.abspath( + os.path.normpath(os.fspath(path if path.is_absolute() else self.root / path)) + ) + ) + session, relative = self._session_for(key) + return session.directory_entries(relative, max_entries=self.max_files) + + def finish(self) -> None: + """Reject any identity or directory-membership change since capture.""" + + if self._finished: + return + for session in self._sessions.values(): + session.finish() + self._finished = True + + def _key(self, path: Path) -> tuple[Path, Path]: + raw = path if path.is_absolute() else self.root / path + key = Path(os.path.abspath(os.path.normpath(os.fspath(raw)))) + try: + relative = key.relative_to(self.root) + except ValueError as exc: + if key not in self._external_paths: + raise ValueError( + f"static input escapes verification workspace: {key}" + ) from exc + relative = key.relative_to(Path(key.anchor)) + if relative in {Path(), Path(".")}: + raise ValueError("static input path must identify a file") + return key, relative + + def _session_for(self, key: Path) -> tuple[IdentityBoundReadSession, Path]: + if key == self.root or self.root in key.parents: + session_root = self.root + elif key in self._external_paths: + session_root = key.parent + else: + raise ValueError(f"static input escapes verification workspace: {key}") + session = self._sessions.get(session_root) + if session is None: + session = IdentityBoundReadSession(session_root, budget=self._budget) + self._sessions[session_root] = session + return session, key.relative_to(session_root) + + def _record(self, key: Path, data: bytes) -> None: + if len(self._entries) >= self.max_files: + raise ValueError( + f"static input snapshot exceeds the {self.max_files}-file limit" + ) + next_total = self._total_bytes + len(data) + if next_total > self.max_total_bytes: + raise ValueError( + "static input snapshot exceeds the " + f"{self.max_total_bytes}-byte aggregate limit" + ) + self._entries[key] = data + self._total_bytes = next_total + + +_ACTIVE_SNAPSHOT: ContextVar[StaticInputSnapshot | None] = ContextVar( + "agents_shipgate_static_input_snapshot", + default=None, +) + + +def activate_static_input_snapshot( + snapshot: StaticInputSnapshot, +) -> Token[StaticInputSnapshot | None]: + return _ACTIVE_SNAPSHOT.set(snapshot) + + +def reset_static_input_snapshot(token: Token[StaticInputSnapshot | None]) -> None: + _ACTIVE_SNAPSHOT.reset(token) + + +def active_static_input_snapshot() -> StaticInputSnapshot | None: + return _ACTIVE_SNAPSHOT.get() + + +def read_static_input_bytes( + path: Path, + *, + max_bytes: int = DEFAULT_STATIC_INPUT_FILE_BYTES, +) -> bytes: + snapshot = _ACTIVE_SNAPSHOT.get() + if snapshot is not None and snapshot.contains(path): + return snapshot.read_bytes(path, max_bytes=max_bytes) + # Outside a graph-scoped worktree verification, retain the loader's + # established support for repository-root aliases (for example macOS + # ``/var`` -> ``/private/var`` and an explicitly accepted repo symlink). + # The active snapshot path above supplies the stronger no-alias boundary + # where request/decision identity must be frozen. + lexical = Path(path) + metadata = lexical.stat() + if not stat.S_ISREG(metadata.st_mode): + raise ValueError("static input is not a regular file") + if metadata.st_size > max_bytes: + raise ValueError(f"Input file too large (limit: {max_bytes} bytes): {path}") + with lexical.open("rb") as handle: + data = handle.read(max_bytes + 1) + if len(data) > max_bytes: + raise ValueError(f"Input file too large (limit: {max_bytes} bytes): {path}") + return data + + +def read_static_input_text( + path: Path, + *, + max_bytes: int = DEFAULT_STATIC_INPUT_FILE_BYTES, +) -> str: + return read_static_input_bytes(path, max_bytes=max_bytes).decode( + "utf-8", + errors="strict", + ) + + +__all__ = [ + "StaticInputSnapshot", + "active_static_input_snapshot", + "activate_static_input_snapshot", + "read_static_input_bytes", + "read_static_input_text", + "reset_static_input_snapshot", +] diff --git a/src/agents_shipgate/core/trust_roots.py b/src/agents_shipgate/core/trust_roots.py index 138d81e6..2bde4d5f 100644 --- a/src/agents_shipgate/core/trust_roots.py +++ b/src/agents_shipgate/core/trust_roots.py @@ -11,8 +11,12 @@ from __future__ import annotations +import os import posixpath -from pathlib import PurePosixPath +import stat +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Literal from agents_shipgate.core.boundary_registry import BOUNDARY_ADAPTERS from agents_shipgate.core.globbing import glob_match @@ -30,6 +34,7 @@ ("ci_gate", "**/.github/workflows/agents-shipgate.yml"), ("ci_gate", "**/.github/workflows/agents-shipgate.yaml"), ("agent_instructions", "**/AGENTS.md"), + ("agent_instructions", "**/AGENTS.override.md"), ("agent_instructions", "**/CLAUDE.md"), ("agent_instructions", "**/.claude/**"), ("agent_instructions", "**/.cursor/rules/**"), @@ -102,10 +107,16 @@ def _registry_trust_root_surfaces() -> tuple[tuple[str, str], ...]: def trust_root_class_for(path: str) -> str | None: - """Classify ``path`` against the ordered trust-root table, first match wins.""" + """Classify ``path`` against the ordered trust-root table, first match wins. + + Git can carry a lowercase spelling that becomes the canonical host file on + APFS/NTFS (for example ``agents.md`` resolving as ``AGENTS.md``). Treat + ASCII case variants conservatively on every platform so a PR cannot be + approved on Linux and acquire a privileged meaning when cloned elsewhere. + """ for trust_root_class, pattern in TRUST_ROOT_SURFACES: - if glob_match(pattern, path): + if glob_match(pattern, path) or glob_match(pattern.casefold(), path.casefold()): return trust_root_class return None @@ -113,7 +124,9 @@ def trust_root_class_for(path: str) -> str | None: def _normalized(value: object) -> str: """A path with separators unified and ``.``/``..`` segments collapsed.""" - text = str(value).replace("\\", "/").strip() + # Repository paths are exact identities. Stripping turns distinct legal + # Git names such as ``gate.yml`` and `` gate.yml`` into one trust root. + text = str(value).replace("\\", "/") if not text: return "" return PurePosixPath(posixpath.normpath(text)).as_posix() @@ -127,6 +140,33 @@ def _is_absolute(path: str) -> bool: ) +def is_portable_repo_path(path: str) -> bool: + """Whether ``path`` has one portable repository-relative identity.""" + + if ( + not path + or path.startswith(("/", "./", "//")) + or "\\" in path + or ":" in path + or any(ord(character) < 32 for character in path) + ): + return False + reserved = {"con", "prn", "aux", "nul"} + reserved.update(f"com{number}" for number in range(1, 10)) + reserved.update(f"lpt{number}" for number in range(1, 10)) + parts = path.split("/") + for part in parts: + normalized = part.casefold() + if ( + not part + or part in {".", ".."} + or part != part.rstrip(" .") + or normalized.split(".", 1)[0] in reserved + ): + return False + return True + + def _under_workspace(workspace: str, path: str) -> str | None: """Return one canonical spelling only when it remains inside ``workspace``.""" @@ -146,11 +186,717 @@ def _under_workspace(workspace: str, path: str) -> str | None: return resolved +@dataclass(frozen=True) +class PathIdentityIssue: + """A lexical repository path that does not identify one exact entry. + + ``alias`` is deliberately discovered from the filesystem rather than from + case-folding or Unicode normalization. Case-sensitive repositories may + legitimately contain both ``Gate.yml`` and ``gate.yml``; only a spelling + that the host resolves to a *different* stored directory entry is unsafe. + """ + + kind: Literal["alias", "symlink", "reparse_point", "inspection_error"] + requested: Path + actual: Path | None = None + detail: str | None = None + + +class IdentityReadBudgetExceeded(ValueError): + """A graph-scoped identity reader exhausted its aggregate resource budget.""" + + +@dataclass +class IdentityReadBudget: + """Aggregate entry/byte budget shared by one or more read sessions.""" + + max_entries: int + max_total_bytes: int + entries_scanned: int = 0 + bytes_read: int = 0 + + def __post_init__(self) -> None: + if self.max_entries < 0 or self.max_total_bytes < 0: + raise ValueError("identity-read budgets must be non-negative") + + def consume_entries(self, count: int = 1) -> None: + if count < 0: + raise ValueError("identity-read entry count must be non-negative") + self.entries_scanned += count + if self.entries_scanned > self.max_entries: + raise IdentityReadBudgetExceeded( + "identity reads exceeded their aggregate filesystem entry bound" + ) + + @property + def remaining_bytes(self) -> int: + return self.max_total_bytes - self.bytes_read + + def consume_bytes(self, count: int) -> None: + if count < 0: + raise ValueError("identity-read byte count must be non-negative") + self.bytes_read += count + if self.bytes_read > self.max_total_bytes: + raise IdentityReadBudgetExceeded( + "identity reads exceeded their aggregate byte bound" + ) + + +@dataclass +class _DirectoryIdentitySnapshot: + names: frozenset[str] + observed: dict[str, os.stat_result] + + +class IdentityBoundReadSession: + """Read several exact files while scanning each directory only twice. + + A standalone identity-bound read deliberately scans every lexical parent + before and after opening the file. Repeating that operation for many files + in one protected directory is quadratic. This graph-scoped reader caches + the first exact-name inventory and revalidates every observed entry in one + final pass per directory. Descriptor and metadata comparisons retain the + same no-alias, no-symlink, no-reparse-point, singly-linked-file boundary. + """ + + def __init__( + self, + root: Path, + *, + max_entries: int | None = None, + max_total_bytes: int | None = None, + budget: IdentityReadBudget | None = None, + ) -> None: + if budget is None: + if max_entries is None or max_total_bytes is None: + raise ValueError("identity-read session requires an aggregate budget") + budget = IdentityReadBudget( + max_entries=max_entries, + max_total_bytes=max_total_bytes, + ) + elif max_entries is not None or max_total_bytes is not None: + raise ValueError( + "identity-read session accepts either a shared budget or limits" + ) + self.root = Path(os.path.abspath(os.path.normpath(os.fspath(root)))) + self._budget = budget + self._snapshots: dict[Path, _DirectoryIdentitySnapshot] = {} + self._finished = False + self._root_metadata = self.root.lstat() + if ( + not stat.S_ISDIR(self._root_metadata.st_mode) + or stat.S_ISLNK(self._root_metadata.st_mode) + or _is_reparse_point(self._root_metadata) + or _is_junction(self.root) + ): + raise ValueError("identity-read root is not one lexical directory") + + @property + def entries_scanned(self) -> int: + return self._budget.entries_scanned + + @property + def bytes_read(self) -> int: + return self._budget.bytes_read + + def read_bytes(self, relative: Path, *, max_bytes: int) -> bytes: + """Read one session-relative file within per-file and aggregate limits.""" + + if self._finished: + raise ValueError("identity-read session is already finished") + if relative.is_absolute() or ".." in relative.parts or max_bytes < 0: + raise ValueError("path is outside the identity-bound read root") + components = self._inspect_components(relative) + if not components: + raise ValueError("identity-bound reads require a file path") + final_metadata = components[-1][2] + if not stat.S_ISREG(final_metadata.st_mode) or final_metadata.st_nlink != 1: + raise ValueError("path is not one singly-linked regular file") + if final_metadata.st_size > max_bytes: + raise ValueError(f"file exceeds the {max_bytes}-byte read limit") + remaining = self._budget.remaining_bytes + if final_metadata.st_size > remaining: + raise IdentityReadBudgetExceeded( + "identity reads exceeded their aggregate byte bound" + ) + + file_flags = ( + os.O_RDONLY + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + if os.open not in os.supports_dir_fd: + raw = self._read_portable( + relative, + expected=final_metadata, + file_flags=file_flags, + max_bytes=max_bytes, + remaining=remaining, + ) + else: + raw = self._read_with_dir_fds( + components, + file_flags=file_flags, + max_bytes=max_bytes, + remaining=remaining, + ) + self._budget.consume_bytes(len(raw)) + return raw + + def directory_entries( + self, + relative: Path = Path(), + *, + max_entries: int | None = None, + ) -> tuple[str, ...]: + """Seed and return one exact directory inventory for final revalidation.""" + + if self._finished: + raise ValueError("identity-read session is already finished") + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("directory is outside the identity-bound read root") + if max_entries is not None and max_entries < 0: + raise IdentityReadBudgetExceeded( + "directory inventory exceeded its static filesystem entry bound" + ) + directory = self.root + if relative.parts: + components = self._inspect_components(relative) + metadata = components[-1][2] + if not stat.S_ISDIR(metadata.st_mode): + raise ValueError("inventory path is not a directory") + directory = self.root / relative + snapshot = self._snapshot(directory, max_entries=max_entries) + return tuple(sorted(snapshot.names)) + + def finish(self) -> None: + """Revalidate every observed lexical entry once after all reads.""" + + if self._finished: + return + current_root = self.root.lstat() + if _identity_stat(current_root) != _identity_stat(self._root_metadata): + raise ValueError("identity-read root changed while files were read") + for directory in sorted(self._snapshots, key=os.fspath): + snapshot = self._snapshots[directory] + current_names = self._scan_names(directory) + if current_names != snapshot.names: + raise ValueError( + "directory entries changed while identity-bound files were read" + ) + for name, expected in sorted(snapshot.observed.items()): + if name not in current_names: + raise ValueError("path changed lexical identity while it was read") + requested = directory / name + try: + current = requested.lstat() + except OSError as exc: + raise ValueError("path changed while it was read") from exc + if ( + stat.S_ISLNK(current.st_mode) + or _is_reparse_point(current) + or _is_junction(requested) + or _identity_stat(current) != _identity_stat(expected) + ): + raise ValueError("path changed identity while it was read") + self._finished = True + + def _inspect_components( + self, + relative: Path, + ) -> list[tuple[Path, str, os.stat_result]]: + current = self.root + components: list[tuple[Path, str, os.stat_result]] = [] + for index, part in enumerate(relative.parts): + snapshot = self._snapshot(current) + requested = current / part + if part not in snapshot.names: + try: + requested.lstat() + except FileNotFoundError: + raise + except OSError as exc: + raise ValueError("path could not be inspected safely") from exc + raise ValueError( + "path resolves through a differently spelled filesystem entry" + ) + try: + metadata = requested.lstat() + except OSError as exc: + raise ValueError("path could not be inspected safely") from exc + if ( + stat.S_ISLNK(metadata.st_mode) + or _is_reparse_point(metadata) + or _is_junction(requested) + ): + raise ValueError("path contains a symlink or reparse point") + if index < len(relative.parts) - 1 and not stat.S_ISDIR(metadata.st_mode): + raise ValueError("path parent is not a directory") + prior = snapshot.observed.get(part) + if prior is not None and _identity_stat(prior) != _identity_stat(metadata): + raise ValueError("path changed identity while it was read") + snapshot.observed[part] = metadata + components.append((current, part, metadata)) + current = requested + return components + + def _snapshot( + self, + directory: Path, + *, + max_entries: int | None = None, + ) -> _DirectoryIdentitySnapshot: + cached = self._snapshots.get(directory) + if cached is not None: + return cached + snapshot = _DirectoryIdentitySnapshot( + names=self._scan_names(directory, max_entries=max_entries), + observed={}, + ) + self._snapshots[directory] = snapshot + return snapshot + + def _scan_names( + self, + directory: Path, + *, + max_entries: int | None = None, + ) -> frozenset[str]: + names: set[str] = set() + local_entries = 0 + try: + with os.scandir(directory) as iterator: + for entry in iterator: + local_entries += 1 + if max_entries is not None and local_entries > max_entries: + raise IdentityReadBudgetExceeded( + "directory inventory exceeded its static filesystem " + "entry bound" + ) + self._budget.consume_entries() + names.add(entry.name) + except IdentityReadBudgetExceeded: + raise + except OSError as exc: + raise ValueError("directory could not be inspected safely") from exc + return frozenset(names) + + def _read_with_dir_fds( + self, + components: list[tuple[Path, str, os.stat_result]], + *, + file_flags: int, + max_bytes: int, + remaining: int, + ) -> bytes: + directory_flags = ( + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + descriptors: list[int] = [] + opened: os.stat_result | None = None + after: os.stat_result | None = None + raw = bytearray() + try: + current = os.open(self.root, directory_flags) + descriptors.append(current) + if _identity_stat(os.fstat(current)) != _identity_stat(self._root_metadata): + raise ValueError("identity-read root changed before it was opened") + for _directory, component, expected in components[:-1]: + current = os.open(component, directory_flags, dir_fd=current) + descriptors.append(current) + if _identity_stat(os.fstat(current)) != _identity_stat(expected): + raise ValueError("path parent changed before it was opened") + final_name = components[-1][1] + descriptor = os.open(final_name, file_flags, dir_fd=current) + descriptors.append(descriptor) + opened = os.fstat(descriptor) + if _identity_stat(opened) != _identity_stat(components[-1][2]): + raise ValueError("path changed identity before it was read") + while chunk := os.read( + descriptor, + min(64 * 1024, min(max_bytes, remaining) + 1 - len(raw)), + ): + raw.extend(chunk) + if len(raw) > max_bytes: + raise ValueError(f"file exceeds the {max_bytes}-byte read limit") + if len(raw) > remaining: + raise IdentityReadBudgetExceeded( + "identity reads exceeded their aggregate byte bound" + ) + after = os.fstat(descriptor) + finally: + for descriptor_to_close in reversed(descriptors): + try: + os.close(descriptor_to_close) + except OSError: + pass + assert opened is not None and after is not None + if _identity_stat(opened) != _identity_stat(after): + raise ValueError("file changed while it was read") + return bytes(raw) + + def _read_portable( + self, + relative: Path, + *, + expected: os.stat_result, + file_flags: int, + max_bytes: int, + remaining: int, + ) -> bytes: + path = self.root / relative + descriptor = os.open(path, file_flags) + raw = bytearray() + try: + opened = os.fstat(descriptor) + if _identity_stat(opened) != _identity_stat(expected): + raise ValueError("path changed identity before it was read") + while chunk := os.read( + descriptor, + min(64 * 1024, min(max_bytes, remaining) + 1 - len(raw)), + ): + raw.extend(chunk) + if len(raw) > max_bytes: + raise ValueError(f"file exceeds the {max_bytes}-byte read limit") + if len(raw) > remaining: + raise IdentityReadBudgetExceeded( + "identity reads exceeded their aggregate byte bound" + ) + after = os.fstat(descriptor) + finally: + os.close(descriptor) + if _identity_stat(opened) != _identity_stat(after): + raise ValueError("file changed while it was read") + current = path.lstat() + if _identity_stat(opened) != _identity_stat(current): + raise ValueError("path changed identity while it was read") + return bytes(raw) + + +def read_identity_bound_bytes( + root: Path, + relative: Path, + *, + max_bytes: int, +) -> bytes: + """Read one exact contained file without following path aliases. + + The lexical path, every parent directory, the opened descriptor, and the + post-read directory entry must all identify the same singly-linked regular + file. ``O_NONBLOCK`` prevents a swapped FIFO/device from hanging the + caller, while the explicit byte limit keeps trust-root reads bounded. + """ + + lexical_root = Path(os.path.abspath(os.path.normpath(os.fspath(root)))) + if relative.is_absolute() or ".." in relative.parts or max_bytes < 0: + raise ValueError("path is outside the identity-bound read root") + issue = inspect_lexical_path_identity(lexical_root, relative) + if issue is not None: + raise ValueError( + "path contains a symlink, reparse point, alias, or uninspectable component" + ) + + file_flags = ( + os.O_RDONLY + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + if os.open not in os.supports_dir_fd: + return _read_identity_bound_bytes_portable( + lexical_root, + relative, + max_bytes=max_bytes, + file_flags=file_flags, + ) + + directory_flags = ( + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + descriptors: list[int] = [] + opened: os.stat_result | None = None + after: os.stat_result | None = None + raw = bytearray() + try: + current = os.open(lexical_root, directory_flags) + descriptors.append(current) + for component in relative.parts[:-1]: + current = os.open(component, directory_flags, dir_fd=current) + descriptors.append(current) + descriptor = os.open(relative.name, file_flags, dir_fd=current) + descriptors.append(descriptor) + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1: + raise ValueError("path is not one singly-linked regular file") + if opened.st_size > max_bytes: + raise ValueError(f"file exceeds the {max_bytes}-byte read limit") + while chunk := os.read( + descriptor, + min(64 * 1024, max_bytes + 1 - len(raw)), + ): + raw.extend(chunk) + if len(raw) > max_bytes: + raise ValueError(f"file exceeds the {max_bytes}-byte read limit") + after = os.fstat(descriptor) + finally: + for descriptor_to_close in reversed(descriptors): + try: + os.close(descriptor_to_close) + except OSError: + pass + + assert opened is not None and after is not None + if _identity_stat(opened) != _identity_stat(after): + raise ValueError("file changed while it was read") + issue = inspect_lexical_path_identity(lexical_root, relative) + try: + current_metadata = (lexical_root / relative).lstat() + except OSError as exc: + raise ValueError("path changed while it was read") from exc + if issue is not None or _identity_stat(opened) != _identity_stat(current_metadata): + raise ValueError("path changed identity while it was read") + return bytes(raw) + + +def read_identity_bound_text( + root: Path, + relative: Path, + *, + max_bytes: int, +) -> str: + """UTF-8 projection of :func:`read_identity_bound_bytes`.""" + + return read_identity_bound_bytes( + root, + relative, + max_bytes=max_bytes, + ).decode("utf-8", errors="strict") + + +def read_absolute_identity_bound_text(path: Path, *, max_bytes: int) -> str: + """Read an absolute/relative path with every lexical component bound.""" + + lexical = Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + anchor = Path(lexical.anchor) + return read_identity_bound_text( + anchor, + lexical.relative_to(anchor), + max_bytes=max_bytes, + ) + + +def _read_identity_bound_bytes_portable( + root: Path, + relative: Path, + *, + max_bytes: int, + file_flags: int, +) -> bytes: + """Best available identity checks where ``dir_fd`` is unavailable.""" + + path = root / relative + before = path.lstat() + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + raise ValueError("path is not one singly-linked regular file") + if before.st_size > max_bytes: + raise ValueError(f"file exceeds the {max_bytes}-byte read limit") + descriptor = os.open(path, file_flags) + raw = bytearray() + try: + opened = os.fstat(descriptor) + if _identity_stat(before) != _identity_stat(opened): + raise ValueError("path changed identity before it was read") + while chunk := os.read( + descriptor, + min(64 * 1024, max_bytes + 1 - len(raw)), + ): + raw.extend(chunk) + if len(raw) > max_bytes: + raise ValueError(f"file exceeds the {max_bytes}-byte read limit") + after = os.fstat(descriptor) + finally: + os.close(descriptor) + if _identity_stat(opened) != _identity_stat(after): + raise ValueError("file changed while it was read") + issue = inspect_lexical_path_identity(root, relative) + current = path.lstat() + if issue is not None or _identity_stat(opened) != _identity_stat(current): + raise ValueError("path changed identity while it was read") + return bytes(raw) + + +def _identity_stat(metadata: os.stat_result) -> tuple[int, ...]: + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_nlink, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) + + +def inspect_lexical_path_identity( + root: Path, + relative: Path, +) -> PathIdentityIssue | None: + """Inspect ``relative`` without following aliases or symlink components. + + Missing suffix components are allowed so callers can retain their normal + "manifest not found" recovery. If the host resolves a missing lexical + spelling to an existing entry, however, the spelling is an alias (case, + Unicode normalization, or another filesystem equivalence) and must be + rejected before it becomes verification identity. + """ + + current = root + for part in relative.parts: + requested = current / part + try: + with os.scandir(current) as iterator: + exact_found = any(entry.name == part for entry in iterator) + except FileNotFoundError: + break + except OSError as exc: + return PathIdentityIssue( + kind="inspection_error", + requested=current, + detail=str(exc), + ) + + if not exact_found: + try: + requested_stat = requested.lstat() + except FileNotFoundError: + break + except OSError as exc: + return PathIdentityIssue( + kind="inspection_error", + requested=requested, + detail=str(exc), + ) + + matches: list[Path] = [] + try: + with os.scandir(current) as iterator: + for entry in iterator: + try: + if os.path.samestat( + requested_stat, + entry.stat(follow_symlinks=False), + ): + matches.append(current / entry.name) + except OSError: + continue + except OSError as exc: + return PathIdentityIssue( + kind="inspection_error", + requested=current, + detail=str(exc), + ) + return PathIdentityIssue( + kind="alias", + requested=requested, + actual=matches[0] if len(matches) == 1 else None, + detail=( + "filesystem identity matches multiple stored entries: " + + ", ".join(path.name for path in matches) + if len(matches) > 1 + else None + ), + ) + + try: + metadata = requested.lstat() + except OSError as exc: + return PathIdentityIssue( + kind="inspection_error", + requested=requested, + detail=str(exc), + ) + if stat.S_ISLNK(metadata.st_mode): + return PathIdentityIssue(kind="symlink", requested=requested) + if _is_reparse_point(metadata) or _is_junction(requested): + return PathIdentityIssue(kind="reparse_point", requested=requested) + current = requested + return None + + +def _is_reparse_point(metadata: os.stat_result) -> bool: + """Whether Windows marked a lexical entry as a filesystem reparse point.""" + + attributes = int(getattr(metadata, "st_file_attributes", 0)) + reparse_flag = int(getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x0400)) + return bool(attributes & reparse_flag) + + +def _is_junction(path: Path) -> bool: + """Reject Windows junctions even when their stat facade omits the flag.""" + + predicate = getattr(path, "is_junction", None) + if predicate is None: + return False + try: + return bool(predicate()) + except OSError: + return True + + +def configured_manifest_identity(context: object) -> object | None: + """Return the stable logical manifest identity for a scan context. + + Committed-head verification scans load files from temporary archives. + Their physical ``ScanContext.config_path`` must never be suffix-matched + against repository-relative changed paths; ``VerificationContext`` carries + the exact repository-relative identity for that comparison. + """ + + verification = getattr(context, "verification", None) + logical = getattr(verification, "configured_manifest_path", None) + if logical: + return logical + return getattr(context, "config_path", None) + + +def is_context_configured_manifest(context: object, path: str) -> bool: + """Match a changed path against the best identity carried by a scan.""" + + verification = getattr(context, "verification", None) + logical = getattr(verification, "configured_manifest_path", None) + if logical: + if is_configured_manifest(logical, path, exact=True): + return True + physical = getattr(context, "config_path", None) + logical_path = Path(str(logical)) + physical_path = Path(str(physical)) if physical is not None else None + if ( + physical_path is not None + and physical_path.is_absolute() + and not logical_path.is_absolute() + and logical_path.parts + and ".." not in logical_path.parts + ): + root = physical_path + for _part in logical_path.parts: + root = root.parent + return is_configured_manifest(logical, path, workspace=root) + return False + # Plain ``scan --changed-files`` predates the logical identity field and + # may carry an absolute physical config path. Preserve that compatibility + # fallback outside verifier contexts that have the exact identity. + return is_configured_manifest(getattr(context, "config_path", None), path) + + def is_configured_manifest( config_path: object | None, path: str, *, workspace: object | None = None, + exact: bool = False, ) -> bool: """Whether a changed-file path is the manifest *this run* loaded as its gate. @@ -164,12 +910,12 @@ def is_configured_manifest( ``docs/engineering/../manifest.yaml`` for ``docs/manifest.yaml`` — cannot slip past by looking textually different from the changed path. - ``workspace`` makes the comparison exact by resolving both sides against - it and rejecting either one when its canonical path escapes that boundary. - Without it the fallback matches on whole path components, which can - over-match a same-named file in another directory; that direction adds a - trust-root finding rather than dropping one, so the fallback is safe, just - less precise. Callers that know their workspace should pass it. + ``workspace`` resolves both sides against the same lexical root and rejects + either one when the normalized path escapes that boundary. Callers with a + stable repository-relative identity can instead request ``exact=True``. + The legacy no-workspace fallback remains for plain ``scan --changed-files`` + contexts that carry a physical config path, but verifier checks must not + use it because it can classify an unrelated same-named file. """ if config_path is None: @@ -184,19 +930,40 @@ def is_configured_manifest( return False absolute_config = _under_workspace(root, configured) absolute_candidate = _under_workspace(root, candidate) - return bool( - absolute_config - and absolute_candidate - and absolute_config == absolute_candidate - ) - if candidate == configured: - return True + if not absolute_config or not absolute_candidate: + return False + if absolute_config == absolute_candidate: + return True + # Git may store a case- or Unicode-precomposed spelling that the host + # resolves to the same exact worktree entry. Recognize only that + # filesystem-proved identity; distinct files on a case-sensitive host + # remain distinct. + try: + return os.path.samestat( + os.lstat(absolute_config), + os.lstat(absolute_candidate), + ) + except OSError: + return False + if candidate == configured or exact: + return candidate == configured return configured.endswith(f"/{candidate}") or candidate.endswith(f"/{configured}") __all__ = [ + "IdentityBoundReadSession", + "IdentityReadBudget", + "IdentityReadBudgetExceeded", "PROTECTED_FILE_EDITS", + "PathIdentityIssue", + "configured_manifest_identity", + "inspect_lexical_path_identity", + "is_context_configured_manifest", "is_configured_manifest", + "is_portable_repo_path", + "read_absolute_identity_bound_text", + "read_identity_bound_bytes", + "read_identity_bound_text", "TRUST_ROOT_SURFACES", "trust_root_class_for", ] diff --git a/src/agents_shipgate/core/verification_identity.py b/src/agents_shipgate/core/verification_identity.py index 0db28b6e..887bd214 100644 --- a/src/agents_shipgate/core/verification_identity.py +++ b/src/agents_shipgate/core/verification_identity.py @@ -20,6 +20,7 @@ from agents_shipgate import __version__ from agents_shipgate.checks.registry import _plugins_enabled, check_catalog +from agents_shipgate.core.static_inputs import active_static_input_snapshot from agents_shipgate.inputs.protocol import REGISTRY from agents_shipgate.schemas.verification_identity import ( VerificationArtifactManifest, @@ -38,6 +39,7 @@ ) _GENERATED_DISTRIBUTION_DIRECTORY_NAMES = frozenset({"agents-shipgate-reports"}) +_MAX_PLAN_INPUT_FILE_BYTES = 64 * 1024 * 1024 def sha256_bytes(value: bytes) -> str: @@ -45,6 +47,14 @@ def sha256_bytes(value: bytes) -> str: def sha256_file(path: Path) -> str: + snapshot = active_static_input_snapshot() + if snapshot is not None and snapshot.has(path): + return sha256_bytes( + snapshot.read_bytes( + path, + max_bytes=_MAX_PLAN_INPUT_FILE_BYTES, + ) + ) digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): @@ -53,7 +63,12 @@ def sha256_file(path: Path) -> str: def build_blob(*, path: Path, logical_path: str, source: str) -> VerificationBlob: - data = path.read_bytes() + snapshot = active_static_input_snapshot() + data = ( + snapshot.read_bytes(path, max_bytes=_MAX_PLAN_INPUT_FILE_BYTES) + if snapshot is not None and snapshot.has(path) + else path.read_bytes() + ) return VerificationBlob( path=logical_path, sha256=sha256_bytes(data), @@ -166,6 +181,8 @@ def build_verification_plan( options: dict[str, Any], plugins_enabled: bool | None, diff_logical_path: str = "verification-input.diff", + external_input_root: Path | None = None, + captured_input_paths: list[Path] | None = None, ) -> VerificationPlan: effective_plugins_enabled = _plugins_enabled(plugins_enabled) normalized_options = dict(options) @@ -207,23 +224,46 @@ def build_verification_plan( size_bytes=len(diff_bytes), source="generated", ) - tool_sources = _manifest_tool_source_blobs( - config_path=config_path, - input_root=input_root, - source="git_blob" if archived_head else "worktree", - ) + if captured_input_paths is None: + tool_sources = _manifest_tool_source_blobs( + config_path=config_path, + input_root=input_root, + source="git_blob" if archived_head else "worktree", + ) + else: + excluded_paths = { + Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + for path in [ + config_path, + *policy_pack_paths, + *([baseline_path] if baseline_path is not None else []), + *([diff_from_path] if diff_from_path is not None else []), + *(git_root / path for path in changed_files), + ] + } + tool_sources = _blobs( + [ + path + for path in captured_input_paths + if Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + not in excluded_paths + ], + root=input_root, + source="worktree", + ) + bundle_root = external_input_root or input_root policy_blobs = _blobs( policy_pack_paths, - root=input_root, - source="git_blob" if archived_head else "external_input", + root=bundle_root, + source="external_input", ) changed_blobs = _existing_changed_blobs( changed_files, root=input_root if archived_head else git_root, source="git_blob" if archived_head else "worktree", ) - baseline_blob = _optional_blob(baseline_path, root=input_root) - diff_from_blob = _optional_blob(diff_from_path, root=input_root) + baseline_blob = _optional_blob(baseline_path, root=bundle_root) + diff_from_blob = _optional_blob(diff_from_path, root=bundle_root) inputs_payload = { "evaluation_date": evaluation_date, "config": config_blob, @@ -262,6 +302,9 @@ def build_verification_plan( diff_blob.path, *(blob.path for blob in tool_sources), *(b.path for b in policy_blobs), + *(blob.path for blob in changed_blobs), + *([baseline_blob.path] if baseline_blob is not None else []), + *([diff_from_blob.path] if diff_from_blob is not None else []), } ), } @@ -812,7 +855,16 @@ def validate_plan_inputs( def _manifest_tool_source_blobs( *, config_path: Path, input_root: Path, source: str ) -> list[VerificationBlob]: - raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + snapshot = active_static_input_snapshot() + config_text = ( + snapshot.read_bytes( + config_path, + max_bytes=_MAX_PLAN_INPUT_FILE_BYTES, + ).decode("utf-8", errors="strict") + if snapshot is not None and snapshot.contains(config_path) + else config_path.read_text(encoding="utf-8") + ) + raw = yaml.safe_load(config_text) or {} rows = raw.get("tool_sources") if isinstance(raw, dict) else [] paths: list[Path] = [] for row in rows or []: @@ -821,7 +873,13 @@ def _manifest_tool_source_blobs( candidate = (config_path.parent / row["path"]).resolve() if input_root.resolve() not in candidate.parents and candidate != input_root.resolve(): raise ValueError(f"tool source escapes verification input root: {row['path']}") - paths.extend(_expand_path(candidate)) + if snapshot is not None and snapshot.contains(candidate): + if snapshot.has(candidate): + paths.append(candidate) + else: + paths.extend(snapshot.paths_under(candidate)) + else: + paths.extend(_expand_path(candidate)) return _blobs(paths, root=input_root, source=source) @@ -835,14 +893,31 @@ def _expand_path(path: Path) -> list[Path]: def _blobs(paths: list[Path], *, root: Path, source: str) -> list[VerificationBlob]: out: list[VerificationBlob] = [] - for path in sorted({candidate.resolve() for candidate in paths if candidate.is_file()}): + snapshot = active_static_input_snapshot() + candidates: set[Path] = set() + for candidate in paths: + lexical = Path( + os.path.abspath(os.path.normpath(os.fspath(candidate))) + ) + if snapshot is not None and snapshot.contains(lexical): + if snapshot.has(lexical): + candidates.add(lexical) + elif candidate.is_file(): + candidates.add(candidate.resolve()) + for path in sorted(candidates): logical = path.relative_to(root.resolve()).as_posix() out.append(build_blob(path=path, logical_path=logical, source=source)) return sorted(out, key=lambda blob: blob.path) def _optional_blob(path: Path | None, *, root: Path) -> VerificationBlob | None: - if path is None or not path.is_file(): + if path is None: + return None + snapshot = active_static_input_snapshot() + if snapshot is not None and snapshot.contains(path): + if not snapshot.has(path): + return None + elif not path.is_file(): return None resolved = path.resolve() try: @@ -859,15 +934,21 @@ def _existing_changed_blobs(paths: list[str], *, root: Path, source: str) -> lis def _worktree_overlay(root: Path, paths: list[str]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] + snapshot = active_static_input_snapshot() for relative in sorted(set(paths)): candidate = (root / relative).resolve() if root.resolve() not in candidate.parents: raise ValueError(f"worktree path escapes repository: {relative}") + present = ( + snapshot.has(candidate) + if snapshot is not None and snapshot.contains(candidate) + else candidate.is_file() + ) rows.append( { "path": relative, - "status": "present" if candidate.is_file() else "deleted", - "sha256": sha256_file(candidate) if candidate.is_file() else None, + "status": "present" if present else "deleted", + "sha256": sha256_file(candidate) if present else None, } ) return rows diff --git a/src/agents_shipgate/inputs/common.py b/src/agents_shipgate/inputs/common.py index 976e5864..06a51839 100644 --- a/src/agents_shipgate/inputs/common.py +++ b/src/agents_shipgate/inputs/common.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import re from collections.abc import Iterator from pathlib import Path @@ -13,6 +14,11 @@ from agents_shipgate.core.domain import ToolParameter from agents_shipgate.core.errors import InputParseError +from agents_shipgate.core.static_inputs import ( + active_static_input_snapshot, + read_static_input_text, +) +from agents_shipgate.core.trust_roots import inspect_lexical_path_identity # These keys are authority-bearing inputs to the binding resolver. Catalog # formats may carry arbitrary extension metadata, so their loaders must never @@ -50,6 +56,28 @@ def resolve_input_path(base_dir: Path, value: str) -> Path: base = base_dir.resolve() raw_path = Path(value) path = raw_path if raw_path.is_absolute() else base / raw_path + snapshot = active_static_input_snapshot() + if snapshot is not None: + lexical = Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + try: + relative = lexical.relative_to(base) + except ValueError as exc: + raise InputParseError( + f"Input path {value!r} resolves outside manifest directory: {lexical}" + ) from exc + issue = inspect_lexical_path_identity(base, relative) + if issue is not None: + raise InputParseError( + f"Input path {value!r} does not identify one exact, non-aliased " + f"worktree entry: {issue.kind}" + ) + if lexical.is_dir(): + try: + snapshot.bind_directory(lexical) + except (OSError, ValueError) as exc: + raise InputParseError( + f"Input directory {value!r} could not be captured safely: {exc}" + ) from exc resolved = path.resolve() try: resolved.relative_to(base) @@ -86,17 +114,8 @@ def load_structured_file(path: Path) -> Any: if not path.exists(): raise InputParseError(f"Input file not found: {path}") try: - size = path.stat().st_size - except OSError as exc: - raise InputParseError(f"Unable to inspect input file {path}: {exc}") from exc - if size > MAX_INPUT_FILE_BYTES: - raise InputParseError( - f"Input file too large: {path} is {size} bytes; " - f"maximum is {MAX_INPUT_FILE_BYTES} bytes" - ) - try: - text = path.read_text(encoding="utf-8") - except OSError as exc: + text = read_static_input_text(path, max_bytes=MAX_INPUT_FILE_BYTES) + except (OSError, UnicodeDecodeError, ValueError) as exc: raise InputParseError(f"Unable to read input file {path}: {exc}") from exc try: stripped = text.lstrip() @@ -111,17 +130,8 @@ def load_text_file(path: Path) -> str: if not path.exists(): raise InputParseError(f"Input file not found: {path}") try: - size = path.stat().st_size - except OSError as exc: - raise InputParseError(f"Unable to inspect input file {path}: {exc}") from exc - if size > MAX_INPUT_FILE_BYTES: - raise InputParseError( - f"Input file too large: {path} is {size} bytes; " - f"maximum is {MAX_INPUT_FILE_BYTES} bytes" - ) - try: - return path.read_text(encoding="utf-8") - except OSError as exc: + return read_static_input_text(path, max_bytes=MAX_INPUT_FILE_BYTES) + except (OSError, UnicodeDecodeError, ValueError) as exc: raise InputParseError(f"Unable to read input file {path}: {exc}") from exc @@ -270,19 +280,24 @@ def load_structured_file_with_positions(path: Path) -> tuple[Any, PositionIndex] if not path.exists(): raise InputParseError(f"Input file not found: {path}") try: - size = path.stat().st_size - except OSError as exc: - raise InputParseError(f"Unable to inspect input file {path}: {exc}") from exc - if size > MAX_INPUT_FILE_BYTES: - raise InputParseError( - f"Input file too large: {path} is {size} bytes; " - f"maximum is {MAX_INPUT_FILE_BYTES} bytes" - ) - try: - text = path.read_text(encoding="utf-8") - except OSError as exc: + text = read_static_input_text(path, max_bytes=MAX_INPUT_FILE_BYTES) + except (OSError, UnicodeDecodeError, ValueError) as exc: raise InputParseError(f"Unable to read input file {path}: {exc}") from exc + return load_structured_text_with_positions(text, source=path) + +def load_structured_text_with_positions( + text: str, + *, + source: str | Path, +) -> tuple[Any, PositionIndex]: + """Parse already-captured structured text and build best-effort positions.""" + + path = Path(source) + if len(text.encode("utf-8")) > MAX_INPUT_FILE_BYTES: + raise InputParseError( + f"Input file too large: {path}; maximum is {MAX_INPUT_FILE_BYTES} bytes" + ) stripped = text.lstrip() is_json = path.suffix.lower() == ".json" or stripped.startswith(("{", "[")) if is_json: diff --git a/src/agents_shipgate/inputs/mcp_manifest.py b/src/agents_shipgate/inputs/mcp_manifest.py index 37394b30..734a238a 100644 --- a/src/agents_shipgate/inputs/mcp_manifest.py +++ b/src/agents_shipgate/inputs/mcp_manifest.py @@ -14,6 +14,7 @@ from agents_shipgate.core.errors import InputParseError from agents_shipgate.inputs.common import ( load_structured_file_with_positions, + load_text_file, manifest_relative_path, schema_to_parameters, stable_tool_id, @@ -110,8 +111,8 @@ def load_codex_config_mcp_sources(root: Path, base_dir: Path) -> list[LoadedTool if rel == ".codex/config.toml" or rel.endswith("/.codex/config.toml"): source_ref = _relative(path, base_dir) try: - data = tomllib.loads(path.read_text(encoding="utf-8")) - except (OSError, tomllib.TOMLDecodeError) as exc: + data = tomllib.loads(load_text_file(path)) + except (InputParseError, tomllib.TOMLDecodeError) as exc: sources.append( LoadedToolSource( source_id=f"codex_config_mcp:{source_ref}", diff --git a/src/agents_shipgate/inputs/policy_packs.py b/src/agents_shipgate/inputs/policy_packs.py index 491cd108..2d878842 100644 --- a/src/agents_shipgate/inputs/policy_packs.py +++ b/src/agents_shipgate/inputs/policy_packs.py @@ -10,6 +10,7 @@ from agents_shipgate.core.context import ScanContext from agents_shipgate.core.errors import ConfigError, InputParseError from agents_shipgate.core.policy_evidence import finding_support, policy_evidence_gap +from agents_shipgate.core.static_inputs import read_static_input_bytes from agents_shipgate.inputs.common import load_structured_file, resolve_input_path from agents_shipgate.schemas.common import SourceReference from agents_shipgate.schemas.manifest import AgentsShipgateManifest, PolicyPackConfig @@ -177,8 +178,8 @@ def _verify_pack_pin( if not pinned: return None, "unpinned" try: - digest = hashlib.sha256(path.read_bytes()).hexdigest() - except OSError as exc: + digest = hashlib.sha256(read_static_input_bytes(path)).hexdigest() + except (OSError, ValueError) as exc: raise ConfigError( f"Could not hash pinned policy pack {config.path!r}: {exc}" ) from exc diff --git a/src/agents_shipgate/mcp_server/server.py b/src/agents_shipgate/mcp_server/server.py index 5b09ba69..81cca4c5 100644 --- a/src/agents_shipgate/mcp_server/server.py +++ b/src/agents_shipgate/mcp_server/server.py @@ -9,13 +9,17 @@ from agents_shipgate.cli.capability import build_capability_lock_from_config from agents_shipgate.cli.explain_finding import explain_finding_payload from agents_shipgate.core.agent_handoff import build_agent_handoff +from agents_shipgate.core.bounded_io import ( + MAX_EXPLICIT_DIFF_BYTES, + MAX_EXPLICIT_JSON_BYTES, + ensure_bounded_utf8_text, +) from agents_shipgate.core.capability_lock import ( diff_capability_locks, load_capability_lock, render_capability_lock_diff_json, render_capability_lock_json, ) -from agents_shipgate.core.codex_boundary import parse_unified_diff from agents_shipgate.core.errors import ConfigError from agents_shipgate.core.preflight import build_preflight_result from agents_shipgate.schemas.preflight import CapabilityRequestV1 @@ -37,6 +41,11 @@ def shipgate_check( ``shipgate check --format agent-boundary-json``. """ + ensure_bounded_utf8_text( + diff_text, + max_bytes=MAX_EXPLICIT_DIFF_BYTES, + label="diff_text", + ) result = build_agent_boundary_result( agent=agent, workspace=Path(workspace), @@ -44,6 +53,9 @@ def shipgate_check( config=Path(config), policy=Path(policy) if policy else None, input_mode="provided_diff", + # MCP callers supply detached diff text, not a checkout/ref identity + # that a later verify command can reproduce. + verification_replayable=False, ) return agent_result_json_payload(result) @@ -60,14 +72,43 @@ def shipgate_preflight( ) -> dict[str, Any]: """Read-only MCP tool implementation for ``shipgate.preflight``.""" - changed = list(changed_files or []) - if diff_text: - changed = sorted( - { - *changed, - *(item.path for item in parse_unified_diff(diff_text) if item.path), - } + if diff_text is not None: + ensure_bounded_utf8_text( + diff_text, + max_bytes=MAX_EXPLICIT_DIFF_BYTES, + label="diff_text", ) + if len(changed_files or []) > 100_000: + raise ConfigError("changed_files exceeds the 100000-entry static input limit") + for label, payload in ( + ("plan", plan), + ("capability_request", capability_request), + ("base_preflight", base_preflight), + ): + if payload is not None: + ensure_bounded_utf8_text( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + max_bytes=MAX_EXPLICIT_JSON_BYTES, + label=label, + ) + if plan is not None: + mixed = [ + name + for name, value in ( + ("changed_files", changed_files), + ("diff_text", diff_text), + ("capability_request", capability_request), + ("base_preflight", base_preflight), + ) + if value is not None + ] + if mixed: + raise ConfigError( + "plan cannot be combined with " + + ", ".join(mixed) + + "; put those inputs in the plan object or omit plan." + ) + request = ( CapabilityRequestV1.model_validate(capability_request) if capability_request is not None @@ -76,7 +117,8 @@ def shipgate_preflight( result = build_preflight_result( workspace=Path(workspace), config=Path(config), - changed_files=changed, + changed_files=changed_files, + diff_text=diff_text, capability_request=request, plan=plan, base_preflight=base_preflight, diff --git a/src/agents_shipgate/schemas/preflight.py b/src/agents_shipgate/schemas/preflight.py index 7e689bc8..73109619 100644 --- a/src/agents_shipgate/schemas/preflight.py +++ b/src/agents_shipgate/schemas/preflight.py @@ -2,12 +2,13 @@ from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from agents_shipgate.schemas.agent_control import AgentControl from agents_shipgate.schemas.surfaces import ActionEffect PREFLIGHT_SCHEMA_VERSION = "0.3" +MAX_PREFLIGHT_DIFF_BYTES = 32 * 1024 * 1024 PreflightActor = Literal["coding_agent", "human"] PreflightActionKind = Literal["continue", "review", "gather_evidence", "verify"] @@ -76,6 +77,16 @@ class CapabilityRequestControls(BaseModel): safeguard_rollback: bool | None = None safeguard_dry_run: bool | None = None + @field_validator("approval_threshold") + @classmethod + def normalize_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + class CapabilityRequestEvidence(BaseModel): model_config = ConfigDict(extra="forbid") @@ -84,6 +95,16 @@ class CapabilityRequestEvidence(BaseModel): runbook: str | None = None approval_ticket: str | None = None + @field_validator("owner", "runbook", "approval_ticket") + @classmethod + def normalize_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + class CapabilityRequestV1(BaseModel): """Static preflight review input for a proposed action/capability. @@ -105,6 +126,40 @@ class CapabilityRequestV1(BaseModel): controls: CapabilityRequestControls = Field(default_factory=CapabilityRequestControls) evidence: CapabilityRequestEvidence = Field(default_factory=CapabilityRequestEvidence) + @field_validator("tool_name") + @classmethod + def normalize_required_text(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + + @field_validator("provider", "operation", "source_type") + @classmethod + def normalize_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + + @field_validator("risk_tags") + @classmethod + def normalize_risk_tags(cls, values: list[str]) -> list[str]: + normalized = [value.strip().lower() for value in values] + if any(not value for value in normalized): + raise ValueError("risk tags must not be blank") + return list(dict.fromkeys(normalized)) + + @field_validator("scopes") + @classmethod + def normalize_scopes(cls, values: list[str]) -> list[str]: + normalized = [value.strip() for value in values] + if any(not value for value in normalized): + raise ValueError("scopes must not be blank") + return list(dict.fromkeys(normalized)) + class HostPermissionRequestV1(BaseModel): """Planning-time request for coding-agent host authority. @@ -125,6 +180,24 @@ class HostPermissionRequestV1(BaseModel): requested_access: dict[str, Any] = Field(default_factory=dict) reason: str | None = None + @field_validator("host", "surface", "operation", "subject") + @classmethod + def normalize_required_text(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + + @field_validator("path", "reason") + @classmethod + def normalize_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + class PreflightPlanContextV1(BaseModel): model_config = ConfigDict(extra="forbid") @@ -132,6 +205,16 @@ class PreflightPlanContextV1(BaseModel): agent: str | None = None task: str | None = None + @field_validator("agent", "task") + @classmethod + def normalize_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + class PreflightPlanV1(BaseModel): """Single proactive input object for coding-agent planning. @@ -144,12 +227,32 @@ class PreflightPlanV1(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: Literal["preflight_plan_v1"] = "preflight_plan_v1" - changed_files: list[str] = Field(default_factory=list) + changed_files: list[str] = Field(default_factory=list, max_length=100_000) diff_text: str | None = None - capability_requests: list[CapabilityRequestV1] = Field(default_factory=list) - host_permission_requests: list[HostPermissionRequestV1] = Field(default_factory=list) + capability_requests: list[CapabilityRequestV1] = Field( + default_factory=list, + max_length=10_000, + ) + host_permission_requests: list[HostPermissionRequestV1] = Field( + default_factory=list, + max_length=10_000, + ) context: PreflightPlanContextV1 = Field(default_factory=PreflightPlanContextV1) + @model_validator(mode="after") + def enforce_static_input_bounds(self) -> PreflightPlanV1: + if ( + self.diff_text is not None + and len(self.diff_text.encode("utf-8")) > MAX_PREFLIGHT_DIFF_BYTES + ): + raise ValueError( + f"diff_text exceeds the {MAX_PREFLIGHT_DIFF_BYTES}-byte static input limit" + ) + for path in self.changed_files: + if len(path.encode("utf-8")) > 4096: + raise ValueError("changed_files entries must not exceed 4096 UTF-8 bytes") + return self + class TrustRootNodeV1(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/src/agents_shipgate/triggers.py b/src/agents_shipgate/triggers.py index d63f1925..0a1116f3 100644 --- a/src/agents_shipgate/triggers.py +++ b/src/agents_shipgate/triggers.py @@ -21,7 +21,6 @@ import argparse import json -import subprocess import sys from importlib.resources import files from pathlib import Path @@ -31,6 +30,7 @@ boundary_adapters_for_path, is_agent_boundary_path, ) +from agents_shipgate.core.errors import ConfigError from agents_shipgate.core.globbing import glob_match as _glob_match _TRIGGERS_FILENAME = "triggers.json" @@ -510,34 +510,20 @@ def _git_diff_context( Returns ``([paths], diff_text)``. """ - run_kwargs: dict[str, Any] = { - "capture_output": True, - "text": True, - "check": True, - } - if cwd is not None: - run_kwargs["cwd"] = str(cwd) - if revspec: - names_cmd = ["git", "diff", "--name-only", revspec] - body_cmd = ["git", "diff", revspec] - else: - names_cmd = ["git", "diff", "HEAD", "--name-only"] - body_cmd = ["git", "diff", "HEAD"] - names = subprocess.run(names_cmd, **run_kwargs) - body = subprocess.run(body_cmd, **run_kwargs) - paths = [line for line in names.stdout.splitlines() if line.strip()] - diff_text = body.stdout - - if not revspec: - untracked = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], - **run_kwargs, - ) - for line in untracked.stdout.splitlines(): - stripped = line.strip() - if stripped and stripped not in paths: - paths.append(stripped) - return paths, diff_text + # Lazy import avoids a module cycle: the verifier orchestrator imports the + # pure trigger evaluator, while this optional CLI-only path reuses the + # verifier's audited Git transport. + from agents_shipgate.cli.verify.git import ( + diff_revspec_context, + working_tree_context, + ) + + root = cwd or Path.cwd() + return ( + diff_revspec_context(root, revspec) + if revspec + else working_tree_context(root, reject_index_hidden=True) + ) def _read_paths_from_stdin() -> list[str]: @@ -651,7 +637,7 @@ def main(argv: list[str] | None = None) -> int: if args.git_diff is not None: try: paths, diff_text = _git_diff_context(args.git_diff) - except (FileNotFoundError, subprocess.CalledProcessError) as exc: + except (FileNotFoundError, ConfigError) as exc: print( f"--git-diff failed: {exc}. Run from a git checkout, or " "pass paths and --diff-text manually.", diff --git a/tests/golden/codex_boundary_result/agents_requirement_removed.json b/tests/golden/codex_boundary_result/agents_requirement_removed.json index e0253f78..af25c711 100644 --- a/tests/golden/codex_boundary_result/agents_requirement_removed.json +++ b/tests/golden/codex_boundary_result/agents_requirement_removed.json @@ -10,7 +10,7 @@ }, "decision": "require_review", "risk_level": "medium", - "audit_id": "agent_boundary_c22c9f245720d8f6f31d67cd", + "audit_id": "agent_boundary_130a698865cb613276ec3b70", "policy_version": "1", "summary": "1 coding-agent boundary change(s) require human review.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/docs_only.json b/tests/golden/codex_boundary_result/docs_only.json index 486db986..ac80cdd1 100644 --- a/tests/golden/codex_boundary_result/docs_only.json +++ b/tests/golden/codex_boundary_result/docs_only.json @@ -10,7 +10,7 @@ }, "decision": "allow", "risk_level": "none", - "audit_id": "agent_boundary_bcae9dc247f5dae731a41e8c", + "audit_id": "agent_boundary_5ff19ceec162427c58475295", "policy_version": "1", "summary": "No recognized coding-agent boundary change requires action.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/github_action_removed.json b/tests/golden/codex_boundary_result/github_action_removed.json index 4f4f6d28..2cb1b681 100644 --- a/tests/golden/codex_boundary_result/github_action_removed.json +++ b/tests/golden/codex_boundary_result/github_action_removed.json @@ -10,7 +10,7 @@ }, "decision": "block", "risk_level": "critical", - "audit_id": "agent_boundary_488574aef143b8ccc5744839", + "audit_id": "agent_boundary_4234ea0a6d9096186b3feae9", "policy_version": "1", "summary": "1 coding-agent boundary change(s) block local continuation.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/malformed_toml.json b/tests/golden/codex_boundary_result/malformed_toml.json index 0792318a..0d631194 100644 --- a/tests/golden/codex_boundary_result/malformed_toml.json +++ b/tests/golden/codex_boundary_result/malformed_toml.json @@ -10,7 +10,7 @@ }, "decision": "require_review", "risk_level": "medium", - "audit_id": "agent_boundary_04887e3b07ba7c7961f61c2a", + "audit_id": "agent_boundary_44f6bbbe7ce3a03ea47879b0", "policy_version": "1", "summary": "1 coding-agent boundary change(s) require human review.", "changed_files": [ 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 0a019b14..03807409 100644 --- a/tests/golden/codex_boundary_result/mcp_auto_approve_write.json +++ b/tests/golden/codex_boundary_result/mcp_auto_approve_write.json @@ -10,7 +10,7 @@ }, "decision": "block", "risk_level": "critical", - "audit_id": "agent_boundary_78c4542efc6c9ede23d89129", + "audit_id": "agent_boundary_3621e6628a32ce7ceb40d6ad", "policy_version": "1", "summary": "1 coding-agent boundary change(s) block local continuation.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/network_wildcard.json b/tests/golden/codex_boundary_result/network_wildcard.json index fe60982d..4459c646 100644 --- a/tests/golden/codex_boundary_result/network_wildcard.json +++ b/tests/golden/codex_boundary_result/network_wildcard.json @@ -10,7 +10,7 @@ }, "decision": "require_review", "risk_level": "high", - "audit_id": "agent_boundary_1697437c484c034da283fa0e", + "audit_id": "agent_boundary_81171befe09a848fcb17dbb3", "policy_version": "1", "summary": "1 coding-agent boundary change(s) require human review.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/python_refactor.json b/tests/golden/codex_boundary_result/python_refactor.json index fb39415b..072215c7 100644 --- a/tests/golden/codex_boundary_result/python_refactor.json +++ b/tests/golden/codex_boundary_result/python_refactor.json @@ -10,7 +10,7 @@ }, "decision": "allow", "risk_level": "none", - "audit_id": "agent_boundary_ca11f1833eaccd04b795f492", + "audit_id": "agent_boundary_25edab7bce1026737ccca666", "policy_version": "1", "summary": "No recognized coding-agent boundary change requires action.", "changed_files": [ diff --git a/tests/golden/codex_boundary_result/unknown_permission_key.json b/tests/golden/codex_boundary_result/unknown_permission_key.json index ba71d3ef..4ea8fb64 100644 --- a/tests/golden/codex_boundary_result/unknown_permission_key.json +++ b/tests/golden/codex_boundary_result/unknown_permission_key.json @@ -10,34 +10,32 @@ }, "decision": "require_review", "risk_level": "medium", - "audit_id": "agent_boundary_7908865cfe6b4fa73441f451", + "audit_id": "agent_boundary_a0e673974b41fe0c295c4c82", "policy_version": "1", - "summary": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", + "summary": "The supplied diff is not bound to a checkout state that verify can reconstruct. Re-run check against the intended worktree or with both --base and --head before following a verification command.", "changed_files": [ ".codex/config.toml" ], "control": { - "state": "agent_action_required", - "reason": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", + "state": "human_review_required", + "reason": "The supplied diff is not bound to a checkout state that verify can reconstruct. Re-run check against the intended worktree or with both --base and --head before following a verification command.", "completion_allowed": false, - "must_stop": false, + "must_stop": true, "verify_required": true, "next_action": { - "actor": "coding_agent", - "kind": "verify", - "command": "agents-shipgate verify --workspace --config /shipgate.yaml --json", + "actor": "human", + "kind": "review", + "command": null, "expects": null, - "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." + "why": "The supplied diff is not bound to a checkout state that verify can reconstruct. Re-run check against the intended worktree or with both --base and --head before following a verification command." }, - "allowed_next_commands": [ - "agents-shipgate verify --workspace --config /shipgate.yaml --json" - ], + "allowed_next_commands": [], "human_review": { - "required": false, - "why": null, + "required": true, + "why": "The supplied diff is not bound to a checkout state that verify can reconstruct. Re-run check against the intended worktree or with both --base and --head before following a verification command.", "required_reviewers": [] }, - "stop_reason": null + "stop_reason": "The supplied diff is not bound to a checkout state that verify can reconstruct. Re-run check against the intended worktree or with both --base and --head before following a verification command." }, "repair": { "actor": "human", @@ -83,7 +81,7 @@ } ], "required_reviewers": [], - "explanation": "1 coding-agent boundary change(s) need PR-time review; verify, then report them.", + "explanation": "The supplied diff is not bound to a checkout state that verify can reconstruct. Re-run check against the intended worktree or with both --base and --head before following a verification command.", "suggested_fixes": [ "Review the unknown permission key before trusting the boundary." ], diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index 353868c1..a8f5be7e 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -193,87 +193,41 @@ class AllowedException: AllowedException( relative_path="cli/discovery/artifacts.py", surface="attr_call:subprocess.run", - line=474, + line=494, snippet=( - "subprocess.run(['git', '-C', str(workspace), 'rev-parse', " - "'--show-toplevel'], check=False, capture_output=True, " - "text=True, timeout=2)" + "subprocess.run(['git', '--no-replace-objects', '-C', " + "str(workspace), 'rev-parse', '--show-toplevel'], check=False, " + "capture_output=True, env=env, text=True, timeout=2)" ), rationale=( "_git_candidate_files step 1: ``git -C " "rev-parse --show-toplevel`` to locate the repo root. " - "Fixed argv, capture-only, no shell, hard timeout." + "Fixed argv, sanitized Git environment, capture-only, no shell, " + "and a hard timeout." ), ), AllowedException( relative_path="cli/discovery/artifacts.py", - surface="attr_call:subprocess.run", - line=490, + surface="attr_call:subprocess.Popen", + line=576, snippet=( - "subprocess.run(['git', '-C', str(workspace), 'ls-files', " - "'-co', '--exclude-standard', '--full-name', '-z', '--', " - "'.'], check=False, capture_output=True, timeout=5)" + "subprocess.Popen(['git', '--no-replace-objects', '-C', " + "str(workspace), *args], env=env, stdout=subprocess.PIPE, " + "stderr=subprocess.DEVNULL)" ), rationale=( "_git_candidate_files step 2: ``git -C " "ls-files -co --exclude-standard --full-name -z -- .`` to " "enumerate tracked + untracked-not-ignored files in NUL-" - "delimited form. Fixed argv, capture-only, no shell." - ), - ), - # triggers.py — trigger evaluation reads ``git diff`` output. Same - # trust profile as discovery/artifacts.py. - AllowedException( - relative_path="triggers.py", - surface="import:subprocess", - line=24, - snippet="import subprocess", - rationale=( - "Trigger evaluation runs ``git diff`` to determine whether a " - "PR's changes match Shipgate's triggers.json rules. Fixed git " - "argv, capture-only, no shell." - ), - ), - AllowedException( - relative_path="triggers.py", - surface="attr_call:subprocess.run", - line=526, - snippet="subprocess.run(names_cmd, **run_kwargs)", - rationale=( - "git-diff change-name pass: ``git diff --name-only " - "base...HEAD``. argv (names_cmd) constructed inside Shipgate " - "from validated base ref; run_kwargs is a fixed capture-only " - "dict (capture_output/text/check, optional cwd)." - ), - ), - AllowedException( - relative_path="triggers.py", - surface="attr_call:subprocess.run", - line=527, - snippet="subprocess.run(body_cmd, **run_kwargs)", - rationale=( - "git-diff body pass: ``git diff base...HEAD`` for full " - "diff body inspection. argv (body_cmd) constructed inside " - "Shipgate; run_kwargs is the same fixed capture-only dict." - ), - ), - AllowedException( - relative_path="triggers.py", - surface="attr_call:subprocess.run", - line=532, - snippet=( - "subprocess.run(['git', 'ls-files', '--others', '--exclude-standard'], **run_kwargs)" - ), - rationale=( - "git-untracked enumeration: ``git ls-files --others " - "--exclude-standard``. Fixed argv, capture-only, no shell." + "delimited form. The fixed-argv, sanitized-environment collector " + "drains incrementally and kills Git at a hard byte or wall-clock " + "bound; no shell or user-code execution." ), ), # cli/trigger.py — the `agents-shipgate trigger` subcommand imports # subprocess ONLY to catch ``subprocess.CalledProcessError`` from the - # shared ``triggers._git_diff_context`` git probe (allowlisted above). - # It issues no subprocess call of its own — git is invoked exclusively - # inside triggers.py, and only when --base/--head is passed. + # verifier's shared audited Git collector. It issues no subprocess call + # of its own. AllowedException( relative_path="cli/trigger.py", surface="import:subprocess", @@ -281,9 +235,9 @@ class AllowedException: snippet="import subprocess", rationale=( "trigger subcommand catches subprocess.CalledProcessError from " - "the shared triggers._git_diff_context git probe; this file " - "issues no subprocess.run call itself (git runs in triggers.py " - "only, and only under --base/--head)." + "the shared audited Git collector reached through " + "triggers._git_diff_context; this file issues no subprocess " + "call itself." ), ), # cli/verify/git.py — verify orchestrates local base/head git reads. @@ -300,10 +254,27 @@ class AllowedException: "network fetch." ), ), + AllowedException( + relative_path="cli/verify/git.py", + surface="attr_call:subprocess.Popen", + line=1383, + snippet=( + "subprocess.Popen(cmd, env=env, stderr=subprocess.DEVNULL, " + "stdin=subprocess.PIPE if input is not None else " + "subprocess.DEVNULL, stdout=subprocess.PIPE)" + ), + rationale=( + "The shared bounded Git collector drains fixed local Git argv " + "incrementally and kills them at a hard byte or wall-clock " + "bound. It covers diff/name/attribute/inventory reads and " + "retained-manifest discovery without a shell, user-code " + "execution, or fetch." + ), + ), AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=757, + line=1493, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " @@ -413,7 +384,7 @@ class AllowedException: AllowedException( relative_path="triggers.py", surface="import:importlib.resources.files", - line=26, + line=25, snippet="from importlib.resources import files", rationale=( "triggers.py reads the bundled docs/triggers.json catalog from " @@ -1291,16 +1262,14 @@ def test_no_unallowlisted_forbidden_surface_in_scanner() -> None: ) -def test_allowed_exceptions_pin_subprocess_run_per_call_site() -> None: +def test_allowed_exceptions_pin_subprocess_per_call_site() -> None: """v0.18 (PR #2 review follow-up): regression test for the P1 bypass the reviewer caught. - Confirms that ``triggers.py`` has THREE distinct - ``subprocess.run`` AllowedException entries (one per call site at - lines 480, 481, 486), not one blanket entry that permits all - occurrences. Same for ``cli/discovery/artifacts.py`` (two call - sites at 474 and 490). Adding a fourth ``subprocess.run`` to - ``triggers.py`` must require adding a new ALLOWED_EXCEPTIONS entry. + Trigger evaluation now delegates to the verifier's audited Git transport, + so ``triggers.py`` has no subprocess exception. Discovery has one bounded + root probe and one bounded inventory collector; verify has one shared + conventional process boundary and one shared bounded-output boundary. If the test fails because lines drifted, that is the intended failure mode — the contributor must re-review the move and bump @@ -1311,32 +1280,40 @@ def test_allowed_exceptions_pin_subprocess_run_per_call_site() -> None: for exc in ALLOWED_EXCEPTIONS: by_file_surface.setdefault((exc.relative_path, exc.surface), []).append(exc) - triggers_subprocess_run = by_file_surface.get(("triggers.py", "attr_call:subprocess.run"), []) - assert len(triggers_subprocess_run) == 3, ( - f"Expected 3 distinct AllowedException entries for " - f"triggers.py subprocess.run calls (one per call site at " - f"lines 480, 481, 486), got {len(triggers_subprocess_run)}. " - f"Per-call-site pinning is the structural fix for the " - f"P1 review finding — each subprocess.run call must have " - f"its own entry with line + snippet pinning." - ) + assert not [ + exc + for exc in ALLOWED_EXCEPTIONS + if exc.relative_path == "triggers.py" + and exc.surface.startswith(("import:subprocess", "attr_call:subprocess.")) + ], "triggers.py must use the verifier's audited Git transport" + artifacts_subprocess_run = by_file_surface.get( ("cli/discovery/artifacts.py", "attr_call:subprocess.run"), [] ) - assert len(artifacts_subprocess_run) == 2, ( - f"Expected 2 distinct AllowedException entries for " - f"cli/discovery/artifacts.py subprocess.run calls (one per " - f"call site at lines 474 and 490), got " - f"{len(artifacts_subprocess_run)}." + artifacts_subprocess_popen = by_file_surface.get( + ("cli/discovery/artifacts.py", "attr_call:subprocess.Popen"), [] + ) + assert len(artifacts_subprocess_run) == 1, ( + "Expected one root-resolution subprocess.run exception for cli/discovery/artifacts.py." + ) + assert len(artifacts_subprocess_popen) == 1, ( + "Expected one bounded inventory subprocess.Popen exception for cli/discovery/artifacts.py." ) verify_subprocess_run = by_file_surface.get( ("cli/verify/git.py", "attr_call:subprocess.run"), [] ) + verify_subprocess_popen = by_file_surface.get( + ("cli/verify/git.py", "attr_call:subprocess.Popen"), [] + ) assert len(verify_subprocess_run) == 1, ( f"Expected 1 AllowedException entry for cli/verify/git.py " f"subprocess.run (the shared fixed-argv process boundary), got " f"{len(verify_subprocess_run)}." ) + assert len(verify_subprocess_popen) == 1, ( + "Expected one AllowedException entry for cli/verify/git.py " + "subprocess.Popen (the shared bounded-output Git boundary)." + ) def test_scanner_sources_covers_known_files() -> None: diff --git a/tests/test_agent_boundary.py b/tests/test_agent_boundary.py index 35ab1b00..6de3728e 100644 --- a/tests/test_agent_boundary.py +++ b/tests/test_agent_boundary.py @@ -1,18 +1,23 @@ from __future__ import annotations import json +import os +import shlex import subprocess +import sys from pathlib import Path import pytest from typer.testing import CliRunner +import agents_shipgate.cli.agent_result as agent_result_cli from agents_shipgate.cli.agent_result import ( build_agent_boundary_result, git_boundary_change_set, ) from agents_shipgate.cli.main import app from agents_shipgate.core.agent_boundary import evaluate_agent_boundary +from agents_shipgate.core.errors import ConfigError from agents_shipgate.mcp_server.server import shipgate_check runner = CliRunner() @@ -43,7 +48,14 @@ def _change_diff(path: str, old: str, new: str) -> str: ) -def _build(tmp_path: Path, diff: str, *, agent: str = "codex", input_issues=None): +def _build( + tmp_path: Path, + diff: str, + *, + agent: str = "codex", + input_issues=None, + base_manifest_absent: bool | None = None, +): return build_agent_boundary_result( agent=agent, workspace=tmp_path, @@ -52,6 +64,11 @@ def _build(tmp_path: Path, diff: str, *, agent: str = "codex", input_issues=None policy=None, input_mode="provided_diff", input_issues=input_issues, + base_manifest_absent=base_manifest_absent, + # Most evaluator fixtures in this file model a diff already bound to + # the temporary worktree. Detached-diff default safety has dedicated + # public-entry tests below. + verification_replayable=True, ) @@ -129,6 +146,52 @@ def test_unresolved_untracked_boundary_input_fails_closed( assert result.control.completion_allowed is False +def test_new_file_diff_must_match_an_existing_workspace_head( + tmp_path: Path, +) -> None: + target = tmp_path / ".codex" / "config.toml" + target.parent.mkdir(parents=True) + target.write_text('network_access = true\n', encoding="utf-8") + + result = _build( + tmp_path, + _new_file_diff(".codex/config.toml", 'model = "safe"\n'), + ) + + assert result.control.state == "human_review_required" + assert "content_source" in result.issues + assert any( + item.evidence.get("kind") == "codex_config_content_unresolved" + and item.evidence.get("source") == "new_file_workspace_mismatch" + for item in result.violated_rules + ) + + +def test_contradictory_new_file_headers_fail_closed( + tmp_path: Path, +) -> None: + path = ".codex/config.toml" + diff = ( + f"diff --git a/{path} b/{path}\n" + "new file mode 100644\n" + f"--- a/{path}\n" + f"+++ b/{path}\n" + "@@ -1,1 +1,1 @@\n" + "-network_access = true\n" + '+model = "safe"\n' + ) + + result = _build(tmp_path, diff) + + assert result.control.state == "human_review_required" + assert "boundary_diff_shape_invalid" in result.issues + assert any( + item.evidence.get("kind") == "boundary_input_unresolved" + and item.evidence.get("code") == "boundary_diff_shape_invalid" + for item in result.violated_rules + ) + + def test_safe_untracked_boundary_file_is_read_and_evaluated(tmp_path: Path) -> None: _init_repo(tmp_path) target = tmp_path / ".claude" / "settings.json" @@ -164,6 +227,45 @@ def test_codex_requirements_change_requires_human_review(tmp_path: Path) -> None assert result.affected_hosts == ["codex"] +def test_stored_lowercase_agent_instructions_keep_the_human_stop( + tmp_path: Path, +) -> None: + result = _build(tmp_path, _new_file_diff("agents.md", "Run Shipgate.")) + + assert result.control.state == "human_review_required" + assert result.control.allowed_next_commands == [] + assert any(item.path == "agents.md" for item in result.violations) + + +def test_untracked_unicode_agent_instructions_are_not_hidden_by_git_quoting( + tmp_path: Path, +) -> None: + _init_repo(tmp_path) + (tmp_path / "shipgate.yaml").write_text(_MCP_MANIFEST, encoding="utf-8") + subprocess.run(["git", "add", "shipgate.yaml"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-qm", "manifest"], cwd=tmp_path, check=True) + instructions = tmp_path / "caf\u00e9" / "AGENTS.md" + instructions.parent.mkdir() + instructions.write_text("Run Shipgate.\n", encoding="utf-8") + + invoked = runner.invoke( + app, + [ + "check", + "--workspace", + str(tmp_path), + "--format", + "agent-boundary-json", + ], + ) + + assert invoked.exit_code == 0, invoked.output + payload = json.loads(invoked.output) + assert "caf\u00e9/AGENTS.md" in payload["changed_files"] + assert payload["control"]["state"] == "human_review_required" + assert payload["input_coverage"] == "complete" + + @pytest.mark.parametrize( "config_text", [ @@ -213,16 +315,18 @@ def test_nested_codex_config_retains_structural_dangerous_grant_check( @pytest.mark.parametrize( - "path", + "path,expected_state,expected_action", [ - "sub/.mcp.json", - "sub/.github/workflows/release.yml", - "claude.md", + ("sub/.mcp.json", "agent_action_required", "verify"), + ("sub/.github/workflows/release.yml", "agent_action_required", "verify"), + ("claude.md", "human_review_required", "review"), ], ) def test_nested_and_case_variant_boundary_paths_never_complete( tmp_path: Path, path: str, + expected_state: str, + expected_action: str, ) -> None: content = ( json.dumps({"mcpServers": {"danger": {"command": "danger"}}}) @@ -231,12 +335,15 @@ def test_nested_and_case_variant_boundary_paths_never_complete( ) result = _build(tmp_path, _new_file_diff(path, content)) - # 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 + # Nested copies remain verify-routed, while a case-variant root instruction + # file is a live gate-governing trust root and must stop for human review. + assert result.control.state == expected_state + assert result.control.next_action.kind == expected_action + if expected_state == "agent_action_required": + assert result.pending_review + else: + assert result.violations + assert result.control.human_review.required is True assert result.control.completion_allowed is False @@ -398,6 +505,37 @@ def test_unclassified_workflow_behavior_change_requires_review(tmp_path: Path) - ) +def test_explicit_custom_policy_is_a_protected_shared_boundary( + tmp_path: Path, +) -> None: + old = 'id: custom\nversion: "1"\nrules: []' + new = 'id: custom\nversion: "2"\nrules: []' + policy = tmp_path / "custom-policy.yml" + policy.write_text(f"{new}\n", encoding="utf-8") + + assessment = evaluate_agent_boundary( + workspace=tmp_path, + diff_text=_change_diff("custom-policy.yml", old, new), + policy_path=Path("custom-policy.yml"), + input_mode="provided_diff", + verification_replayable=True, + ) + + assert assessment.legacy_result.control.state == "human_review_required" + assert assessment.affected_hosts == ("claude-code", "codex", "cursor") + assert any( + item.path == "custom-policy.yml" + and item.evidence.get("kind") == "protected_surface_unclassified" + and item.evidence.get("trust_root_class") == "policy" + for item in assessment.violations + ) + shared = next( + item for item in assessment.host_coverage if item.adapter == "shared" + ) + assert shared.paths == ["custom-policy.yml"] + assert shared.status == "complete" + + def test_changed_experimental_vscode_mcp_never_completes(tmp_path: Path) -> None: result = _build( tmp_path, @@ -734,23 +872,42 @@ def _init_repo(path: Path) -> None: "project:\n name: demo\n" "agent:\n name: support\n declared_purpose: Help customers.\n" ) +_MCP_MANIFEST = ( + 'version: "0.1"\n' + "project:\n name: demo\n" + "agent:\n name: support\n declared_purpose:\n - Help customers.\n" + "environment:\n target: production_like\n" + "tool_sources:\n" + " - id: mcp\n" + " type: mcp\n" + " path: mcp-tools.json\n" +) def test_new_manifest_reads_as_adoption_not_an_unclassified_surface( tmp_path: Path, ) -> None: - result = _build(tmp_path, _new_file_diff("shipgate.yaml", _MANIFEST)) + result = _build( + tmp_path, + _new_file_diff("shipgate.yaml", _MANIFEST), + base_manifest_absent=True, + ) # Routing is untouched: adopting a gate is still a human decision. assert result.decision == "require_review" assert result.control.state == "human_review_required" assert result.control.must_stop is True + assert result.affected_hosts == ["claude-code", "codex", "cursor"] + shared = next(item for item in result.host_coverage if item.adapter == "shared") + assert shared.paths == ["shipgate.yaml"] + assert shared.status == "complete" rows = [item for item in result.violated_rules if item.path == "shipgate.yaml"] assert rows and rows[0].id == "BOUNDARY-PROTECTED-SURFACE-UNCLASSIFIED" assert rows[0].evidence["kind"] == "manifest_introduced" assert "Adopting Agents Shipgate" in rows[0].title assert "human-reviewed PR" in rows[0].recommendation + assert "generated" not in rows[0].recommendation def test_editing_an_existing_manifest_is_not_an_adoption(tmp_path: Path) -> None: @@ -814,6 +971,146 @@ def test_a_custom_named_manifest_is_protected_by_check(tmp_path: Path) -> None: assert result.control.must_stop is True +def test_check_accepts_absolute_config_under_external_workspace_alias( + tmp_path: Path, +) -> None: + root = tmp_path / "repo" + root.mkdir() + target = root / "new-gate.yml" + target.write_text(_MANIFEST, encoding="utf-8") + alias = tmp_path / "repo-alias" + alias.symlink_to(root, target_is_directory=True) + + result = build_agent_boundary_result( + agent="codex", + workspace=alias, + diff_text=_change_diff( + target.name, + _MANIFEST, + _MANIFEST + "ci:\n mode: advisory\n", + ), + config=alias / target.name, + policy=None, + input_mode="worktree", + ) + + rows = [item for item in result.violated_rules if item.path == target.name] + assert rows + assert rows[0].evidence.get("trust_root_class") == "manifest" + assert result.control.state == "human_review_required" + + +def test_check_rejects_a_filesystem_resolved_custom_config_alias( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + actual = tmp_path / "new-gate.yml" + actual.write_text(_MANIFEST, encoding="utf-8") + alias = tmp_path / "NEW-GATE.yml" + diff = _change_diff( + actual.name, + _MANIFEST, + _MANIFEST + "ci:\n mode: advisory\n", + ) + real_lstat = Path.lstat + + def aliased_lstat(path: Path, *args, **kwargs): + if path == alias: + return real_lstat(actual, *args, **kwargs) + return real_lstat(path, *args, **kwargs) + + monkeypatch.setattr(Path, "lstat", aliased_lstat) + + with pytest.raises( + ConfigError, + match=( + r"--config must use the exact filesystem spelling: " + r"NEW-GATE\.yml resolves to new-gate\.yml" + ), + ): + build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=diff, + config=Path(alias.name), + policy=None, + input_mode="worktree", + ) + + cli_result = runner.invoke( + app, + [ + "check", + "--agent", + "codex", + "--workspace", + str(tmp_path), + "--config", + alias.name, + "--diff", + "-", + "--format", + "agent-boundary-json", + ], + input=diff, + ) + + assert cli_result.exit_code == 2, cli_result.output + assert "--config must use the exact filesystem spelling" in cli_result.output + assert "NEW-GATE.yml resolves to new-gate.yml" in cli_result.output + + +@pytest.mark.skipif( + sys.platform != "darwin", + reason="exercises Git spelling drift on macOS filesystems", +) +def test_check_matches_real_git_index_case_spelling_to_configured_manifest( + tmp_path: Path, +) -> None: + _init_repo(tmp_path) + indexed = tmp_path / "Gate.gate" + indexed.write_text(_MCP_MANIFEST, encoding="utf-8") + (tmp_path / "mcp-tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-qm", "indexed manifest"], cwd=tmp_path, check=True) + + configured = tmp_path / "gate.gate" + indexed.rename(configured) + configured.write_text(_MCP_MANIFEST + "# changed\n", encoding="utf-8") + stored_names = {entry.name for entry in tmp_path.iterdir()} + if configured.name not in stored_names or indexed.name in stored_names: + pytest.skip("filesystem did not retain the case-only rename spelling") + try: + if not os.path.samestat(indexed.lstat(), configured.lstat()): + pytest.skip("filesystem does not alias case variants") + except OSError: + pytest.skip("filesystem does not alias case variants") + + diff = subprocess.run( + ["git", "-c", "core.quotepath=false", "diff", "--no-ext-diff"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ).stdout + if "Gate.gate" not in diff: + pytest.skip("Git did not emit its index spelling") + + result = build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=diff, + config=Path(configured.name), + policy=None, + input_mode="worktree", + ) + + rows = [item for item in result.violated_rules if item.path == indexed.name] + assert rows + assert rows[0].evidence.get("trust_root_class") == "manifest" + assert result.control.state == "human_review_required" + + def test_the_default_manifest_keeps_its_classification(tmp_path: Path) -> None: """The configured-manifest path must not shadow the table's own class.""" @@ -859,12 +1156,380 @@ def test_check_authorizes_a_verify_command_for_its_own_target(tmp_path: Path) -> assert "new-gate.yml" in command -def test_existing_codex_audit_ids_do_not_rotate(tmp_path: Path) -> None: - """Actor entered the digest; ids issued before it existed must not move. +def test_check_verify_command_preserves_the_evaluated_ref_range( + tmp_path: Path, +) -> None: + (tmp_path / "new-gate.yml").write_text(_MCP_MANIFEST, encoding="utf-8") + result = build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=_change_diff("mcp-tools.json", "[]\n", '[{"name": "read"}]\n'), + config=Path("new-gate.yml"), + policy=None, + input_mode="git_range", + base="origin/base; printf BAD", + head="feature head", + ) - Every one of those was a codex run, so rotating them would break stored - references to say nothing new. - """ + command = result.control.next_action.command + assert command is not None + parts = shlex.split(command) + assert parts[parts.index("--base") + 1] == "origin/base; printf BAD" + assert parts[parts.index("--head") + 1] == "feature head" + assert parts[parts.index("--workspace") + 1] == str(tmp_path) + assert parts[parts.index("--config") + 1].endswith("new-gate.yml") + + +def test_provided_diff_never_authorizes_an_unbound_verify( + tmp_path: Path, +) -> None: + (tmp_path / "shipgate.yaml").write_text(_MCP_MANIFEST, encoding="utf-8") + diff_file = tmp_path / "change.diff" + diff_file.write_text( + _change_diff("mcp-tools.json", "[]\n", '[{"name": "read"}]\n'), + encoding="utf-8", + ) + + invoked = runner.invoke( + app, + [ + "check", + "--workspace", + str(tmp_path), + "--config", + "shipgate.yaml", + "--diff", + str(diff_file), + "--format", + "agent-boundary-json", + ], + ) + + assert invoked.exit_code == 0, invoked.output + payload = json.loads(invoked.output) + assert payload["control"]["state"] == "human_review_required" + assert payload["control"]["allowed_next_commands"] == [] + assert payload["control"]["next_action"]["command"] is None + assert "not bound to a checkout state" in payload["control"]["stop_reason"] + + +def test_provided_diff_builder_defaults_to_non_replayable( + tmp_path: Path, +) -> None: + result = build_agent_boundary_result( + workspace=tmp_path, + diff_text=_new_file_diff( + ".codex/config.toml", + '[permissions.workspace.network]\nenabled = true\nsurprise = "value"\n', + ), + config=Path("shipgate.yaml"), + policy=None, + ) + + assert result.control.state == "human_review_required" + assert result.control.allowed_next_commands == [] + assert result.control.next_action.command is None + + +def test_undeclared_recovery_commands_preserve_the_requested_workspace( + tmp_path: Path, +) -> None: + manifest = ( + 'version: "0.1"\n' + "project:\n name: demo\n" + "agent:\n name: support\n declared_purpose:\n - Help customers.\n" + "environment:\n target: production_like\n" + "tool_sources:\n" + " - id: other\n" + " type: mcp\n" + " path: other-tools.json\n" + ) + (tmp_path / "custom gate.yml").write_text(manifest, encoding="utf-8") + diff = _new_file_diff("api/openapi.yaml", "openapi: 3.0.0\npaths: {}\n") + + adopted = build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=diff, + config=Path("custom gate.yml"), + policy=None, + input_mode="worktree", + ) + detect = shlex.split(adopted.control.next_action.command or "") + assert detect == [ + "shipgate", + "detect", + "--workspace", + str(tmp_path), + "--json", + ] + adopted_text = json.dumps(adopted.model_dump(mode="json")) + assert "custom gate.yml" in adopted_text + assert "shipgate.yaml tool_sources" not in adopted_text + + unconfigured = build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=diff, + config=Path("missing gate.yml"), + policy=None, + input_mode="worktree", + ) + preview = shlex.split(unconfigured.control.next_action.command or "") + assert preview[preview.index("--workspace") + 1] == str(tmp_path) + assert preview[preview.index("--config") + 1].endswith("missing gate.yml") + assert "--preview" in preview + unconfigured_text = json.dumps(unconfigured.model_dump(mode="json")) + assert "missing gate.yml" in unconfigured_text + assert "shipgate.yaml does not declare" not in unconfigured_text + + +def test_ref_range_undeclared_discovery_never_targets_the_current_checkout( + tmp_path: Path, +) -> None: + (tmp_path / "custom-gate.yml").write_text(_MCP_MANIFEST, encoding="utf-8") + + result = build_agent_boundary_result( + workspace=tmp_path, + diff_text=_new_file_diff( + "api/openapi.yaml", + "openapi: 3.0.0\npaths: {}\n", + ), + config=Path("custom-gate.yml"), + policy=None, + input_mode="git_range", + base="origin/main", + head="feature", + ) + + assert result.control.state == "human_review_required" + assert result.control.allowed_next_commands == [] + assert result.control.next_action.command is None + assert ( + "detect can inspect only the checked-out worktree" + in (result.control.stop_reason or "") + ) + assert all("shipgate detect" not in fix for fix in result.suggested_fixes) + + +def test_ordinary_check_does_not_probe_every_tracked_file_for_adoption( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def unexpected_probe(_workspace: Path, _ref: str) -> bool: + raise AssertionError("retained-manifest probe should not run") + + monkeypatch.setattr( + agent_result_cli, + "carries_manifest_like_yaml", + unexpected_probe, + ) + + result = build_agent_boundary_result( + workspace=tmp_path, + diff_text=_new_file_diff("docs/readme.md", "docs\n"), + config=Path("shipgate.yaml"), + policy=None, + input_mode="worktree", + ) + + assert result.changed_files == ["docs/readme.md"] + + +def test_check_does_not_call_an_added_manifest_adoption_when_base_keeps_one( + tmp_path: Path, +) -> None: + _init_repo(tmp_path) + (tmp_path / "old-gate.yml").write_text(_MCP_MANIFEST, encoding="utf-8") + subprocess.run(["git", "add", "old-gate.yml"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-qm", "existing gate"], cwd=tmp_path, check=True) + (tmp_path / "new-gate.yml").write_text(_MCP_MANIFEST, encoding="utf-8") + + result = build_agent_boundary_result( + agent="codex", + workspace=tmp_path, + diff_text=_new_file_diff("new-gate.yml", _MCP_MANIFEST), + config=Path("new-gate.yml"), + policy=None, + input_mode="worktree", + ) + + rows = [item for item in result.violated_rules if item.path == "new-gate.yml"] + assert rows + assert all(item.evidence.get("kind") != "manifest_introduced" for item in rows) + assert all("Adopting Agents Shipgate" not in item.title for item in rows) + + +def test_boundary_changed_files_keep_both_sides_of_a_rename( + tmp_path: Path, +) -> None: + diff = ( + "diff --git a/policies/review.yml b/retired.txt\n" + "similarity index 100%\n" + "rename from policies/review.yml\n" + "rename to retired.txt\n" + ) + + result = _build(tmp_path, diff) + + assert result.changed_files == ["policies/review.yml", "retired.txt"] + + +def test_worktree_rename_is_not_misclassified_as_an_untracked_missing_file( + tmp_path: Path, +) -> None: + _init_repo(tmp_path) + source = tmp_path / "policies" / "review.yml" + source.parent.mkdir() + source.write_text("review: required\n", encoding="utf-8") + subprocess.run(["git", "add", "policies/review.yml"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-qm", "policy"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "mv", "policies/review.yml", "retired.txt"], + cwd=tmp_path, + check=True, + ) + + change_set = git_boundary_change_set(workspace=tmp_path, base=None, head=None) + + assert change_set.changed_paths == ("policies/review.yml", "retired.txt") + assert change_set.issues == () + + +def test_check_binds_an_ignored_custom_manifest_to_head(tmp_path: Path) -> None: + _init_repo(tmp_path) + (tmp_path / ".gitignore").write_text("custom-gate.yml\n", encoding="utf-8") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-qm", "ignore local gate"], cwd=tmp_path, check=True) + (tmp_path / "custom-gate.yml").write_text(_MCP_MANIFEST, encoding="utf-8") + + invoked = runner.invoke( + app, + [ + "check", + "--workspace", + str(tmp_path), + "--config", + "custom-gate.yml", + "--format", + "agent-boundary-json", + ], + ) + + assert invoked.exit_code == 0, invoked.output + payload = json.loads(invoked.output) + assert "custom-gate.yml" in payload["changed_files"] + assert payload["control"]["state"] == "human_review_required" + assert any( + item.get("evidence", {}).get("trust_root_class") == "manifest" + for item in payload["violations"] + ) + + +def test_check_preserves_a_trailing_space_manifest_identity(tmp_path: Path) -> None: + _init_repo(tmp_path) + manifest_name = "gate.yml " + (tmp_path / manifest_name).write_text(_MCP_MANIFEST, encoding="utf-8") + + invoked = runner.invoke( + app, + [ + "check", + "--workspace", + str(tmp_path), + "--config", + manifest_name, + "--format", + "agent-boundary-json", + ], + ) + + assert invoked.exit_code == 0, invoked.output + payload = json.loads(invoked.output) + assert manifest_name in payload["changed_files"] + assert payload["control"]["state"] == "human_review_required" + assert any( + item.get("path") == manifest_name + and item.get("evidence", {}).get("trust_root_class") == "manifest" + for item in payload["violations"] + ) + + +@pytest.mark.parametrize("index_flag", ["--assume-unchanged", "--skip-worktree"]) +def test_check_rejects_index_hidden_manifest_changes( + tmp_path: Path, + index_flag: str, +) -> None: + _init_repo(tmp_path) + gate = tmp_path / "custom-gate.yml" + gate.write_text(_MCP_MANIFEST, encoding="utf-8") + subprocess.run(["git", "add", "custom-gate.yml"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-qm", "add gate"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "update-index", index_flag, "custom-gate.yml"], + cwd=tmp_path, + check=True, + ) + gate.write_text(_MCP_MANIFEST + "# hidden change\n", encoding="utf-8") + assert ( + subprocess.run( + ["git", "diff", "--name-only"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ).stdout + == "" + ) + + invoked = runner.invoke( + app, + [ + "check", + "--workspace", + str(tmp_path), + "--config", + "custom-gate.yml", + "--format", + "agent-boundary-json", + ], + ) + + assert invoked.exit_code == 2 + assert "Git index flags hide paths from worktree collection" in invoked.output + + +def test_check_rejects_an_index_hidden_declared_source(tmp_path: Path) -> None: + _init_repo(tmp_path) + (tmp_path / "shipgate.yaml").write_text(_MCP_MANIFEST, encoding="utf-8") + source = tmp_path / "mcp-tools.json" + source.write_text("[]\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-qm", "declare source"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "update-index", "--assume-unchanged", "mcp-tools.json"], + cwd=tmp_path, + check=True, + ) + source.write_text('[{"name": "dangerous_write"}]\n', encoding="utf-8") + + invoked = runner.invoke( + app, + [ + "check", + "--workspace", + str(tmp_path), + "--format", + "agent-boundary-json", + ], + ) + + assert invoked.exit_code == 2 + assert "Git index flags hide paths from worktree collection" in invoked.output + + +def test_agent_boundary_audit_ids_bind_actor_and_input_subject(tmp_path: Path) -> None: + """Control-distinct substrates are distinct audit rows.""" (tmp_path / "shipgate.yaml").write_text(_MANIFEST, encoding="utf-8") diff = _change_diff("shipgate.yaml", _MANIFEST, _MANIFEST + "ci:\n mode: advisory\n") @@ -901,8 +1566,108 @@ def test_existing_codex_audit_ids_do_not_rotate(tmp_path: Path) -> None: json.dumps(legacy_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest()[:24] assert _agent_boundary_audit_id( - actor="codex", changed_files=["x"], fingerprints=["fp"], policy_digest="d" + actor="codex", + changed_files=["x"], + fingerprints=["fp"], + policy_digest="d", + input_mode="provided_diff", + verification_replayable=True, ) == f"agent_boundary_{legacy_digest}" assert _agent_boundary_audit_id( - actor="claude-code", changed_files=["x"], fingerprints=["fp"], policy_digest="d" + actor="claude-code", + changed_files=["x"], + fingerprints=["fp"], + policy_digest="d", + input_mode="provided_diff", + verification_replayable=True, + ) != f"agent_boundary_{legacy_digest}" + assert _agent_boundary_audit_id( + actor="codex", + changed_files=["x"], + fingerprints=["fp"], + policy_digest="d", + input_mode="worktree", + verification_replayable=True, ) != f"agent_boundary_{legacy_digest}" + + +def test_same_diff_with_detached_and_worktree_control_has_distinct_audit_ids( + tmp_path: Path, +) -> None: + (tmp_path / "shipgate.yaml").write_text(_MCP_MANIFEST, encoding="utf-8") + diff = _change_diff("mcp-tools.json", "[]\n", '[{"name": "read"}]\n') + + detached = build_agent_boundary_result( + workspace=tmp_path, + diff_text=diff, + config=Path("shipgate.yaml"), + policy=None, + input_mode="provided_diff", + verification_replayable=False, + ) + worktree = build_agent_boundary_result( + workspace=tmp_path, + diff_text=diff, + config=Path("shipgate.yaml"), + policy=None, + input_mode="worktree", + verification_replayable=True, + ) + + assert detached.control.state == "human_review_required" + assert worktree.control.state == "agent_action_required" + assert detached.audit_id != worktree.audit_id + + +def test_worktree_audit_id_binds_diff_content_for_the_same_path( + tmp_path: Path, +) -> None: + first = build_agent_boundary_result( + workspace=tmp_path, + diff_text=_change_diff("README.md", "one", "two"), + config=Path("shipgate.yaml"), + policy=None, + input_mode="worktree", + ) + second = build_agent_boundary_result( + workspace=tmp_path, + diff_text=_change_diff("README.md", "one", "three"), + config=Path("shipgate.yaml"), + policy=None, + input_mode="worktree", + ) + + assert first.audit_id != second.audit_id + + +def test_worktree_audit_id_binds_resolved_workspace_content( + tmp_path: Path, +) -> None: + diff = _change_diff( + ".codex/config.toml", + 'model = "old"', + 'model = "new"', + ) + results = [] + for name, sandbox in ( + ("restricted", "workspace-write"), + ("expanded", "danger-full-access"), + ): + workspace = tmp_path / name + config = workspace / ".codex" / "config.toml" + config.parent.mkdir(parents=True) + config.write_text( + f'model = "new"\nsandbox_mode = "{sandbox}"\n', + encoding="utf-8", + ) + results.append( + build_agent_boundary_result( + workspace=workspace, + diff_text=diff, + config=Path("shipgate.yaml"), + policy=None, + input_mode="worktree", + ) + ) + + assert results[0].audit_id != results[1].audit_id diff --git a/tests/test_agent_controls.py b/tests/test_agent_controls.py index 93682a56..a423846f 100644 --- a/tests/test_agent_controls.py +++ b/tests/test_agent_controls.py @@ -3,7 +3,12 @@ import shlex from pathlib import Path -from agents_shipgate.core.agent_controls import verify_command_for +from agents_shipgate.cli.verify.orchestrator import _resolve_config_under_workspace +from agents_shipgate.core.agent_controls import ( + detect_command_for, + preview_command_for, + verify_command_for, +) def _argument(command: str, name: str) -> str: @@ -36,3 +41,122 @@ def test_verify_command_keeps_cross_repo_config_absolute(tmp_path: Path) -> None assert _argument(command, "--workspace") == str(requested_repository) assert _argument(command, "--config") == config.resolve().as_posix() + + +def test_verify_command_preserves_a_lexical_config_symlink_for_rejection( + tmp_path: Path, +) -> None: + repository = tmp_path / "repository" + (repository / ".git").mkdir(parents=True) + target = repository / "new-gate.yml" + target.write_text("manifest\n", encoding="utf-8") + link = repository / "gate.yml" + link.symlink_to(target.name) + + command = verify_command_for(repository, Path("gate.yml")) + + assert _argument(command, "--config") == "gate.yml" + assert "new-gate.yml" not in command + + +def test_verify_command_survives_a_symlinked_workspace_anchor(tmp_path: Path) -> None: + repository = tmp_path / "repository" + nested = repository / "services" / "api" + (repository / ".git").mkdir(parents=True) + nested.mkdir(parents=True) + alias = tmp_path / "repository-alias" + alias.symlink_to(repository, target_is_directory=True) + requested_workspace = alias / "services" / "api" + + for config in (Path("gate.yml"), requested_workspace / "gate.yml"): + command = verify_command_for(requested_workspace, config) + + assert _argument(command, "--workspace") == str(requested_workspace) + emitted_config = _argument(command, "--config") + assert emitted_config == "services/api/gate.yml" + _config_path, config_relative = _resolve_config_under_workspace( + repository.resolve(), + Path(emitted_config), + ) + assert config_relative.as_posix() == emitted_config + + +def test_verify_resolver_maps_absolute_config_under_direct_nested_workspace_alias( + tmp_path: Path, +) -> None: + repository = tmp_path / "repository" + nested = repository / "services" / "api" + (repository / ".git").mkdir(parents=True) + nested.mkdir(parents=True) + alias = tmp_path / "api-alias" + alias.symlink_to(nested, target_is_directory=True) + + config_path, config_relative = _resolve_config_under_workspace( + repository.resolve(), + alias / "gate.yml", + requested_workspace=alias, + ) + + assert config_path == nested / "gate.yml" + assert config_relative.as_posix() == "services/api/gate.yml" + + +def test_verify_resolver_keeps_canonical_config_for_nested_workspace_alias( + tmp_path: Path, +) -> None: + repository = tmp_path / "repository" + nested = repository / "services" / "api" + (repository / ".git").mkdir(parents=True) + nested.mkdir(parents=True) + alias = tmp_path / "api-alias" + alias.symlink_to(nested, target_is_directory=True) + + config_path, config_relative = _resolve_config_under_workspace( + repository.resolve(), + nested / "gate.yml", + requested_workspace=alias, + ) + + assert config_path == nested / "gate.yml" + assert config_relative.as_posix() == "services/api/gate.yml" + + +def test_verify_command_preserves_and_quotes_the_checked_ref_range( + tmp_path: Path, +) -> None: + workspace = tmp_path / "repository with spaces" + workspace.mkdir() + + command = verify_command_for( + workspace, + Path("gate.yml"), + base="origin/base; printf BAD", + head="feature head", + ) + + assert _argument(command, "--base") == "origin/base; printf BAD" + assert _argument(command, "--head") == "feature head" + assert _argument(command, "--workspace") == str(workspace) + + +def test_detect_and_preview_commands_preserve_the_requested_target( + tmp_path: Path, +) -> None: + workspace = tmp_path / "repository with spaces" + workspace.mkdir() + + detect = detect_command_for(workspace) + preview = preview_command_for(workspace, Path("custom gate.yml")) + + assert shlex.split(detect) == [ + "shipgate", + "detect", + "--workspace", + str(workspace), + "--json", + ] + assert _argument(preview, "--workspace") == str(workspace) + assert _argument(preview, "--config") == str( + (workspace / "custom gate.yml").resolve() + ) + assert "--preview" in shlex.split(preview) diff --git a/tests/test_agent_mode.py b/tests/test_agent_mode.py index 42b9f5f5..b0379967 100644 --- a/tests/test_agent_mode.py +++ b/tests/test_agent_mode.py @@ -13,7 +13,6 @@ from __future__ import annotations import json -import shlex import subprocess from pathlib import Path @@ -373,6 +372,22 @@ def test_check_explicit_agent_flag_beats_detection( } +def test_config_error_catalog_covers_non_manifest_request_configuration() -> None: + catalog = json.loads( + (Path(__file__).resolve().parent.parent / "docs" / "errors.json").read_text( + encoding="utf-8" + ) + ) + entry = next(item for item in catalog["errors"] if item["id"] == "config_error") + + assert "incompatible CLI flags" in entry["description"] + assert "preflight" in entry["typical_cause"] + assert "host-grants baseline" in entry["typical_cause"] + assert "do not assume every config_error is a manifest problem" in entry[ + "recovery_hint" + ] + + def _assert_documented_envelope(payload: dict) -> None: """`docs/errors.json` is the contract, not a description of what we emit. @@ -401,6 +416,32 @@ def _agent_mode_error(result) -> dict: return json.loads(lines[-1]) +@pytest.mark.parametrize("command", ["detect", "init"]) +def test_discovery_inventory_failure_is_a_structured_agent_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + command: str, +) -> None: + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + monkeypatch.setattr( + "agents_shipgate.cli.discovery.artifacts._run_git_inventory_bounded", + lambda *_args, **_kwargs: None, + ) + args = [command, "--workspace", str(tmp_path), "--json"] + + result = runner.invoke(app, args) + + assert result.exit_code == 4 + assert "Traceback" not in result.output + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + assert payload["error"] == "other_error" + assert payload["next_actions"][0]["kind"] == "review" + assert "bounded coverage" in payload["message"] + assert not (tmp_path / "shipgate.yaml").exists() + + def test_check_rejects_an_unknown_format_on_the_agent_channel( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -516,14 +557,14 @@ def test_preflight_reports_only_documented_error_kinds( _assert_documented_envelope(_agent_mode_error(result)) -def test_preflight_recovery_reruns_the_same_request( +def test_preflight_recovery_edits_then_reruns_the_same_request( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A bare `preflight --json` answers a question nobody asked. + """Repair comes first; the later rerun must still preserve the request. - It discards workspace, config, plan, diff and capability request, so - following it after a failed targeted run evaluates the current repository - with an empty plan and reports `control.state=complete`. + Replaying a malformed manifest cannot repair it. After the edit, a bare + `preflight --json` would still answer a question nobody asked because it + discards workspace, config, plan, diff and capability request. """ monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") @@ -535,10 +576,207 @@ def test_preflight_recovery_reruns_the_same_request( ) assert result.exit_code == 2 - action = _agent_mode_error(result)["next_actions"][0] - assert action["kind"] == "command" - assert str(tmp_path) in action["command"] - assert "--config shipgate.yaml" in action["command"] + payload = _agent_mode_error(result) + actions = payload["next_actions"] + assert actions[0]["kind"] == "edit" + assert actions[0]["path"] == str(tmp_path / "shipgate.yaml") + assert payload["next_action"] == f"Edit {tmp_path / 'shipgate.yaml'}" + assert actions[1]["kind"] == "command" + assert str(tmp_path) in actions[1]["command"] + assert "--config shipgate.yaml" in actions[1]["command"] + + +def test_preflight_config_identity_error_is_review_only( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A symlinked trust root is not an editable or replayable repair target.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + target = tmp_path / "actual-gate.yml" + target.write_text( + 'version: "0.1"\n' + "project:\n name: demo\n" + "agent:\n name: demo\n declared_purpose:\n - Test.\n" + "environment:\n target: development\n" + "tool_sources: []\n", + encoding="utf-8", + ) + alias = tmp_path / "gate.yml" + alias.symlink_to(target.name) + + result = runner.invoke( + app, + [ + "preflight", + "--workspace", + str(tmp_path), + "--config", + alias.name, + "--json", + ], + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + assert payload["error"] == "config_error" + assert len(payload["next_actions"]) == 1 + action = payload["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert action["path"] is None + assert "symlink" in payload["message"].lower() + + +def test_preflight_external_request_file_is_not_an_edit_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Passing a file for inspection does not grant authority to rewrite it.""" + + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "workspace" + workspace.mkdir() + external_plan = tmp_path / "outside-plan.json" + external_plan.write_text("{", encoding="utf-8") + + result = runner.invoke( + app, + [ + "preflight", + "--workspace", + str(workspace), + "--plan", + str(external_plan), + "--json", + ], + ) + + assert result.exit_code == 3 + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + assert len(payload["next_actions"]) == 1 + action = payload["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert action["path"] is None + assert str(external_plan) in payload["message"] + + +@pytest.mark.parametrize( + ("flag", "payload_text", "exit_code"), + [ + ("--plan", "[]\n", 3), + ("--plan", '{"changed_files": "not-a-list"}\n', 2), + ("--capability-request", "[]\n", 3), + ("--capability-request", "{}\n", 2), + ("--base-preflight", "[]\n", 3), + ("--base-preflight", "{}\n", 2), + ], +) +def test_preflight_external_json_errors_name_source_and_stay_review_only( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + flag: str, + payload_text: str, + exit_code: int, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "workspace" + workspace.mkdir() + external = tmp_path / f"outside-{flag.removeprefix('--')}.json" + external.write_text(payload_text, encoding="utf-8") + + result = runner.invoke( + app, + [ + "preflight", + "--workspace", + str(workspace), + flag, + str(external), + "--json", + ], + ) + + assert result.exit_code == exit_code, result.output + envelope = _agent_mode_error(result) + _assert_documented_envelope(envelope) + assert str(external) in envelope["message"] + assert len(envelope["next_actions"]) == 1 + action = envelope["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert action["path"] is None + + +def test_preflight_hardlinked_request_is_not_an_edit_target( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "workspace" + workspace.mkdir() + external = tmp_path / "external-request.json" + external.write_text("{", encoding="utf-8") + request = workspace / "request.json" + request.hardlink_to(external) + + result = runner.invoke( + app, + [ + "preflight", + "--workspace", + str(workspace), + "--capability-request", + str(request), + "--json", + ], + ) + + assert result.exit_code == 3, result.output + envelope = _agent_mode_error(result) + _assert_documented_envelope(envelope) + assert request.stat().st_nlink == 2 + assert len(envelope["next_actions"]) == 1 + action = envelope["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert action["path"] is None + + +def test_preflight_malformed_request_edits_then_replays_exact_request( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + request = tmp_path / "capability-request.json" + request.write_text("{", encoding="utf-8") + + result = runner.invoke( + app, + [ + "preflight", + "--workspace", + str(tmp_path), + "--capability-request", + str(request), + "--json", + ], + ) + + assert result.exit_code == 3 + payload = _agent_mode_error(result) + _assert_documented_envelope(payload) + actions = payload["next_actions"] + assert actions[0]["kind"] == "edit" + assert actions[0]["path"] == str(request) + assert actions[1]["kind"] == "command" + assert actions[1]["command"] == ( + "agents-shipgate preflight " + f"--workspace {tmp_path} " + "--config shipgate.yaml " + f"--capability-request {request} " + "--json" + ) def test_preflight_offers_no_command_it_cannot_reproduce( @@ -560,6 +798,7 @@ def test_preflight_offers_no_command_it_cannot_reproduce( payload = _agent_mode_error(result) _assert_documented_envelope(payload) assert payload["next_actions"][0]["kind"] == "review" + assert len(payload["next_actions"]) == 1 def test_audit_baseline_directory_is_an_other_error( @@ -681,6 +920,70 @@ def test_check_rejects_ambiguous_diff_shapes_without_a_command( assert action["command"] is None +def test_check_missing_diff_file_requires_review_instead_of_repeating_request( + tmp_path: Path, +) -> None: + missing = tmp_path / "missing change.diff" + + result = runner.invoke( + app, + [ + "check", + "--workspace", + str(tmp_path), + "--diff", + str(missing), + "--format", + "agent-boundary-json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["audit_id"].startswith("agent_boundary_error_") + assert payload["control"]["state"] == "human_review_required" + assert payload["control"]["next_action"]["kind"] == "review" + assert payload["control"]["allowed_next_commands"] == [] + assert str(missing) in payload["control"]["next_action"]["why"] + assert payload["repair"]["safe_to_attempt"] is False + assert "command" not in payload["repair"] + + +def test_check_missing_refs_requests_inputs_without_repeating_failed_command( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + (repo / "README.md").write_text("test\n", encoding="utf-8") + _commit_all(repo, "initial") + + result = runner.invoke( + app, + [ + "check", + "--workspace", + str(repo), + "--base", + "origin/missing", + "--head", + "HEAD", + "--format", + "agent-boundary-json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["audit_id"].startswith("agent_boundary_error_") + assert payload["control"]["state"] == "agent_action_required" + action = payload["control"]["next_action"] + assert action["kind"] == "fetch_base" + assert action["command"] is None + assert action["expects"] == "origin/missing and HEAD" + assert payload["control"]["allowed_next_commands"] == [] + assert payload["repair"]["safe_to_attempt"] is False + assert "command" not in payload["repair"] + + def test_preflight_offers_no_command_for_conflicting_flags( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -706,12 +1009,13 @@ def test_preflight_offers_no_command_for_conflicting_flags( payload = _agent_mode_error(result) _assert_documented_envelope(payload) assert payload["next_actions"][0]["kind"] == "review" + assert len(payload["next_actions"]) == 1 -def test_audit_missing_baseline_recovery_preserves_scope_and_target( +def test_audit_missing_baseline_requires_human_acknowledgement( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A missing baseline is a request problem, not a filesystem failure.""" + """A drift request never authorizes acceptance of the current grants.""" monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") baseline = tmp_path / "missing baseline; grants.json" @@ -734,23 +1038,12 @@ def test_audit_missing_baseline_recovery_preserves_scope_and_target( payload = _agent_mode_error(result) assert payload["error"] == "config_error" action = payload["next_actions"][0] - assert action["kind"] == "command" - argv = shlex.split(action["command"]) - assert argv == [ - "agents-shipgate", - "audit", - "--host", - "--workspace", - str(tmp_path), - "--scope", - "local-static", - "--save-baseline", - "--baseline-file", - str(baseline), - ] - assert "shipgate audit --host --scope repository --save-baseline" not in ( - result.output - ) + assert action["kind"] == "review" + assert action["command"] is None + assert "Human review is required" in action["why"] + assert str(tmp_path) in action["why"] + assert "local-static" in action["why"] + assert "--save-baseline" not in result.output assert payload["message"].endswith( f"No baseline was written to {baseline}." ) diff --git a/tests/test_agent_protocol.py b/tests/test_agent_protocol.py index b822d820..7980c3a3 100644 --- a/tests/test_agent_protocol.py +++ b/tests/test_agent_protocol.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import shlex from pathlib import Path from typing import Any @@ -294,32 +293,18 @@ def test_check_diff_input_failure_emits_schema_valid_boundary_result(tmp_path: P assert payload["schema_version"] == "shipgate.codex_boundary_result/v2" assert payload["decision"] == "block" control = _control(payload) - assert control["state"] == "agent_action_required" + assert control["state"] == "human_review_required" assert control["completion_allowed"] is False - assert control["must_stop"] is False - assert control["next_action"]["actor"] == "coding_agent" - assert control["next_action"]["kind"] == "repair" - assert payload["repair"]["safe_to_attempt"] is True + assert control["must_stop"] is True + assert control["next_action"]["actor"] == "human" + assert control["next_action"]["kind"] == "review" + assert control["next_action"]["command"] is None + assert control["allowed_next_commands"] == [] + assert payload["repair"]["actor"] == "human" + assert payload["repair"]["safe_to_attempt"] is False + assert "command" not in payload["repair"] assert payload["diagnostics"][0]["code"] == "diff_input_unresolved" - command = control["next_action"]["command"] - assert control["allowed_next_commands"] == [command] - assert payload["repair"]["command"] == command - assert shlex.split(command) == [ - "agents-shipgate", - "check", - "--agent", - "claude-code", - "--workspace", - str(workspace), - "--config", - str(config), - "--policy", - str(policy), - "--diff", - str(missing_diff), - "--format", - "codex-boundary-json", - ] + assert str(missing_diff) in control["next_action"]["why"] def test_check_diff_input_failure_preserves_a_complete_quoted_range( @@ -356,24 +341,15 @@ def test_check_diff_input_failure_preserves_a_complete_quoted_range( payload = json.loads(result.output) control = _control(payload) assert control["state"] == "agent_action_required" - assert shlex.split(control["next_action"]["command"]) == [ - "agents-shipgate", - "check", - "--agent", - "cursor", - "--workspace", - str(workspace), - "--config", - "custom gate.yml", - "--policy", - "custom policy.yml", - "--base", - base, - "--head", - head, - "--format", - "codex-boundary-json", - ] + assert control["next_action"]["actor"] == "coding_agent" + assert control["next_action"]["kind"] == "fetch_base" + assert control["next_action"]["command"] is None + assert control["next_action"]["expects"] == f"{base} and {head}" + assert control["allowed_next_commands"] == [] + assert payload["repair"]["actor"] == "coding_agent" + assert payload["repair"]["safe_to_attempt"] is False + assert "command" not in payload["repair"] + assert str(workspace) in control["next_action"]["why"] def test_missing_install_fixture_is_schema_valid_and_actionable() -> None: diff --git a/tests/test_codex_boundary_check.py b/tests/test_codex_boundary_check.py index 9cd33970..7ba45174 100644 --- a/tests/test_codex_boundary_check.py +++ b/tests/test_codex_boundary_check.py @@ -8,9 +8,13 @@ from jsonschema import Draft202012Validator from typer.testing import CliRunner -from agents_shipgate.cli.agent_result import build_codex_agent_result +from agents_shipgate.cli.agent_result import ( + build_codex_agent_result as _build_codex_agent_result, +) from agents_shipgate.cli.main import app -from agents_shipgate.core.codex_boundary import evaluate_codex_boundary_result +from agents_shipgate.core.codex_boundary import ( + evaluate_codex_boundary_result as _evaluate_codex_boundary_result, +) from agents_shipgate.inputs.codex_plugin import resolve_local_codex_marketplace_roots ROOT = Path(__file__).resolve().parent.parent @@ -21,6 +25,20 @@ runner = CliRunner() +def build_codex_agent_result(**kwargs): + """Build a result for the worktree-bound evaluator fixtures in this file.""" + + kwargs.setdefault("input_mode", "worktree") + return _build_codex_agent_result(**kwargs) + + +def evaluate_codex_boundary_result(**kwargs): + """Evaluate a diff whose test workspace is its reproducible subject.""" + + kwargs.setdefault("verification_replayable", True) + return _evaluate_codex_boundary_result(**kwargs) + + # 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 @@ -51,7 +69,8 @@ "unknown_permission_key": ( "require_review", ["CODEX-UNKNOWN-PERMISSION-KEY"], - "agent_action_required", # medium, parseable -> graded verify route + # A standalone diff is not bound to a checkout verify can reproduce. + "human_review_required", ), "malformed_toml": ( "require_review", @@ -97,9 +116,10 @@ def test_codex_check_boundary_json_golden_outputs(tmp_path: Path) -> None: # The authorized verify command names this invocation's own workspace # and manifest, so the golden pins its shape rather than one machine's # temporary directory. - assert _normalize_workspace(payload, tmp_path) == json.loads( - (GOLDEN / f"{case}.json").read_text(encoding="utf-8") - ) + normalized = _normalize_workspace(payload, tmp_path) + expected = json.loads((GOLDEN / f"{case}.json").read_text(encoding="utf-8")) + assert normalized == expected + assert payload["audit_id"].startswith("agent_boundary_") assert payload["decision"] == decision assert [item["id"] for item in payload["violated_rules"]] == rule_ids control = _control(payload) @@ -122,6 +142,26 @@ def test_codex_check_audit_id_is_stable(tmp_path: Path) -> None: assert first["audit_id"] == second["audit_id"] +def test_codex_audit_id_distinguishes_detached_control_from_replayable( + tmp_path: Path, +) -> None: + diff = (CORPUS / "unknown_permission_key.diff").read_text(encoding="utf-8") + detached = _evaluate_codex_boundary_result( + workspace=tmp_path, + diff_text=diff, + verification_replayable=False, + ) + replayable = _evaluate_codex_boundary_result( + workspace=tmp_path, + diff_text=diff, + verification_replayable=True, + ) + + assert detached.control.state == "human_review_required" + assert replayable.control.state == "agent_action_required" + assert detached.audit_id != replayable.audit_id + + # --- Coverage gap: check is boundary-only and must not green-light a ------- # capability change that only verify gates (the check/verify consistency fix). @@ -845,7 +885,11 @@ def test_no_manifest_capability_add_via_check_warns_and_routes_to_verify_preview ) assert result.decision == "warn" assert result.control.next_action.kind == "configure" - assert result.control.next_action.command.startswith("agents-shipgate verify --preview") + command = result.control.next_action.command + assert command is not None + assert str(tmp_path) in command + assert "shipgate.yaml" in command + assert "--preview" in command def test_capability_add_to_undeclared_surface_warns_when_manifest_declares_other( @@ -868,7 +912,9 @@ def test_capability_add_to_undeclared_surface_warns_when_manifest_declares_other ) assert result.decision == "warn" assert result.control.next_action.kind == "discover" - assert result.control.next_action.command == "shipgate detect --workspace . --json" + assert result.control.next_action.command == ( + f"shipgate detect --workspace {tmp_path} --json" + ) payload = result.model_dump(mode="json", exclude_none=True) assert any(d["code"] == "undeclared_capability_surface" for d in payload["diagnostics"]) assert "suggested_sources" in payload["control"]["next_action"]["why"] @@ -892,7 +938,9 @@ def test_mixed_declared_and_undeclared_via_check_routes_to_detect( ) assert result.decision == "warn" assert result.control.next_action.kind == "discover" - assert result.control.next_action.command == "shipgate detect --workspace . --json" + assert result.control.next_action.command == ( + f"shipgate detect --workspace {tmp_path} --json" + ) payload = result.model_dump(mode="json", exclude_none=True) diag = next(d for d in payload["diagnostics"] if d["code"] == "undeclared_capability_surface") assert "api/openapi.yaml" in diag["message"] @@ -935,7 +983,8 @@ def test_manifest_edit_is_not_an_undeclared_tool_surface(tmp_path: Path) -> None def test_prompts_edit_is_not_an_undeclared_tool_surface(tmp_path: Path) -> None: # Review finding P2: a prompts/ edit fires TRIGGER-PROMPTS-OR-POLICIES but - # is not a declarable tool source — no undeclared-surface warn / detect. + # is not a declarable tool source. It is nevertheless a trust-root change, + # so it stops for human review without an undeclared-surface/detect route. _write_manifest( tmp_path, " - id: mcp\n type: mcp\n path: mcp-tools.json\n trust: internal\n", @@ -955,7 +1004,17 @@ def test_prompts_edit_is_not_an_undeclared_tool_surface(tmp_path: Path) -> None: config=Path("shipgate.yaml"), policy=None, ) - assert result.decision == "allow" + assert result.decision == "require_review" + assert result.control.state == "agent_action_required" + assert result.control.next_action.kind == "verify" + assert [item.check_id for item in result.violated_rules] == [ + "SHIP-AGENT-BOUNDARY-PROTECTED-SURFACE-UNCLASSIFIED" + ] + payload = result.model_dump(mode="json", exclude_none=True) + assert not any( + diagnostic["code"] == "undeclared_capability_surface" + for diagnostic in payload.get("diagnostics", []) + ) def test_docs_file_mentioning_tool_decorator_is_not_undeclared_surface( diff --git a/tests/test_first_adoption.py b/tests/test_first_adoption.py index 6956d660..82c3b07a 100644 --- a/tests/test_first_adoption.py +++ b/tests/test_first_adoption.py @@ -19,6 +19,8 @@ import subprocess from pathlib import Path +import pytest + from agents_shipgate.checks import verify_policy from agents_shipgate.cli.verify.orchestrator import ( _manifest_introduced, @@ -28,6 +30,7 @@ from agents_shipgate.config.loader import load_manifest from agents_shipgate.core.context import ScanContext from agents_shipgate.core.domain import Agent +from agents_shipgate.core.errors import ConfigError from agents_shipgate.schemas.verification import VerificationContext from agents_shipgate.schemas.verifier import VerifierCapabilityReview @@ -81,6 +84,45 @@ def test_missing_manifest_base_with_no_manifest_anywhere_is_an_adoption(tmp_path ) +def test_unrelated_base_file_with_custom_basename_does_not_hide_adoption( + tmp_path: Path, +) -> None: + """A custom basename is not repository-wide manifest identity.""" + + repo = tmp_path / "repo" + (repo / "docs").mkdir(parents=True) + (repo / "docs" / "release.gate").write_text( + "ordinary release notes\n", + encoding="utf-8", + ) + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.test") + _git(repo, "config", "user.name", "Test User") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "ordinary same-basename file") + + manifest = repo / "config" / "release.gate" + manifest.parent.mkdir() + manifest.write_text( + 'version: "0.1"\nproject:\n name: demo\nagent:\n name: assistant\n', + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "adopt custom gate") + + assert ( + _manifest_introduced( + git_root=repo, + config_relative=Path("config/release.gate"), + base_status="missing_manifest", + base="HEAD~1", + head="HEAD", + changed_files=["config/release.gate"], + ) + is True + ) + + def test_moved_manifest_cannot_pass_itself_off_as_an_adoption(tmp_path): """The dodge this signal has to survive. @@ -175,6 +217,7 @@ def _scan_context(*, manifest_introduced: bool, changed=("shipgate.yaml",)): config_path=Path("shipgate.yaml"), verification=VerificationContext( changed_files=list(changed), + configured_manifest_path="shipgate.yaml", manifest_introduced=manifest_introduced, ), ) @@ -201,6 +244,24 @@ def test_adoption_fail_safe_still_needs_a_touched_policy_surface(): assert verify_policy.run(context) == [] +def test_policy_fail_safe_uses_exact_logical_manifest_identity(): + context = _scan_context( + manifest_introduced=False, + changed=("release.gate",), + ) + context.config_path = Path("/tmp/archive/config/release.gate") + context.verification.configured_manifest_path = "config/release.gate" + + assert verify_policy.run(context) == [] + + context.verification.changed_files = ["config/release.gate"] + findings = verify_policy.run(context) + assert len(findings) == 1 + assert findings[0].evidence["changed_policy_files"] == [ + "config/release.gate" + ] + + # --- headline and fix_task copy --------------------------------------------- @@ -213,34 +274,51 @@ def _review(*, policy_weakened=False, trust_root_touched=False): def test_adoption_headline_replaces_the_weakening_claim(): review = _review(trust_root_touched=True) - adopting = _self_approval_note(review, manifest_introduced=True) + adopting = _self_approval_note( + review, + manifest_introduced=True, + pure_adoption_review=True, + configured_manifest="config/release.gate", + ) modifying = _self_approval_note( _review(policy_weakened=True), manifest_introduced=False ) assert adopting is not None and modifying is not None assert "introduces Agents Shipgate" in adopting + assert "config/release.gate" in adopting + assert "shipgate.yaml" not in adopting + assert "agent-instruction" not in adopting + assert "CI files" not in adopting assert "human-reviewed PR" in adopting assert "weakens the release policy" in modifying -def test_adoption_raises_the_prohibition_on_its_own(): +def test_adoption_raises_the_prohibition_after_a_completed_scan(): """``_self_approval_note`` is the "a trust root is in play" probe. ``_can_merge_without_human`` raises on a passed decision that carries one. Since `policy_weakened` is now honestly `false` during an adoption, the - adoption has to raise the prohibition by itself — including when no other - flag is set and when there is no capability review at all. + adoption has to raise the prohibition by itself when a completed scan + supplies a capability review. Before that point the scan-failure route is + authoritative and adoption wording would be unsupported. """ for review in ( - None, _review(), _review(policy_weakened=True), _review(trust_root_touched=True), _review(policy_weakened=True, trust_root_touched=True), ): - assert _self_approval_note(review, manifest_introduced=True) is not None + assert ( + _self_approval_note( + review, + manifest_introduced=True, + pure_adoption_review=True, + ) + is not None + ) + assert _self_approval_note(None, manifest_introduced=True) is None def test_a_weakened_policy_beats_the_adoption_wording(): @@ -253,6 +331,7 @@ def test_a_weakened_policy_beats_the_adoption_wording(): mixed = _self_approval_note( _review(policy_weakened=True, trust_root_touched=True), manifest_introduced=True, + pure_adoption_review=True, ) assert mixed is not None assert "weakens the release policy" in mixed @@ -271,16 +350,17 @@ def test_non_adoption_wording_is_unchanged(): def test_adoption_reports_no_policy_weakening_to_machine_consumers(tmp_path): """The flag is read as a fact by the registry, attestations, and feedback. - A run whose headline says "introduces Agents Shipgate" while - `capability_review.policy_weakened` is true feeds that contradiction to - every downstream consumer — including feedback's gate-bypass alarm. + An adoption that has other blockers leads with those blockers, but its + machine-readable policy fact must still say no existing gate was weakened. """ repo = _repo_adopting_shipgate(tmp_path) verifier = _run_verify(repo, base="HEAD~1", head="HEAD") assert verifier.headline is not None - assert "introduces Agents Shipgate" in verifier.headline + assert verifier.headline.startswith("5 active finding(s) block release") + assert "also introduces the configured manifest" in verifier.headline + assert "then merge" not in verifier.headline assert verifier.capability_review.policy_weakened is False # The adoption is still visible as a trust-root touch, which is what keeps # reviewer routing and the gate-bypass alarm intact. @@ -325,7 +405,9 @@ def test_first_adoption_pr_is_honest_and_actionable(tmp_path): assert verifier.base_status == "missing_manifest" assert verifier.headline is not None - assert "introduces Agents Shipgate" in verifier.headline + assert verifier.headline.startswith("5 active finding(s) block release") + assert "also introduces the configured manifest" in verifier.headline + assert "then merge" not in verifier.headline # Fail-closed, unchanged: adoption is still a human decision. assert verifier.can_merge_without_human is False @@ -336,8 +418,18 @@ def test_first_adoption_pr_is_honest_and_actionable(tmp_path): fix_task = verifier.fix_task assert fix_task is not None assert fix_task.actor == "human" - assert [i for i in fix_task.instructions if "adopts Agents Shipgate" in i] - assert [r for r in fix_task.allowed_repairs if r.id == "adopt_shipgate_manifest"] + assert [ + i + for i in fix_task.instructions + if "also introduces the configured manifest" in i + ] + assert "merge" not in " ".join(fix_task.instructions).lower() + assert [ + r for r in fix_task.allowed_repairs if r.id == "review_shipgate_adoption" + ] + assert not [ + r for r in fix_task.allowed_repairs if r.id == "adopt_shipgate_manifest" + ] payload = json.loads( (repo / "agents-shipgate-reports" / "report.json").read_text("utf-8") @@ -351,6 +443,85 @@ def test_first_adoption_pr_is_honest_and_actionable(tmp_path): assert weakening[0]["evidence"]["kind"] == "manifest_introduced" +def test_clean_adoption_collapses_same_manifest_rows_into_one_human_act( + tmp_path: Path, +) -> None: + repo = tmp_path / "clean-repo" + shutil.copytree(REPO_ROOT / "samples" / "clean_read_only_agent", repo) + manifest = repo / "shipgate.yaml" + held_back = manifest.read_text(encoding="utf-8") + manifest.unlink() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.test") + _git(repo, "config", "user.name", "Test User") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "before shipgate") + manifest.write_text(held_back, encoding="utf-8") + _git(repo, "add", "shipgate.yaml") + _git(repo, "commit", "-m", "adopt shipgate") + + verifier, report, _exit = run_verify( + workspace=repo, + config=Path("shipgate.yaml"), + base="HEAD~1", + head="HEAD", + archive_head=True, + out=repo / "agents-shipgate-reports", + ci_mode="advisory", + fail_on=None, + baseline=None, + baseline_mode="new-findings", + diff_from=None, + policy_packs=None, + plugins_enabled=False, + strict_plugins=False, + suggest_patches=False, + no_heuristics=False, + verbose=False, + ) + + assert report is not None + assert report.release_decision is not None + assert report.release_decision.decision == "review_required" + assert verifier.headline is not None + assert "introduces Agents Shipgate" in verifier.headline + assert "then merge" in verifier.headline + assert verifier.fix_task is not None + assert "adopt_shipgate_manifest" in { + repair.id for repair in verifier.fix_task.allowed_repairs + } + assert not { + "review_shipgate_adoption", + "review_trust_root", + } & {repair.id for repair in verifier.fix_task.allowed_repairs} + + +def test_failed_head_scan_never_emits_adoption_or_merge_guidance(tmp_path): + repo = _repo_adopting_shipgate(tmp_path) + manifest = repo / SAMPLE_CONFIG + manifest.write_text("version: [\n", encoding="utf-8") + _git(repo, "add", SAMPLE_CONFIG.as_posix()) + _git(repo, "commit", "--amend", "--no-edit") + + with pytest.raises(ConfigError): + _run_verify(repo, base="HEAD~1", head="HEAD") + + payload = json.loads( + (repo / "agents-shipgate-reports" / "verifier.json").read_text("utf-8") + ) + assert payload["execution"] == "failed" + assert payload["headline"] == ( + "Shipgate could not complete the scan; human review required." + ) + assert "introduces Agents Shipgate" not in json.dumps(payload["control"]) + assert "then merge" not in json.dumps(payload["control"]) + assert payload["control"]["state"] == "human_review_required" + assert payload["control"]["verify_required"] is True + assert payload["control"]["next_action"]["kind"] == "stop" + assert payload["fix_task"]["actor"] == "human" + assert "re-run before merge" in " ".join(payload["fix_task"]["instructions"]) + + def test_adopted_repo_gets_the_ordinary_wording_back(tmp_path): repo = _repo_adopting_shipgate(tmp_path) manifest = repo / "samples" / "support_refund_agent" / "shipgate.yaml" @@ -433,6 +604,20 @@ def test_an_adoption_that_also_edits_a_policy_pack_keeps_the_strict_wording(): assert findings[0].evidence["kind"] == "base_snapshot_unavailable" +def test_adoption_finding_names_the_exact_configured_manifest(): + context = _scan_context(manifest_introduced=True, changed=("config/release.gate",)) + context.config_path = Path("/temporary/archive/config/release.gate") + assert context.verification is not None + context.verification.configured_manifest_path = "config/release.gate" + + findings = verify_policy.run(context) + + assert len(findings) == 1 + assert "'config/release.gate'" in findings[0].recommendation + assert "shipgate.yaml" not in findings[0].recommendation + assert "generated" not in findings[0].recommendation + + def test_a_custom_named_manifest_is_still_a_trust_root(tmp_path): """The gate is whatever file the run loaded as the gate. @@ -461,6 +646,15 @@ def test_a_custom_named_manifest_is_still_a_trust_root(tmp_path): assert verifier.capability_review.trust_root_touched is True assert verifier.can_merge_without_human is False assert verifier.control.state != "complete" + assert verifier.headline is not None + assert "samples/support_refund_agent/new-gate.yml" in verifier.headline + assert "generated shipgate.yaml" not in verifier.headline + assert "agent-instruction and CI files" not in verifier.headline + assert verifier.fix_task is not None + instructions = " ".join(verifier.fix_task.instructions) + assert "samples/support_refund_agent/new-gate.yml" in instructions + assert "generated shipgate.yaml" not in instructions + assert "agent-instruction and CI files" not in instructions def _run_verify_worktree(repo: Path, config: str): @@ -563,9 +757,10 @@ def test_custom_manifest_evidence_is_deterministic(tmp_path): def test_a_base_that_keeps_another_manifest_is_not_an_adoption(tmp_path): """Absence has to be established, not inferred from two basenames. - A base that retains an operational `old-gate.yml` deletes nothing and - matches no name check, so only a content probe separates it from a genuine - first adoption. + A base that retains an operational `old-gate.json` deletes nothing and + matches no name check. ``load_manifest`` accepts its YAML content despite + the suffix, so only a suffix-agnostic content probe separates it from a + genuine first adoption. """ repo = _repo_adopting_shipgate(tmp_path) @@ -574,11 +769,11 @@ def test_a_base_that_keeps_another_manifest_is_not_an_adoption(tmp_path): repo, "mv", "samples/support_refund_agent/shipgate.yaml", - "samples/support_refund_agent/old-gate.yml", + "samples/support_refund_agent/old-gate.json", ) _git(repo, "commit", "-m", "base keeps a custom manifest") (sample / "new-gate.yml").write_text( - (sample / "old-gate.yml").read_text("utf-8"), encoding="utf-8" + (sample / "old-gate.json").read_text("utf-8"), encoding="utf-8" ) _git(repo, "add", "-A") _git(repo, "commit", "-m", "add a second manifest without removing the first") diff --git a/tests/test_fix_task_contract.py b/tests/test_fix_task_contract.py index 736b8330..2bf57589 100644 --- a/tests/test_fix_task_contract.py +++ b/tests/test_fix_task_contract.py @@ -14,7 +14,11 @@ from pydantic import ValidationError from agents_shipgate.cli.verify.fix_task import build_fix_task -from agents_shipgate.cli.verify.orchestrator import _derive_verifier_control +from agents_shipgate.cli.verify.orchestrator import ( + _derive_verifier_control, + _verifier_headline, +) +from agents_shipgate.schemas.patches import AppendPointerPatch from agents_shipgate.schemas.report import ( BaselineDelta, EvidenceCoverageDecision, @@ -59,6 +63,23 @@ def _finding( ) +def _with_applicable_patch(finding: Finding) -> Finding: + """Make an autofix-safe finding genuinely selectable by apply-patches.""" + + finding.patches = [ + AppendPointerPatch( + target_file="/abs/shipgate.yaml", + pointer=f"/checks/{finding.id}", + value="owner", + target_format="yaml", + confidence="high", + rationale=f"Apply {finding.id}.", + target_sha256="abc123", + ) + ] + return finding + + def _item(finding: Finding) -> ReleaseDecisionItem: return ReleaseDecisionItem( id=finding.id, @@ -138,6 +159,7 @@ def _fix_task(report, *, capability_review=None, base_ref="origin/main", head_re capability_review=capability_review or _review(), base_ref=base_ref, head_ref=head_ref, + worktree=True, ) @@ -210,11 +232,13 @@ def test_semantic_gap_routes_human_with_structured_declaration_repair() -> None: def test_mechanical_review_routes_to_coding_agent() -> None: - f = _finding( - "F1", - requires_human_review=False, - autofix_safe=True, - recommendation="Add an owner field from CODEOWNERS.", + f = _with_applicable_patch( + _finding( + "F1", + requires_human_review=False, + autofix_safe=True, + recommendation="Add an owner field from CODEOWNERS.", + ) ) task = _fix_task(_report(decision="review_required", findings=[f], review_items=[f])) assert task is not None @@ -232,13 +256,15 @@ def test_authority_review_routes_to_human() -> None: def test_blocked_but_mechanical_routes_by_autofix_not_verdict() -> None: - # Routing is by the per-finding autofix_safe signal, not the verdict label. - f = _finding( - "F1", - requires_human_review=False, - autofix_safe=True, - severity="critical", - blocks_release=True, + # Routing is by an exact applicable patch, not the verdict label alone. + f = _with_applicable_patch( + _finding( + "F1", + requires_human_review=False, + autofix_safe=True, + severity="critical", + blocks_release=True, + ) ) task = _fix_task(_report(decision="blocked", findings=[f], blockers=[f])) assert task is not None @@ -246,7 +272,9 @@ def test_blocked_but_mechanical_routes_by_autofix_not_verdict() -> None: def test_policy_weakened_forces_human_even_when_mechanical() -> None: - f = _finding("F1", requires_human_review=False, autofix_safe=True) + f = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) task = _fix_task( _report(decision="review_required", findings=[f], review_items=[f]), capability_review=_review(policy_weakened=True), @@ -258,7 +286,9 @@ def test_policy_weakened_forces_human_even_when_mechanical() -> None: def test_trust_root_touched_forces_human_even_when_mechanical() -> None: - f = _finding("F1", requires_human_review=False, autofix_safe=True) + f = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) task = _fix_task( _report(decision="review_required", findings=[f], review_items=[f]), capability_review=_review(trust_root_touched=True), @@ -282,12 +312,14 @@ def test_degraded_evidence_under_review_required_forces_human() -> None: # — opening an auto-fix path on evidence too weak to gate. The fix_task must # still fail closed to a human because the evidence is below the IE # threshold (2 low-confidence tools of 2 → threshold 1). - f = _finding( - "F1", - requires_human_review=False, - autofix_safe=True, - severity="high", - recommendation="Add the missing scope bound.", + f = _with_applicable_patch( + _finding( + "F1", + requires_human_review=False, + autofix_safe=True, + severity="high", + recommendation="Add the missing scope bound.", + ) ) report = _report( decision="review_required", @@ -313,12 +345,14 @@ def test_review_required_with_full_evidence_stays_mechanical() -> None: # Counterpart guard: a mechanically-fixable high finding with HIGH-confidence # evidence (no gap) must still route to the coding agent — the escalation # fires on degraded evidence, not on severity. - f = _finding( - "F1", - requires_human_review=False, - autofix_safe=True, - severity="high", - recommendation="Add an owner field from CODEOWNERS.", + f = _with_applicable_patch( + _finding( + "F1", + requires_human_review=False, + autofix_safe=True, + severity="high", + recommendation="Add an owner field from CODEOWNERS.", + ) ) report = _report( decision="review_required", @@ -347,7 +381,7 @@ def test_forbidden_shortcuts_and_verification_command_present() -> None: ) assert task is not None assert task.verification_command == ( - "agents-shipgate verify --base origin/main --head HEAD --json" + "agents-shipgate verify --base origin/main --json" ) assert task.forbidden_shortcuts assert any("suppress" in shortcut for shortcut in task.forbidden_shortcuts) @@ -383,13 +417,11 @@ def test_human_allowed_repairs_reserve_terminal_verify_step() -> None: assert len(task.allowed_repairs) == 10 assert task.allowed_repairs[-1].id == "rerun_verify_after_human_action" assert task.allowed_repairs[-1].command == ( - "agents-shipgate verify --base origin/main --head HEAD --json" + "agents-shipgate verify --base origin/main --json" ) def test_mechanical_allowed_repairs_reserve_terminal_verify_step() -> None: - from agents_shipgate.schemas.patches import AppendPointerPatch - findings = [ _finding( f"F{i}", @@ -418,7 +450,7 @@ def test_mechanical_allowed_repairs_reserve_terminal_verify_step() -> None: assert len(task.allowed_repairs) == 10 assert task.allowed_repairs[-1].id == "rerun_verify" assert task.allowed_repairs[-1].command == ( - "agents-shipgate verify --base origin/main --head HEAD --json" + "agents-shipgate verify --base origin/main --json" ) @@ -426,7 +458,9 @@ def test_mechanical_allowed_repairs_reserve_terminal_verify_step() -> None: def test_first_next_action_actor_matches_fix_task() -> None: - mech = _finding("F1", requires_human_review=False, autofix_safe=True) + mech = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) mech_task = _fix_task(_report(decision="review_required", findings=[mech], review_items=[mech])) assert ( _control_for_task( @@ -540,9 +574,13 @@ def test_verification_command_quotes_shell_metacharacters() -> None: # ';' is a valid git ref character, so an unquoted command would be # injectable when an agent or human runs the suggested string. f = _finding("F1", requires_human_review=True, autofix_safe=False) - task = _fix_task( + task = build_fix_task( _report(decision="blocked", findings=[f], blockers=[f]), + merge_verdict="blocked", + capability_review=_review(), + base_ref="origin/main", head_ref="foo;rm -rf /", + worktree=False, ) assert task is not None assert task.verification_command is not None @@ -554,7 +592,9 @@ def test_verification_command_quotes_shell_metacharacters() -> None: def test_control_next_action_follows_agent_safe_fix_task() -> None: - mech = _finding("F1", requires_human_review=False, autofix_safe=True) + mech = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) task = _fix_task(_report(decision="review_required", findings=[mech], review_items=[mech])) control = _control_for_task( task, @@ -562,11 +602,14 @@ def test_control_next_action_follows_agent_safe_fix_task() -> None: ) action = control.next_action assert action.actor == "coding_agent" - assert action.command == task.verification_command + assert action.command == task.allowed_repairs[0].command + assert action.command != task.verification_command def test_control_does_not_substitute_summary_commands_for_fix_task_contract() -> None: - mech = _finding("F1", requires_human_review=False, autofix_safe=True) + mech = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) task = _fix_task(_report(decision="review_required", findings=[mech], review_items=[mech])) control = _control_for_task( task, @@ -574,11 +617,12 @@ def test_control_does_not_substitute_summary_commands_for_fix_task_contract() -> ) action = control.next_action assert action.actor == "coding_agent" - assert action.command == task.verification_command + assert action.command == task.allowed_repairs[0].command + assert "--finding-id F1 --confidence high --apply" in (action.command or "") def test_mechanical_task_projects_machine_patches() -> None: - from agents_shipgate.schemas.patches import AppendPointerPatch, ManualPatch + from agents_shipgate.schemas.patches import ManualPatch f = _finding("F1", requires_human_review=False, autofix_safe=True) f.patches = [ @@ -641,7 +685,7 @@ def test_human_task_carries_no_patches() -> None: assert task.patches == [] -def test_fix_task_without_suggest_patches_has_empty_patches() -> None: +def test_autofix_flag_without_an_applicable_patch_fails_closed_to_human() -> None: f = _finding("F1", requires_human_review=False, autofix_safe=True) report = _report(decision="review_required", findings=[f], review_items=[f]) @@ -654,11 +698,52 @@ def test_fix_task_without_suggest_patches_has_empty_patches() -> None: ) assert task is not None + assert task.actor == "human" + assert task.safe_to_attempt is False assert task.patches == [] + assert not any( + repair.kind == "apply_high_confidence_patch" + for repair in task.allowed_repairs + ) + + +def test_ref_bound_mechanical_finding_routes_human_without_apply_repair() -> None: + finding = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) + report = _report( + decision="review_required", + findings=[finding], + review_items=[finding], + ) + + task = build_fix_task( + report, + merge_verdict="human_review_required", + capability_review=_review(), + base_ref="origin/main", + head_ref="HEAD", + worktree=False, + repair_subject_available=False, + ) + + assert task is not None + assert task.actor == "human" + assert task.safe_to_attempt is False + assert task.patches == [] + assert task.verification_command == ( + "agents-shipgate verify --base origin/main --head HEAD --json" + ) + assert not any( + repair.kind == "apply_high_confidence_patch" + for repair in task.allowed_repairs + ) def test_insufficient_evidence_names_low_confidence_sources() -> None: - f = _finding("F1", requires_human_review=False, autofix_safe=True) + f = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) report = _report(decision="insufficient_evidence", findings=[f], review_items=[f]) report.tool_inventory = [ { @@ -700,7 +785,9 @@ def test_insufficient_evidence_names_low_confidence_sources() -> None: def test_insufficient_evidence_without_inventory_gives_generic_remedy() -> None: - f = _finding("F1", requires_human_review=False, autofix_safe=True) + f = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) report = _report(decision="insufficient_evidence", findings=[f], review_items=[f]) task = build_fix_task( @@ -720,14 +807,20 @@ def test_insufficient_evidence_without_inventory_gives_generic_remedy() -> None: # --- first adoption: the same routing, honest wording ----------------------- -def _trust_root_report(): +def _trust_root_report(*, adoption: bool = False, path: str = "shipgate.yaml"): f = _finding("F1", requires_human_review=True, autofix_safe=False) + if adoption: + f.check_id = "SHIP-VERIFY-POLICY-WEAKENED" + f.evidence = { + "kind": "manifest_introduced", + "changed_policy_files": [path], + } return _report(decision="review_required", findings=[f], review_items=[f]) def test_manifest_modification_keeps_the_weakening_wording() -> None: task = build_fix_task( - _trust_root_report(), + _trust_root_report(adoption=True), merge_verdict="human_review_required", capability_review=_review(policy_weakened=True, trust_root_touched=True), base_ref="origin/main", @@ -748,7 +841,7 @@ def test_first_adoption_replaces_the_weakening_wording() -> None: """Adoption is not weakening: one honest instruction, not two wrong ones.""" task = build_fix_task( - _trust_root_report(), + _trust_root_report(adoption=True), merge_verdict="human_review_required", capability_review=_review(trust_root_touched=True), base_ref="origin/main", @@ -766,6 +859,45 @@ def test_first_adoption_replaces_the_weakening_wording() -> None: assert "adopt_shipgate_manifest" in repair_ids assert not {"review_policy_weakening", "review_trust_root"} & repair_ids + report = _trust_root_report(adoption=True) + headline = _verifier_headline( + report=report, + merge_verdict="human_review_required", + head_status="succeeded", + capability_review=_review(trust_root_touched=True), + manifest_introduced=True, + pure_adoption_review=True, + configured_manifest="shipgate.yaml", + ) + assert headline is not None + assert "introduces Agents Shipgate" in headline + assert "then merge" in headline + + +def test_first_adoption_names_only_the_configured_manifest() -> None: + task = build_fix_task( + _trust_root_report(adoption=True, path="config/release.gate"), + merge_verdict="human_review_required", + capability_review=_review(trust_root_touched=True), + base_ref="origin/main", + head_ref="HEAD", + manifest_introduced=True, + config="config/release.gate", + ) + + assert task is not None + joined = " ".join(task.instructions) + assert "config/release.gate" in joined + assert "generated shipgate.yaml" not in joined + assert "agent-instruction" not in joined + assert "CI files" not in joined + adoption = next( + repair + for repair in task.allowed_repairs + if repair.id == "adopt_shipgate_manifest" + ) + assert adoption.target == "config/release.gate" + def test_an_adoption_that_also_weakens_policy_keeps_the_weakening_repair() -> None: """`review_policy_weakening` must not vanish behind adoption wording.""" @@ -795,7 +927,9 @@ def test_adoption_escalates_without_borrowing_another_flag() -> None: coding-agent auto-fix path. """ - f = _finding("F1", requires_human_review=False, autofix_safe=True) + f = _with_applicable_patch( + _finding("F1", requires_human_review=False, autofix_safe=True) + ) report = _report(decision="review_required", findings=[f], review_items=[f]) task = build_fix_task( @@ -809,4 +943,57 @@ def test_adoption_escalates_without_borrowing_another_flag() -> None: assert task is not None assert task.actor == "human" and task.safe_to_attempt is False - assert "adopts Agents Shipgate" in " ".join(task.instructions) + assert "adopts Agents Shipgate" not in " ".join(task.instructions) + assert "adopt_shipgate_manifest" not in {r.id for r in task.allowed_repairs} + + +@pytest.mark.parametrize( + ("decision", "merge_verdict"), + [ + ("blocked", "blocked"), + ("insufficient_evidence", "insufficient_evidence"), + ], +) +def test_non_mergeable_adoption_leads_with_the_real_stop_condition( + decision: str, + merge_verdict: str, +) -> None: + adoption = _finding("adoption", requires_human_review=True, autofix_safe=False) + adoption.check_id = "SHIP-VERIFY-POLICY-WEAKENED" + adoption.evidence = {"kind": "manifest_introduced"} + other = _finding("other", requires_human_review=True, autofix_safe=False) + report = _report( + decision=decision, + findings=[adoption, other], + blockers=[other] if decision == "blocked" else [], + review_items=[adoption, other], + ) + + task = build_fix_task( + report, + merge_verdict=merge_verdict, # type: ignore[arg-type] + capability_review=_review(trust_root_touched=True), + base_ref="origin/main", + head_ref="HEAD", + manifest_introduced=True, + config="config/release.gate", + ) + + assert task is not None and task.actor == "human" + assert task.instructions[0] == report.release_decision.reason + assert "merge" not in " ".join(task.instructions).lower() + assert "adopt_shipgate_manifest" not in {r.id for r in task.allowed_repairs} + + headline = _verifier_headline( + report=report, + merge_verdict=merge_verdict, # type: ignore[arg-type] + head_status="succeeded", + capability_review=_review(trust_root_touched=True), + manifest_introduced=True, + pure_adoption_review=False, + configured_manifest="config/release.gate", + ) + assert headline is not None + assert headline.startswith(report.release_decision.reason) + assert "then merge" not in headline.lower() + assert "separate human-review decision" in headline diff --git a/tests/test_host_audit.py b/tests/test_host_audit.py index b2a654bd..9fd80706 100644 --- a/tests/test_host_audit.py +++ b/tests/test_host_audit.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import os +import shlex from pathlib import Path import pytest @@ -8,8 +10,12 @@ from pydantic import ValidationError from typer.testing import CliRunner +import agents_shipgate.cli.host_audit as host_audit_cli +import agents_shipgate.core.trust_roots as trust_roots_module from agents_shipgate.cli.host_audit import ( + HOST_GRANTS_SCHEMA_VERSION, _atomic_write_baseline, + _refuse_invalid_baseline_overwrite, host_audit_inventory, host_grants_sha256, render_host_audit_markdown, @@ -21,6 +27,7 @@ build_host_boundary_snapshot, build_host_drift_payload, build_host_grants_baseline, + load_host_grants_baseline, ) from agents_shipgate.schemas.host_grants import ( HostGrantsBaselineV2, @@ -32,6 +39,17 @@ runner = CliRunner() +def _agent_mode_error(output: str) -> dict: + for line in reversed(output.splitlines()): + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict) and "error" in payload: + return payload + raise AssertionError(f"No structured agent-mode error in output:\n{output}") + + def _seed_workspace(tmp_path: Path) -> Path: (tmp_path / ".mcp.json").write_text( json.dumps( @@ -398,6 +416,36 @@ def test_invalid_config_is_structured_partial_coverage_and_cannot_baseline(tmp_p assert "cannot acknowledge missing evidence" in result.output +def test_incomplete_inventory_save_routes_to_coverage_review_without_rerun( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + (tmp_path / ".mcp.json").write_text("{not-json", encoding="utf-8") + + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(tmp_path), + "--save-baseline", + "--json", + ], + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result.output) + action = payload["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert "claude-code=partial" in action["why"] + assert "claude-code:.mcp.json" in action["why"] + assert "--save-baseline" not in result.output + assert not (tmp_path / ".agents-shipgate/host-grants.json").exists() + + def test_repository_intermediate_symlink_is_rejected_without_secret_leak(tmp_path: Path) -> None: outside = tmp_path.parent / f"{tmp_path.name}-outside" outside.mkdir() @@ -416,6 +464,34 @@ def test_repository_intermediate_symlink_is_rejected_without_secret_leak(tmp_pat assert coverage["status"] == "partial" +def test_repository_globstar_boundary_fails_closed_on_symlink_ancestor( + tmp_path: Path, +) -> None: + outside = tmp_path.parent / f"{tmp_path.name}-outside-globstar" + outside.mkdir() + secret = "OUTSIDE-INSTRUCTION-TOP-SECRET" + (outside / "CLAUDE.md").write_text(secret, encoding="utf-8") + (tmp_path / "vendor").symlink_to(outside, target_is_directory=True) + + inventory = host_audit_inventory(tmp_path) + + assert secret not in json.dumps(inventory) + issues = [ + item + for item in inventory["issues"] + if item["source"] == "vendor" and item["host"] == "claude-code" + ] + assert issues + assert all(item["kind"] == "unreadable" for item in issues) + assert all(item["blocking"] is True for item in issues) + coverage = next( + item + for item in inventory["host_coverage"] + if item["host"] == "claude-code" + ) + assert coverage["status"] == "partial" + + def test_vscode_present_is_experimental_and_cannot_baseline(tmp_path: Path) -> None: vscode = tmp_path / ".vscode" vscode.mkdir() @@ -507,6 +583,152 @@ def test_shared_snapshot_reads_and_parses_each_file_once(tmp_path: Path) -> None assert cache.parse_counts == before_parses +def test_host_static_entry_budget_is_aggregate_across_inventory_and_reads( + tmp_path: Path, +) -> None: + (tmp_path / "AGENTS.md").write_text("Use least privilege.\n", encoding="utf-8") + cache = HostStaticParseCache( + max_entries=1, + max_total_bytes=1024 * 1024, + ) + + snapshot = build_host_boundary_snapshot(tmp_path, cache=cache) + + assert cache.resource_bound_error is not None + assert any( + issue["kind"] == "unreadable" + and "aggregate" in issue["message"] + and issue["blocking"] + for issue in snapshot.inventory["issues"] + ) + assert all( + coverage["status"] != "complete" + for coverage in snapshot.inventory["host_coverage"] + ) + + +def test_host_static_byte_budget_is_aggregate_across_unique_sources( + tmp_path: Path, +) -> None: + agents = tmp_path / "AGENTS.md" + claude = tmp_path / "CLAUDE.md" + agents.write_text("A" * 40, encoding="utf-8") + claude.write_text("C" * 40, encoding="utf-8") + cache = HostStaticParseCache( + max_entries=10_000, + max_total_bytes=40, + ) + + snapshot = build_host_boundary_snapshot(tmp_path, cache=cache) + + assert cache.resource_bound_error is not None + assert any( + issue["kind"] == "unreadable" + and "aggregate" in issue["message"] + and issue["blocking"] + for issue in snapshot.inventory["issues"] + ) + assert all( + coverage["status"] != "complete" + for coverage in snapshot.inventory["host_coverage"] + ) + + +def test_host_static_reader_scans_each_source_directory_constant_times( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + rules = tmp_path / ".cursor" / "rules" + rules.mkdir(parents=True) + for index in range(50): + (rules / f"rule-{index:02d}.mdc").write_text( + f"rule {index}\n", + encoding="utf-8", + ) + real_scandir = os.scandir + calls: dict[str, int] = {} + + def counting_scandir(path: os.PathLike[str] | str | int): + if not isinstance(path, int): + key = os.path.abspath(os.fspath(path)) + calls[key] = calls.get(key, 0) + 1 + return real_scandir(path) + + monkeypatch.setattr(trust_roots_module.os, "scandir", counting_scandir) + + inventory = host_audit_inventory(tmp_path) + + assert not inventory["issues"] + assert calls[os.path.abspath(os.fspath(rules))] == 2 + + +def test_bounded_host_static_reader_keeps_hardlinks_fail_closed( + tmp_path: Path, +) -> None: + claude = tmp_path / ".claude" + claude.mkdir() + settings = claude / "settings.json" + settings.write_text("{}", encoding="utf-8") + alias = tmp_path / "settings-alias.json" + alias.hardlink_to(settings) + + inventory = host_audit_inventory(tmp_path) + + issue = next( + item + for item in inventory["issues"] + if item["source"] == ".claude/settings.json" + ) + assert issue["kind"] == "unreadable" + assert "singly-linked" in issue["message"] + assert issue["blocking"] is True + assert all( + grant["source"] != ".claude/settings.json" + for grant in inventory["grants"] + ) + + +def test_host_inventory_rejects_protected_addition_after_discovery( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + docs = tmp_path / "docs" + docs.mkdir() + added = docs / "AGENTS.md" + real_finish = trust_roots_module.IdentityBoundReadSession.finish + injected = False + + def finish_after_addition( + reader: trust_roots_module.IdentityBoundReadSession, + ) -> None: + nonlocal injected + if reader.root == tmp_path and not injected: + injected = True + added.write_text("late host instruction\n", encoding="utf-8") + real_finish(reader) + + monkeypatch.setattr( + trust_roots_module.IdentityBoundReadSession, + "finish", + finish_after_addition, + ) + + inventory = host_audit_inventory(tmp_path) + + assert inventory["artifacts"] == [] + assert inventory["grants"] == [] + assert any( + issue["kind"] == "unreadable" + and "changed identity" in issue["message"] + and issue["blocking"] + for issue in inventory["issues"] + ) + assert all( + coverage["status"] == "partial" + for coverage in inventory["host_coverage"] + ) + + def test_snapshot_projection_rejects_scope_or_workspace_mismatch(tmp_path: Path) -> None: snapshot = build_host_boundary_snapshot(tmp_path) with pytest.raises(ValueError, match="scope"): @@ -623,6 +845,35 @@ def test_save_refuses_hardlink_and_preserves_external_target(tmp_path: Path) -> assert external.stat().st_nlink == 2 +def test_baseline_read_rejects_a_symlink_swap_before_open( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "source" + source.mkdir() + baseline = _save_baseline(source) + external_root = tmp_path / "external" + external_root.mkdir() + external = _save_baseline(external_root) + real_open = os.open + swapped = False + + def swapping_open(path, flags, *args, **kwargs): + nonlocal swapped + if Path(path) == baseline and not swapped: + swapped = True + baseline.unlink() + baseline.symlink_to(external) + return real_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(os, "open", swapping_open) + + with pytest.raises(ValueError, match="readable host-grants baseline"): + load_host_grants_baseline(baseline) + assert swapped is True + assert baseline.is_symlink() + + def test_save_allows_explicit_absolute_regular_target_but_not_relative_escape( tmp_path: Path, ) -> None: @@ -970,7 +1221,12 @@ def test_legacy_v01_baseline_is_incomparable_advisory_and_strict_20(tmp_path: Pa strict = runner.invoke( app, [ - "audit", "--host", "--workspace", str(tmp_path), "--drift", "--json", + "audit", + "--host", + "--workspace", + str(tmp_path), + "--drift", + "--json", "--fail-on-drift", ], ) @@ -1034,12 +1290,7 @@ def test_malformed_nested_v02_baseline_is_incomparable_not_a_crash(tmp_path: Pat strict = runner.invoke( app, [ - "audit", - "--host", - "--workspace", - str(tmp_path), - "--drift", - "--json", + "audit", "--host", "--workspace", str(tmp_path), "--drift", "--json", "--fail-on-drift", ], ) @@ -1110,6 +1361,253 @@ def test_invalid_flag_combinations_are_usage_errors(tmp_path: Path) -> None: assert no_drift.exit_code == 2 +def test_missing_host_recovery_preserves_the_complete_quoted_request( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "workspace with spaces" + workspace.mkdir() + baseline = workspace / "reviewed grants; baseline.json" + out = workspace / "reports with spaces" / "saved.json" + + result = runner.invoke( + app, + [ + "audit", + "--workspace", + str(workspace), + "--scope", + "local-static", + "--save-baseline", + "--baseline-file", + str(baseline), + "--json", + "--out", + str(out), + ], + ) + + assert result.exit_code == 2 + action = _agent_mode_error(result.output)["next_actions"][0] + assert action["kind"] == "command" + assert shlex.split(action["command"]) == [ + "agents-shipgate", + "audit", + "--host", + "--workspace", + str(workspace), + "--scope", + "local-static", + "--save-baseline", + "--baseline-file", + str(baseline), + "--json", + "--out", + str(out), + ] + + +def test_invalid_scope_with_full_custom_request_requires_a_choice( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "custom workspace" + baseline = workspace / "custom baseline.json" + out = workspace / "custom output.json" + + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(workspace), + "--scope", + "repo-or-local", + "--save-baseline", + "--baseline-file", + str(baseline), + "--json", + "--out", + str(out), + ], + ) + + assert result.exit_code == 2 + payload = _agent_mode_error(result.output) + action = payload["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert "agents-shipgate audit --host" not in payload["next_action"] + + +def test_save_and_drift_conflict_never_authorizes_a_different_request( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "custom workspace" + baseline = workspace / "custom baseline.json" + out = workspace / "custom output.json" + + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(workspace), + "--scope", + "local-static", + "--save-baseline", + "--drift", + "--baseline-file", + str(baseline), + "--fail-on-drift", + "--json", + "--out", + str(out), + ], + ) + + assert result.exit_code == 2 + action = _agent_mode_error(result.output)["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + + +def test_missing_baseline_with_output_still_requires_human_acknowledgement( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "custom workspace" + workspace.mkdir() + baseline = workspace / "missing baseline.json" + out = workspace / "custom output.json" + + result = runner.invoke( + app, + [ + "audit", + "--host", + "--workspace", + str(workspace), + "--scope", + "local-static", + "--drift", + "--baseline-file", + str(baseline), + "--fail-on-drift", + "--json", + "--out", + str(out), + ], + ) + + assert result.exit_code == 2 + action = _agent_mode_error(result.output)["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert str(workspace) in action["why"] + assert str(baseline) in action["why"] + assert "local-static" in action["why"] + assert "--save-baseline" not in result.output + assert not out.exists() + + +def test_baseline_lstat_failure_is_an_io_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + baseline = tmp_path / "baseline.json" + original_lstat = Path.lstat + + def denied_lstat(path: Path) -> object: + if path == baseline: + raise PermissionError("inspection denied") + return original_lstat(path) + + monkeypatch.setattr(Path, "lstat", denied_lstat) + + with pytest.raises(typer.Exit) as raised: + _refuse_invalid_baseline_overwrite(baseline) + + assert raised.value.exit_code == 4 + payload = _agent_mode_error(capsys.readouterr().err) + assert payload["error"] == "other_error" + assert payload["exit_code"] == 4 + + +def test_baseline_read_failure_is_an_io_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + baseline = tmp_path / "baseline.json" + baseline.write_text("{}", encoding="utf-8") + + def denied_read(_path: Path) -> tuple[dict, str]: + try: + raise PermissionError("read denied") + except PermissionError as cause: + raise ValueError("could not read baseline") from cause + + monkeypatch.setattr( + host_audit_cli, + "load_host_grants_baseline_with_text", + denied_read, + ) + + with pytest.raises(typer.Exit) as raised: + _refuse_invalid_baseline_overwrite(baseline) + + assert raised.value.exit_code == 4 + payload = _agent_mode_error(capsys.readouterr().err) + assert payload["error"] == "other_error" + assert payload["exit_code"] == 4 + + +def test_baseline_overwrite_reuses_descriptor_bound_text_without_path_reread( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + baseline = tmp_path / "baseline.json" + baseline.write_text("{}", encoding="utf-8") + original_read_text = Path.read_text + descriptor_text = '{"host_grants_schema_version":"0.2"}\n' + + monkeypatch.setattr( + host_audit_cli, + "load_host_grants_baseline_with_text", + lambda _path: ( + {"host_grants_schema_version": HOST_GRANTS_SCHEMA_VERSION}, + descriptor_text, + ), + ) + + def denied_read_text( + path: Path, + *args: object, + **kwargs: object, + ) -> str: + if path == baseline: + raise PermissionError("post-validation read denied") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", denied_read_text) + + state = _refuse_invalid_baseline_overwrite(baseline) + + assert state is not None + assert state[1] == descriptor_text + + def test_generated_models_reject_unknown_fields_and_invalid_literals(tmp_path: Path) -> None: payload = host_audit_inventory(tmp_path) with pytest.raises(ValidationError): diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index a70a3dad..c74734fb 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -1,10 +1,12 @@ from __future__ import annotations +import io import json import os import subprocess import sys from pathlib import Path +from types import SimpleNamespace from typer.testing import CliRunner @@ -135,6 +137,25 @@ def test_install_hooks_rejects_unknown_target(tmp_path: Path) -> None: assert "Unsupported hook target" in result.output +def test_install_hooks_rejects_head_without_base(tmp_path: Path) -> None: + result = runner.invoke( + app, + [ + "install-hooks", + "--workspace", + str(tmp_path), + "--base", + "", + "--head", + "HEAD", + "--json", + ], + ) + + assert result.exit_code == 2 + assert "--head requires --base" in result.output + + def test_generated_post_tool_hook_emits_trigger_context(tmp_path: Path) -> None: render_or_install_hooks( workspace=tmp_path, @@ -179,7 +200,9 @@ def test_generated_post_tool_hook_emits_trigger_context(tmp_path: Path) -> None: assert "Do not bypass the verifier" in context -def test_generated_post_tool_hook_ignores_irrelevant_docs_edit(tmp_path: Path) -> None: +def test_generated_post_tool_hook_evaluates_relevance_without_manifest_force_run( + tmp_path: Path, +) -> None: render_or_install_hooks( workspace=tmp_path, target="claude-code", @@ -217,6 +240,9 @@ def test_generated_post_tool_hook_ignores_irrelevant_docs_edit(tmp_path: Path) - ) assert result.returncode == 0, result.stderr + # A configured repository would force every PR through Shipgate, but the + # edit-time hook deliberately passes manifest_present=false so an ordinary + # docs edit remains quiet rather than becoming a force-run nudge. assert result.stdout == "" @@ -230,13 +256,16 @@ def test_generated_post_tool_hook_matches_untracked_diff_tokens(tmp_path: Path) head="", ci_mode="advisory", ) - (tmp_path / "shipgate.yaml").write_text("version: '0.1'\n", encoding="utf-8") + _init_repo(tmp_path) agent = tmp_path / "agent.py" agent.write_text( "from agents import function_tool\n\n@function_tool\ndef lookup() -> str:\n" " return ''\n", encoding="utf-8", ) + hook_namespace = _rendered_hook_namespace(tmp_path) + git_diff_for_paths = hook_namespace["_git_diff_for_paths"] + assert "@function_tool" in git_diff_for_paths(tmp_path, ["agent.py"]) event = { "hook_event_name": "PostToolUse", "cwd": str(tmp_path), @@ -264,10 +293,41 @@ def test_generated_post_tool_hook_matches_untracked_diff_tokens(tmp_path: Path) assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) - assert "Agents Shipgate trigger matched" in ( - payload["hookSpecificOutput"]["additionalContext"] + context = payload["hookSpecificOutput"]["additionalContext"] + assert "Agents Shipgate trigger matched" in context + + +def test_post_tool_hook_treats_custom_manifest_as_relevant_without_catalog_match( + tmp_path: Path, + capsys, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + custom = tmp_path / "config" / "release.gate" + custom.parent.mkdir() + custom.write_text("version: '0.1'\n", encoding="utf-8") + namespace["_git_diff_for_paths"] = lambda *_args, **_kwargs: "diff" + namespace["_run_trigger_for_paths"] = lambda *_args, **_kwargs: { + "should_run": False + } + args = SimpleNamespace( + config="config/release.gate", + base="HEAD", + head="HEAD", + ci_mode="advisory", ) + result = namespace["_trigger"]( + {"tool_input": {"file_path": str(custom)}}, + tmp_path, + args, + ) + + assert result == 0 + payload = json.loads(capsys.readouterr().out) + context = payload["hookSpecificOutput"]["additionalContext"] + assert "configured protected surface changed" in context.lower() + assert "--head" not in context + def test_generated_stop_hook_advisory_uses_system_message(tmp_path: Path) -> None: render_or_install_hooks( @@ -300,10 +360,12 @@ def test_generated_stop_hook_advisory_uses_system_message(tmp_path: Path) -> Non payload = json.loads(result.stdout) assert "systemMessage" in payload assert "hookSpecificOutput" not in payload - assert "could not evaluate the local trigger" in payload["systemMessage"] + assert "verify could not start" in payload["systemMessage"] -def test_generated_stop_hook_skips_clean_opted_in_repo(tmp_path: Path) -> None: +def test_generated_stop_hook_warns_when_clean_repo_base_is_unavailable( + tmp_path: Path, +) -> None: render_or_install_hooks( workspace=tmp_path, target="claude-code", @@ -331,7 +393,10 @@ def test_generated_stop_hook_skips_clean_opted_in_repo(tmp_path: Path) -> None: ) assert result.returncode == 0, result.stderr - assert result.stdout == "" + payload = json.loads(result.stdout) + assert "decision" not in payload + assert "configured base ref is unavailable" in payload["systemMessage"] + assert "Fetch the base ref" in payload["systemMessage"] assert not log.exists() @@ -386,6 +451,148 @@ def test_generated_stop_hook_verifies_worktree_once_without_head(tmp_path: Path) assert "--no-manifest-present" in trigger_entries[0] +def test_generated_stop_hook_omits_configured_head_for_worktree_snapshot( + tmp_path: Path, +) -> None: + render_or_install_hooks( + workspace=tmp_path, + target="claude-code", + write=True, + config=Path("shipgate.yaml"), + base="HEAD", + head="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", + ) + 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}" + env["AGENTS_SHIPGATE_VERIFY_BASE"] = "HEAD" + env["AGENTS_SHIPGATE_VERIFY_HEAD"] = "HEAD" + + result = subprocess.run( + [sys.executable, str(tmp_path / HOOK_SCRIPT_RELATIVE_PATH), "verify"], + input=json.dumps({"cwd": str(tmp_path)}), + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + check=False, + ) + + assert result.returncode == 0, result.stderr + entries = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()] + verify_args = next(entry for entry in entries if entry[0] == "verify") + assert verify_args[verify_args.index("--base") + 1] == "HEAD" + assert "--head" not in verify_args + + +def test_generated_stop_hook_warns_on_index_hidden_worktree_path( + tmp_path: Path, +) -> None: + render_or_install_hooks( + workspace=tmp_path, + target="claude-code", + write=True, + config=Path("shipgate.yaml"), + base="HEAD", + head="", + ci_mode="advisory", + ) + hidden = tmp_path / "inventory.surface" + hidden.write_text("safe\n", encoding="utf-8") + _init_repo(tmp_path) + subprocess.run( + ["git", "update-index", "--assume-unchanged", hidden.name], + cwd=tmp_path, + check=True, + ) + hidden.write_text("expanded authority\n", encoding="utf-8") + 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}" + + result = subprocess.run( + [sys.executable, str(tmp_path / HOOK_SCRIPT_RELATIVE_PATH), "verify"], + input=json.dumps({"cwd": str(tmp_path)}), + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + check=False, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert "could not collect a bounded, static worktree snapshot" in payload[ + "systemMessage" + ] + assert not log.exists() + + +def test_generated_stop_hook_binds_ignored_custom_manifest( + tmp_path: Path, +) -> None: + render_or_install_hooks( + workspace=tmp_path, + target="claude-code", + write=True, + config=Path("config/release.gate"), + base="HEAD", + head="", + ci_mode="advisory", + ) + _init_repo(tmp_path) + with (tmp_path / ".gitignore").open("a", encoding="utf-8") as handle: + handle.write("config/release.gate\n") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "commit", "-m", "ignore custom gate"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + custom = tmp_path / "config" / "release.gate" + custom.parent.mkdir() + custom.write_text("version: '0.1'\n", encoding="utf-8") + 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}" + + result = subprocess.run( + [ + sys.executable, + str(tmp_path / HOOK_SCRIPT_RELATIVE_PATH), + "verify", + "--config", + "config/release.gate", + "--base", + "HEAD", + ], + input=json.dumps({"cwd": str(tmp_path)}), + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + check=False, + ) + + assert result.returncode == 0, result.stderr + entries = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()] + assert any(entry[0] == "verify" for entry in entries) + + def _run_stop_hook( tmp_path: Path, *, @@ -437,6 +644,7 @@ def test_stop_hook_blocks_only_for_agent_action_required(tmp_path: Path) -> None "state": "agent_action_required", "reason": "verify pending", "next_action": {"kind": "verify", "command": "agents-shipgate verify --json"}, + "allowed_next_commands": ["agents-shipgate verify --json"], }, } ) @@ -483,6 +691,40 @@ def test_stop_hook_hands_off_instead_of_blocking_on_human_review(tmp_path: Path) assert rerun.stdout == "" +def test_stop_hook_surfaces_fetch_base_without_inventing_a_command( + tmp_path: Path, +) -> None: + _stop_hook_workspace(tmp_path) + payload = json.dumps( + { + "release_decision": { + "decision": "unknown", + "blockers": [], + "review_items": [], + }, + "control": { + "state": "agent_action_required", + "reason": "base ref is missing", + "allowed_next_commands": [], + "next_action": { + "kind": "fetch_base", + "expects": "origin/main", + "why": "Make the base ref available locally.", + }, + }, + } + ) + + 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 "origin/main" in out["reason"] + assert "No executable command was authorized" in out["reason"] + assert "agents-shipgate verify" not in out["reason"] + + def test_stop_hook_warns_and_never_caches_unparseable_verifier_output( tmp_path: Path, ) -> None: @@ -533,8 +775,13 @@ def test_stop_hook_cold_start_advises_instead_of_blocking(tmp_path: Path) -> Non 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 "configured manifest 'shipgate.yaml' does not exist" in out["systemMessage"] assert "verify --preview" in out["systemMessage"] + log = tmp_path.parent / f"{tmp_path.name}-cli.log" + entries = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()] + assert len(entries) == 1 + assert entries[0][0] == "trigger" + assert "--no-manifest-present" in entries[0] def _pretooluse_out( @@ -787,6 +1034,165 @@ def _render_hook_script(tmp_path: Path) -> Path: return tmp_path / HOOK_SCRIPT_RELATIVE_PATH +def _rendered_hook_namespace(tmp_path: Path) -> dict[str, object]: + script = _render_hook_script(tmp_path) + namespace: dict[str, object] = {"__name__": "hook_under_test"} + code = script.read_text(encoding="utf-8") + exec(compile(code, str(script), "exec"), namespace) + return namespace + + +def test_rendered_bounded_git_runner_fails_closed_on_output_overflow( + tmp_path: Path, + monkeypatch, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + + class FakeProcess: + def __init__(self) -> None: + self.stdout = io.BytesIO(b"overflow") + self.killed = False + + def wait(self, timeout=None) -> int: + return 0 + + def kill(self) -> None: + self.killed = True + + process = FakeProcess() + subprocess_module = namespace["subprocess"] + monkeypatch.setattr(subprocess_module, "Popen", lambda *args, **kwargs: process) + + run_git_bounded = namespace["_run_git_bounded"] + assert run_git_bounded(tmp_path, ["status"], limit=4) is None + assert process.killed is True + + +def test_rendered_bounded_git_runner_fails_closed_on_timeout( + tmp_path: Path, + monkeypatch, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + + class FakeProcess: + def __init__(self) -> None: + self.stdout = io.BytesIO() + self.killed = False + self.wait_calls = 0 + + def wait(self, timeout=None) -> int: + self.wait_calls += 1 + if self.wait_calls == 1: + raise subprocess.TimeoutExpired(cmd=["git"], timeout=timeout) + return -9 + + def kill(self) -> None: + self.killed = True + + process = FakeProcess() + subprocess_module = namespace["subprocess"] + monkeypatch.setattr(subprocess_module, "Popen", lambda *args, **kwargs: process) + + run_git_bounded = namespace["_run_git_bounded"] + assert run_git_bounded(tmp_path, ["status"], limit=1024) is None + assert process.killed is True + + +def test_rendered_post_tool_diff_fails_closed_on_bounded_git_failure( + tmp_path: Path, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + namespace["_is_git_repository"] = lambda _root: True + namespace["_has_executable_worktree_filter"] = lambda _root: False + namespace["_run_git_bounded"] = lambda *_args, **_kwargs: None + + git_diff_for_paths = namespace["_git_diff_for_paths"] + assert git_diff_for_paths(tmp_path, ["agent.py"]) is None + + +def test_rendered_post_tool_diff_fails_closed_on_executable_filter( + tmp_path: Path, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + namespace["_is_git_repository"] = lambda _root: True + namespace["_has_executable_worktree_filter"] = lambda _root: True + + git_diff_for_paths = namespace["_git_diff_for_paths"] + assert git_diff_for_paths(tmp_path, ["agent.py"]) is None + + +def test_rendered_untracked_diff_enforces_an_aggregate_content_budget( + tmp_path: Path, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + namespace["GIT_DIFF_OUTPUT_LIMIT_BYTES"] = 64 + namespace["_run_git_bounded"] = lambda *_args, **_kwargs: b"" + metadata = SimpleNamespace(st_size=8, st_mtime_ns=1) + namespace["_read_untracked_file"] = ( + lambda _root, _path: (b"12345678", metadata) + ) + + untracked_content = namespace["_untracked_content_for_paths"] + assert untracked_content( + tmp_path, + ["one.py", "two.py", "three.py"], + ) is None + + +def test_rendered_git_path_arguments_are_literal_pathspecs(tmp_path: Path) -> None: + namespace = _rendered_hook_namespace(tmp_path) + calls: list[list[str]] = [] + + def fake_run(_root, args, *, limit): + calls.append(args) + if "ls-files" in args: + return b":(exclude)**\0shipgate.yaml\0" + return b"" + + namespace["_is_git_repository"] = lambda _root: True + namespace["_ref_exists"] = lambda _root, _ref: True + namespace["_has_executable_worktree_filter"] = lambda _root: False + namespace["_run_git_bounded"] = fake_run + + diff_for_paths = namespace["_git_diff_for_paths"] + assert diff_for_paths(tmp_path, [":(exclude)**", "shipgate.yaml"]) == "" + flattened = [argument for call in calls for argument in call] + assert ":(top,literal):(exclude)**" in flattened + assert ":(exclude)**" not in flattened + + +def test_rendered_untracked_binary_marker_binds_content_digest( + tmp_path: Path, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + namespace["_run_git_bounded"] = lambda *_args, **_kwargs: b"" + metadata = SimpleNamespace(st_size=4) + untracked_content = namespace["_untracked_content_for_paths"] + + namespace["_read_untracked_file"] = lambda _root, _path: (b"a\0aa", metadata) + first = untracked_content(tmp_path, ["agent.bin"]) + namespace["_read_untracked_file"] = lambda _root, _path: (b"b\0bb", metadata) + second = untracked_content(tmp_path, ["agent.bin"]) + + assert first is not None + assert second is not None + assert first != second + assert "sha256=" in first + + +def test_rendered_untracked_oversized_file_makes_snapshot_unavailable( + tmp_path: Path, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + namespace["_run_git_bounded"] = lambda *_args, **_kwargs: b"" + limit = namespace["UNTRACKED_DIFF_CONTENT_LIMIT_BYTES"] + metadata = SimpleNamespace(st_size=limit + 1) + namespace["_read_untracked_file"] = lambda _root, _path: (b"", metadata) + + untracked_content = namespace["_untracked_content_for_paths"] + assert untracked_content(tmp_path, ["large-agent.py"]) is None + + def _run_pretooluse(tmp_path: Path, file_path: str, *, env_extra=None) -> str: script = _render_hook_script(tmp_path) event = { @@ -845,6 +1251,31 @@ def test_pretooluse_hook_allow_mode_disables(tmp_path: Path) -> None: assert out == "" +def test_rendered_alias_inspector_rejects_nonexact_unicode_entry( + tmp_path: Path, + monkeypatch, +) -> None: + namespace = _rendered_hook_namespace(tmp_path) + config_dir = tmp_path / "config" + config_dir.mkdir() + stored = config_dir / "café.gate" + stored.write_text("version: '0.1'\n", encoding="utf-8") + requested = config_dir / "cafe\u0301.gate" + original_lstat = Path.lstat + + def aliasing_lstat(path: Path): + if path == requested: + return original_lstat(stored) + return original_lstat(path) + + monkeypatch.setattr(Path, "lstat", aliasing_lstat) + unsafe_alias_kind = namespace["_unsafe_alias_kind"] + assert ( + unsafe_alias_kind(tmp_path, "config/cafe\u0301.gate") + == "aliased-path" + ) + + def test_rendered_script_glob_matcher_matches_canonical_globbing( tmp_path: Path, ) -> None: @@ -853,12 +1284,7 @@ def test_rendered_script_glob_matcher_matches_canonical_globbing( from agents_shipgate.checks.verify import TRUST_ROOT_SURFACES from agents_shipgate.core.globbing import glob_match - script = _render_hook_script(tmp_path) - namespace: dict = {} - # Extract just the rendered module constants/functions we need by - # executing the script with a stubbed __name__ so main() doesn't run. - code = script.read_text(encoding="utf-8") - exec(compile(code, str(script), "exec"), {"__name__": "hook_under_test"}, namespace) + namespace = _rendered_hook_namespace(tmp_path) hook_match = namespace["_glob_match"] surfaces = namespace["PROTECTED_SURFACES"] diff --git a/tests/test_mcp_audit.py b/tests/test_mcp_audit.py index 54f93ea5..40dbb61e 100644 --- a/tests/test_mcp_audit.py +++ b/tests/test_mcp_audit.py @@ -107,6 +107,236 @@ def test_mcp_audit_reads_mcp_json_diff(tmp_path: Path) -> None: assert [item["id"] for item in payload["violated_rules"]] == ["MCP-UNKNOWN-TOOL-SCHEMA"] +def test_mcp_audit_retains_the_source_side_of_a_rename(tmp_path: Path) -> None: + diff = tmp_path / "rename.diff" + content = ( + json.dumps( + { + "mcpServers": { + "docs": { + "command": "docs-mcp", + "tools": { + "read": { + "inputSchema": {"type": "object"}, + } + }, + } + } + } + ) + + "\n" + ) + (tmp_path / "retired.txt").write_text(content, encoding="utf-8") + diff.write_text( + ( + "diff --git a/.mcp.json b/retired.txt\n" + "similarity index 100%\n" + "rename from .mcp.json\n" + "rename to retired.txt\n" + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + [ + "mcp", + "audit", + "--workspace", + str(tmp_path), + "--diff", + str(diff), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["changed_files"] == [".mcp.json", "retired.txt"] + assert payload["capability_delta"]["removed"] + assert not payload["capability_delta"]["added"] + + +def test_mcp_audit_processes_both_adapters_in_a_cross_type_rename( + tmp_path: Path, +) -> None: + content = ( + json.dumps( + { + "mcpServers": { + "docs": { + "command": "docs-mcp", + "tools": { + "read": { + "inputSchema": {"type": "object"}, + } + }, + } + } + } + ) + + "\n" + ) + (tmp_path / ".mcp.json").write_text(content, encoding="utf-8") + diff = tmp_path / "cross-type-rename.diff" + diff.write_text( + ( + "diff --git a/.codex/config.toml b/.mcp.json\n" + "similarity index 100%\n" + "rename from .codex/config.toml\n" + "rename to .mcp.json\n" + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + [ + "mcp", + "audit", + "--workspace", + str(tmp_path), + "--diff", + str(diff), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["changed_files"] == [".codex/config.toml", ".mcp.json"] + assert payload["capability_delta"]["added"] + + +def test_mcp_audit_same_adapter_pure_rename_is_not_a_capability_addition( + tmp_path: Path, +) -> None: + destination = tmp_path / "config" / ".mcp.json" + destination.parent.mkdir() + destination.write_text( + json.dumps( + { + "mcpServers": { + "docs": { + "command": "docs-mcp", + "tools": { + "read": { + "inputSchema": {"type": "object"}, + } + }, + } + } + } + ) + + "\n", + encoding="utf-8", + ) + diff = tmp_path / "same-adapter-rename.diff" + diff.write_text( + ( + "diff --git a/.mcp.json b/config/.mcp.json\n" + "similarity index 100%\n" + "rename from .mcp.json\n" + "rename to config/.mcp.json\n" + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + [ + "mcp", + "audit", + "--workspace", + str(tmp_path), + "--diff", + str(diff), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["changed_files"] == [".mcp.json", "config/.mcp.json"] + assert payload["capability_delta"]["added"] == [] + assert payload["capability_delta"]["removed"] == [] + assert payload["capability_delta"]["changed"] == [] + + +def test_mcp_audit_edited_rename_out_of_adapter_retains_source_removals( + tmp_path: Path, +) -> None: + old_text = json.dumps( + { + "mcpServers": { + "docs": { + "command": "docs-mcp", + "tools": { + "read": { + "inputSchema": {"type": "object"}, + } + }, + } + } + }, + sort_keys=True, + ) + new_text = json.dumps( + { + "mcpServers": { + "docs": { + "command": "replacement-docs-mcp", + "tools": { + "read": { + "inputSchema": {"type": "object"}, + } + }, + } + } + }, + sort_keys=True, + ) + (tmp_path / "retired.txt").write_text(new_text + "\n", encoding="utf-8") + diff = tmp_path / "edited-rename.diff" + diff.write_text( + ( + "diff --git a/.mcp.json b/retired.txt\n" + "similarity index 83%\n" + "rename from .mcp.json\n" + "rename to retired.txt\n" + "--- a/.mcp.json\n" + "+++ b/retired.txt\n" + "@@ -1 +1 @@\n" + f"-{old_text}\n" + f"+{new_text}\n" + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + [ + "mcp", + "audit", + "--workspace", + str(tmp_path), + "--diff", + str(diff), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["changed_files"] == [".mcp.json", "retired.txt"] + assert payload["capability_delta"]["removed"] + assert payload["capability_delta"]["added"] == [] + + def test_mcp_audit_policy_override_changes_decision(tmp_path: Path) -> None: policy = tmp_path / "mcp-policy.yaml" policy.write_text( diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 18e008e8..100715d4 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -59,6 +59,32 @@ def test_shipgate_check_returns_boundary_result_without_writes(tmp_path: Path) - json.dumps(payload) +def test_mcp_check_never_authorizes_verify_for_detached_diff_text( + tmp_path: Path, +) -> None: + payload = shipgate_check( + workspace=str(tmp_path), + diff_text=( + "diff --git a/.codex/config.toml b/.codex/config.toml\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/.codex/config.toml\n" + "@@ -0,0 +1,3 @@\n" + "+[permissions.workspace.network]\n" + "+enabled = true\n" + '+surprise = "value"\n' + ), + ) + + assert payload["decision"] == "require_review" + assert payload["control"]["state"] == "human_review_required" + assert payload["control"]["allowed_next_commands"] == [] + assert payload["control"]["next_action"]["command"] is None + assert "not bound to a checkout state" in payload["control"]["stop_reason"] + assert payload["summary"] == payload["control"]["reason"] + assert "Re-run check against the intended worktree" in payload["summary"] + + def test_mcp_preflight_handler_is_read_only(tmp_path: Path) -> None: workspace = tmp_path / "wk" shutil.copytree("samples/clean_read_only_agent", workspace) @@ -87,6 +113,28 @@ def test_mcp_preflight_handler_is_read_only(tmp_path: Path) -> None: assert _snapshot(workspace) == before +def test_mcp_preflight_keeps_the_source_side_of_a_rename(tmp_path: Path) -> None: + workspace = tmp_path / "wk" + shutil.copytree("samples/clean_read_only_agent", workspace) + + payload = shipgate_preflight( + workspace=str(workspace), + diff_text=( + "diff --git a/shipgate.yaml b/retired.txt\n" + "similarity index 100%\n" + "rename from shipgate.yaml\n" + "rename to retired.txt\n" + ), + ) + + assert payload["changed_files"] == ["retired.txt", "shipgate.yaml"] + assert payload["requires_human_review"] is True + assert any( + touch["path"] == "shipgate.yaml" + for touch in payload["protected_surface_touches"] + ) + + def test_mcp_preflight_accepts_plan_without_writes(tmp_path: Path) -> None: workspace = tmp_path / "wk" shutil.copytree("samples/clean_read_only_agent", workspace) @@ -118,6 +166,47 @@ def test_mcp_preflight_accepts_plan_without_writes(tmp_path: Path) -> None: assert _snapshot(workspace) == before +def test_mcp_preflight_rejects_plan_combined_with_direct_diff( + tmp_path: Path, +) -> None: + workspace = tmp_path / "wk" + shutil.copytree("samples/clean_read_only_agent", workspace) + + with pytest.raises( + ConfigError, + match=r"plan cannot be combined with diff_text", + ): + shipgate_preflight( + workspace=str(workspace), + plan={"schema_version": "preflight_plan_v1"}, + diff_text=( + "diff --git a/shipgate.yaml b/shipgate.yaml\n" + "--- a/shipgate.yaml\n" + "+++ b/shipgate.yaml\n" + "@@ -1 +1 @@\n" + '-version: "0.1"\n' + '+version: "0.2"\n' + ), + ) + + +def test_mcp_preflight_rejects_mixed_shape_before_validating_direct_input( + tmp_path: Path, +) -> None: + workspace = tmp_path / "wk" + shutil.copytree("samples/clean_read_only_agent", workspace) + + with pytest.raises( + ConfigError, + match=r"plan cannot be combined with capability_request", + ): + shipgate_preflight( + workspace=str(workspace), + plan={"schema_version": "preflight_plan_v1"}, + capability_request={"not": "a capability request"}, + ) + + def test_mcp_explain_handler_returns_check_metadata() -> None: payload = shipgate_explain( check_id="SHIP-POLICY-APPROVAL-MISSING", diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 937bf969..c6061828 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -2,6 +2,7 @@ import difflib import json +import os import shlex import subprocess import sys @@ -15,7 +16,10 @@ from agents_shipgate.cli.main import app from agents_shipgate.cli.preflight import _read_plan from agents_shipgate.cli.scan import run_scan +from agents_shipgate.core import preflight as preflight_module +from agents_shipgate.core import trust_roots as trust_roots_module from agents_shipgate.core.boundary_registry import BOUNDARY_ADAPTERS +from agents_shipgate.core.errors import ConfigError from agents_shipgate.core.host_grants import build_host_grants_baseline, host_audit_inventory from agents_shipgate.core.preflight import ( build_preflight_result, @@ -25,6 +29,7 @@ required_evidence_for_capability_request, ) from agents_shipgate.schemas.preflight import ( + CapabilityRequestControls, CapabilityRequestV1, PreflightResultV1, PreflightResultV2, @@ -85,13 +90,13 @@ def _write(root: Path, path: str, text: str = "x\n") -> None: target.write_text(text, encoding="utf-8") -def _manifest_diff(old: str, new: str) -> str: - return "diff --git a/shipgate.yaml b/shipgate.yaml\n" + "".join( +def _manifest_diff(old: str, new: str, path: str = "shipgate.yaml") -> str: + return f"diff --git a/{path} b/{path}\n" + "".join( difflib.unified_diff( old.splitlines(keepends=True), new.splitlines(keepends=True), - fromfile="a/shipgate.yaml", - tofile="b/shipgate.yaml", + fromfile=f"a/{path}", + tofile=f"b/{path}", ) ) @@ -124,6 +129,47 @@ def test_preflight_routes_protected_surface_touches_to_human(tmp_path: Path) -> assert any(".codex/config.toml" in pattern for pattern in result.forbidden_file_edits) +def test_preflight_routes_a_planned_symlink_path_to_human(tmp_path: Path) -> None: + root = _workspace(tmp_path) + alias = root / "edit-me" + alias.symlink_to(root / "AGENTS.md") + + result = build_preflight_result(workspace=root, changed_files=[alias.name]) + + touch = next(item for item in result.protected_surface_touches if item.path == alias.name) + assert touch.kind == "path_identity" + assert touch.scope_type == "whole_file" + assert result.control.state == "human_review_required" + + +def test_preflight_routes_a_planned_hardlink_path_to_human(tmp_path: Path) -> None: + root = _workspace(tmp_path) + alias = root / "edit-me" + alias.hardlink_to(root / "AGENTS.md") + + result = build_preflight_result(workspace=root, changed_files=[alias.name]) + + touch = next(item for item in result.protected_surface_touches if item.path == alias.name) + assert touch.kind == "path_identity" + assert touch.scope_type == "whole_file" + assert result.control.state == "human_review_required" + + +def test_preflight_treats_stored_case_variants_as_host_trust_roots( + tmp_path: Path, +) -> None: + root = _workspace(tmp_path) + (root / "AGENTS.md").rename(root / "agents.md") + + result = build_preflight_result(workspace=root, changed_files=["agents.md"]) + + touch = next( + item for item in result.protected_surface_touches if item.path == "agents.md" + ) + assert touch.kind == "agent_instructions" + assert result.control.state == "human_review_required" + + def test_preflight_nested_verify_command_targets_the_nested_manifest( tmp_path: Path, ) -> None: @@ -154,6 +200,112 @@ def test_preflight_nested_verify_command_targets_the_nested_manifest( ).resolve() +def test_preflight_rejects_a_symlinked_config_instead_of_authorizing_its_target( + tmp_path: Path, +) -> None: + root = _workspace(tmp_path) + target = root / "new-gate.yml" + (root / "shipgate.yaml").rename(target) + link = root / "gate.yml" + link.symlink_to(target.name) + + with pytest.raises( + ConfigError, + match=r"--config must not contain symlink components: gate\.yml", + ): + build_preflight_result( + workspace=root, + config=Path("gate.yml"), + changed_files=["gate.yml"], + ) + + +def test_preflight_accepts_absolute_config_under_external_workspace_alias( + tmp_path: Path, +) -> None: + root = _workspace(tmp_path) + alias = tmp_path / "repo-alias" + alias.symlink_to(root, target_is_directory=True) + + result = build_preflight_result( + workspace=alias, + config=alias / "shipgate.yaml", + changed_files=["shipgate.yaml"], + ) + + touch = next( + item for item in result.protected_surface_touches + if item.path == "shipgate.yaml" + ) + assert touch.kind == "manifest" + assert result.control.state == "human_review_required" + + +def test_preflight_rejects_a_filesystem_resolved_config_alias( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _workspace(tmp_path) + actual = root / "new-gate.yml" + (root / "shipgate.yaml").rename(actual) + alias = root / "NEW-GATE.yml" + real_lstat = Path.lstat + + def aliased_lstat(path: Path, *args, **kwargs): + if path == alias: + return real_lstat(actual, *args, **kwargs) + return real_lstat(path, *args, **kwargs) + + monkeypatch.setattr(Path, "lstat", aliased_lstat) + + with pytest.raises( + ConfigError, + match=( + r"--config must use the exact filesystem spelling: " + r"NEW-GATE\.yml resolves to new-gate\.yml" + ), + ): + build_preflight_result( + workspace=root, + config=Path(alias.name), + changed_files=[actual.name], + ) + + +@pytest.mark.skipif( + sys.platform != "darwin", + reason="exercises configured-manifest aliases on macOS filesystems", +) +def test_preflight_matches_real_case_drift_to_configured_manifest( + tmp_path: Path, +) -> None: + root = _workspace(tmp_path) + indexed = root / "Gate.gate" + (root / "shipgate.yaml").rename(indexed) + configured = root / "gate.gate" + indexed.rename(configured) + stored_names = {entry.name for entry in root.iterdir()} + if configured.name not in stored_names or indexed.name in stored_names: + pytest.skip("filesystem did not retain the case-only rename spelling") + try: + if not os.path.samestat(indexed.lstat(), configured.lstat()): + pytest.skip("filesystem does not alias case variants") + except OSError: + pytest.skip("filesystem does not alias case variants") + + result = build_preflight_result( + workspace=root, + config=Path(configured.name), + changed_files=[indexed.name], + ) + + touch = next( + item for item in result.protected_surface_touches if item.path == configured.name + ) + assert touch.kind == "manifest" + assert result.control.state == "human_review_required" + + def test_preflight_classifies_both_sides_of_a_custom_manifest_rename( tmp_path: Path, ) -> None: @@ -216,6 +368,49 @@ def test_preflight_allows_exact_append_only_builtin_source_proposal( assert "not approved" in signal.reason +@pytest.mark.parametrize( + ("manifest_relative_source_exists", "proposal_safe"), + [(False, False), (True, True)], +) +def test_preflight_resolves_proposed_sources_from_custom_manifest_directory( + tmp_path: Path, + manifest_relative_source_exists: bool, + proposal_safe: bool, +) -> None: + root = _workspace(tmp_path) + nested = root / "config" + nested.mkdir() + manifest = nested / "gate.yml" + old = (root / "shipgate.yaml").read_text(encoding="utf-8") + manifest.write_text(old, encoding="utf-8") + (nested / "tools.json").write_text('{"tools": []}\n', encoding="utf-8") + # A repository-root decoy must not satisfy `config/gate.yml`'s + # manifest-relative `decoy.json` declaration. + (root / "decoy.json").write_text('{"tools": []}\n', encoding="utf-8") + if manifest_relative_source_exists: + (nested / "decoy.json").write_text('{"tools": []}\n', encoding="utf-8") + new = old + ( + " - id: decoy\n" + " type: mcp\n" + " path: decoy.json\n" + ) + + result = build_preflight_result( + workspace=root, + config=Path("config/gate.yml"), + diff_text=_manifest_diff(old, new, "config/gate.yml"), + ) + + assert result.changed_files == ["config/gate.yml"] + assert result.protected_surface_touches[0].requires_human_review is ( + not proposal_safe + ) + assert result.requires_human_review is (not proposal_safe) + assert result.control.state == ( + "agent_action_required" if proposal_safe else "human_review_required" + ) + + @pytest.mark.parametrize("safe_block_first", [True, False]) def test_preflight_rejects_duplicate_manifest_blocks_when_one_is_unsafe( tmp_path: Path, @@ -387,7 +582,7 @@ def test_preflight_rejects_plugin_source_with_symlinked_manifest(tmp_path: Path) (".codex/config.toml", "codex_config", "whole_file"), (".codex/hooks/preflight.sh", "codex_hooks", "whole_file"), (".cursor/cli.json", "host_boundary", "whole_file"), - ("AGENTS.override.md", "host_boundary", "whole_file"), + ("AGENTS.override.md", "agent_instructions", "whole_file"), (".github/workflows/deploy.yml", "host_boundary", "whole_file"), (".codex-plugin/plugin.json", "codex_plugin", "capability_surface"), ("servers/refund/.mcp.json", "tool_surface_decl", "capability_surface"), @@ -461,6 +656,117 @@ def test_read_only_capability_request_has_no_required_evidence() -> None: assert required_evidence_for_capability_request(request) == [] +@pytest.mark.parametrize( + "request_key,request_payload", + [ + ( + "capability_requests", + [{"tool_name": " \t ", "effect": "read"}], + ), + ( + "host_permission_requests", + [ + { + "host": "\n ", + "surface": "permissions.allow", + "operation": "add", + "subject": "Bash(git status)", + } + ], + ), + ( + "host_permission_requests", + [ + { + "host": "claude-code", + "surface": " \t", + "operation": "add", + "subject": "Bash(git status)", + } + ], + ), + ( + "host_permission_requests", + [ + { + "host": "claude-code", + "surface": "permissions.allow", + "operation": " ", + "subject": "Bash(git status)", + } + ], + ), + ( + "host_permission_requests", + [ + { + "host": "claude-code", + "surface": "permissions.allow", + "operation": "add", + "subject": "\t", + } + ], + ), + ], +) +def test_preflight_plan_rejects_blank_capability_and_host_request_fields( + tmp_path: Path, + request_key: str, + request_payload: list[dict[str, object]], +) -> None: + root = _workspace(tmp_path) + + with pytest.raises(ConfigError, match="must not be blank"): + build_preflight_result( + workspace=root, + plan={ + "schema_version": "preflight_plan_v1", + request_key: request_payload, + }, + ) + + +def test_capability_request_normalizes_and_deduplicates_risk_tag_case() -> None: + request = CapabilityRequestV1( + tool_name="lookup_customer", + effect="read", + risk_tags=[ + " Financial_Action ", + "financial_action", + "PRODUCTION_OPERATION", + ], + ) + + assert request.risk_tags == ["financial_action", "production_operation"] + assert required_evidence_for_capability_request(request) + + +def test_external_communication_requires_explicit_confirmation_evidence() -> None: + missing = CapabilityRequestV1( + tool_name="send_customer_email", + effect="external_communication", + ) + confirmed = CapabilityRequestV1( + tool_name="send_customer_email", + effect="external_communication", + controls=CapabilityRequestControls(confirmation_required=True), + ) + + missing_confirmation = next( + item + for item in required_evidence_for_capability_request(missing) + if item.id == "confirmation" + ) + confirmed_confirmation = next( + item + for item in required_evidence_for_capability_request(confirmed) + if item.id == "confirmation" + ) + assert missing_confirmation.satisfied is False + assert missing_confirmation.field == "controls.confirmation_required" + assert confirmed_confirmation.satisfied is True + + def test_policy_and_trust_root_hashes_are_deterministic(tmp_path: Path) -> None: root = _workspace(tmp_path) @@ -472,6 +778,202 @@ def test_policy_and_trust_root_hashes_are_deterministic(tmp_path: Path) -> None: assert build_trust_root_graph(root).graph_hash == first.trust_root_graph_hash +def test_trust_root_graph_entry_budget_is_aggregate_across_inventory_and_reads( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _workspace(tmp_path) + budget = trust_roots_module.IdentityReadBudget( + max_entries=10_000, + max_total_bytes=1024 * 1024, + ) + reader = trust_roots_module.IdentityBoundReadSession(root, budget=budget) + _paths, inventory_entries = preflight_module._walk_trust_root_files( + root, + reader=reader, + max_entries=10_000, + ) + assert inventory_entries > 0 + monkeypatch.setattr( + preflight_module, + "_MAX_TRUST_ROOT_GRAPH_ENTRIES", + inventory_entries, + ) + + with pytest.raises(ConfigError, match="aggregate static resource bound"): + build_trust_root_graph(root) + + +def test_trust_root_graph_byte_budget_is_aggregate_across_unique_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _workspace(tmp_path) + protected = [ + root / "shipgate.yaml", + root / "AGENTS.md", + root / ".codex" / "config.toml", + root / ".github" / "workflows" / "agents-shipgate.yml", + ] + individual_sizes = [path.stat().st_size for path in protected] + aggregate_limit = max(individual_sizes) + assert sum(individual_sizes) > aggregate_limit + monkeypatch.setattr( + preflight_module, + "_MAX_TRUST_ROOT_GRAPH_BYTES", + aggregate_limit, + ) + + with pytest.raises(ConfigError, match="aggregate static resource bound"): + build_trust_root_graph(root) + + +def test_trust_root_graph_scans_each_protected_directory_constant_times( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _workspace(tmp_path) + policy_directory = root / "policies" + for index in range(50): + _write(root, f"policies/policy-{index:02d}.yaml", f"index: {index}\n") + + real_scandir = os.scandir + calls: dict[str, int] = {} + + def counting_scandir(path: os.PathLike[str] | str | int): + if not isinstance(path, int): + key = os.path.abspath(os.fspath(path)) + calls[key] = calls.get(key, 0) + 1 + return real_scandir(path) + + monkeypatch.setattr(trust_roots_module.os, "scandir", counting_scandir) + + graph = build_trust_root_graph(root) + + node = next(item for item in graph.nodes if item.pattern == "**/policies/**") + assert len(node.file_hashes) == 50 + assert calls[os.path.abspath(os.fspath(policy_directory))] == 2 + + +def test_bounded_trust_root_graph_keeps_hardlinks_fail_closed( + tmp_path: Path, +) -> None: + root = _workspace(tmp_path) + original = root / "policies" / "primary.yaml" + _write(root, "policies/primary.yaml", "approval: required\n") + alias = root / "policies" / "alias.yaml" + alias.hardlink_to(original) + + result = build_preflight_result(workspace=root) + + node = next( + item for item in result.trust_root_graph.nodes + if item.pattern == "**/policies/**" + ) + assert node.file_hashes["policies/primary.yaml"] == "unavailable:path_identity" + assert node.file_hashes["policies/alias.yaml"] == "unavailable:path_identity" + assert { + item.path + for item in result.protected_surface_touches + if item.kind == "path_identity" + } >= {"policies/primary.yaml", "policies/alias.yaml"} + assert result.control.state == "human_review_required" + + +def test_trust_root_graph_rejects_protected_addition_after_inventory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _workspace(tmp_path) + (root / "docs").mkdir() + added = root / "docs" / "AGENTS.md" + real_finish = trust_roots_module.IdentityBoundReadSession.finish + injected = False + + def finish_after_addition( + reader: trust_roots_module.IdentityBoundReadSession, + ) -> None: + nonlocal injected + if reader.root == root and not injected: + injected = True + added.write_text("late protected instruction\n", encoding="utf-8") + real_finish(reader) + + monkeypatch.setattr( + trust_roots_module.IdentityBoundReadSession, + "finish", + finish_after_addition, + ) + + with pytest.raises(ConfigError, match="changed identity"): + build_trust_root_graph(root) + + +def test_exact_custom_manifest_keeps_literal_glob_characters(tmp_path: Path) -> None: + root = _workspace(tmp_path) + manifest_name = "gate[production]*?.yml" + (root / "shipgate.yaml").rename(root / manifest_name) + + result = build_preflight_result( + workspace=root, + config=Path(manifest_name), + changed_files=[manifest_name], + ) + + node = next( + item + for item in result.trust_root_graph.nodes + if item.kind == "manifest" and item.pattern == manifest_name + ) + assert node.present_paths == [manifest_name] + assert node.file_hashes[manifest_name].startswith("sha256:") + touch = next( + item for item in result.protected_surface_touches if item.path == manifest_name + ) + assert touch.kind == "manifest" + assert result.control.state == "human_review_required" + + +def test_preflight_rejects_a_hardlinked_config_manifest(tmp_path: Path) -> None: + root = _workspace(tmp_path) + alias = root / "custom-gate.yml" + alias.hardlink_to(root / "shipgate.yaml") + + with pytest.raises(ConfigError, match="hardlinked manifest refused"): + build_preflight_result( + workspace=root, + config=Path(alias.name), + changed_files=[alias.name], + ) + + +def test_trust_root_globstar_fails_closed_on_a_symlink_ancestor( + tmp_path: Path, +) -> None: + root = _workspace(tmp_path) + outside = tmp_path / "outside" + (outside / "policies").mkdir(parents=True) + secret = "OUTSIDE-POLICY-SECRET" + (outside / "policies" / "review.yaml").write_text(secret, encoding="utf-8") + (root / "vendor").symlink_to(outside, target_is_directory=True) + + result = build_preflight_result(workspace=root) + + node = next( + item + for item in result.trust_root_graph.nodes + if item.pattern == "**/policies/**" + ) + assert "vendor" in node.present_paths + assert node.file_hashes["vendor"] == "unavailable:path_identity" + assert secret not in result.model_dump_json() + assert any( + item.path == "vendor" and item.kind == "path_identity" + for item in result.protected_surface_touches + ) + assert result.control.state == "human_review_required" + + @pytest.mark.parametrize( "pattern,path", [ @@ -937,7 +1439,7 @@ def test_cli_preflight_routes_incomparable_legacy_host_baseline_to_human( assert "--save-baseline" not in json.dumps(payload) -def test_cli_preflight_default_corrupt_host_baseline_warns_and_continues( +def test_cli_preflight_default_corrupt_host_baseline_fails_closed( tmp_path: Path, ) -> None: root = _workspace(tmp_path) @@ -957,9 +1459,41 @@ def test_cli_preflight_default_corrupt_host_baseline_warns_and_continues( assert result.exit_code == 0, result.output payload = json.loads(result.output) - assert payload["host_grant_drift"] is None - assert any("host-grant drift skipped" in note for note in payload["notes"]) - assert not any(signal["kind"] == "host_grant_drift" for signal in payload["signals"]) + assert payload["host_grant_drift"]["comparison_status"] == "incomparable" + assert payload["requires_human_review"] is True + assert payload["control"]["state"] == "human_review_required" + assert any("requires review" in note for note in payload["notes"]) + assert any(signal["kind"] == "host_grant_drift" for signal in payload["signals"]) + assert "--save-baseline" not in json.dumps(payload) + + +@pytest.mark.parametrize("broken", [False, True]) +def test_cli_preflight_default_symlinked_host_baseline_fails_closed( + tmp_path: Path, + broken: bool, +) -> None: + root = _workspace(tmp_path) + baseline_path = root / ".agents-shipgate" / "host-grants.json" + baseline_path.parent.mkdir(parents=True) + external = tmp_path / "external-baseline.json" + if not broken: + external.write_text( + json.dumps(build_host_grants_baseline(host_audit_inventory(root))), + encoding="utf-8", + ) + baseline_path.symlink_to(external) + + result = runner.invoke( + app, + ["preflight", "--workspace", str(root), "--json"], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["host_grant_drift"]["comparison_status"] == "incomparable" + assert payload["control"]["state"] == "human_review_required" + assert payload["control"]["allowed_next_commands"] == [] + assert "--save-baseline" not in json.dumps(payload) def test_cli_preflight_explicit_missing_or_corrupt_host_baseline_fails( @@ -979,7 +1513,7 @@ def test_cli_preflight_explicit_missing_or_corrupt_host_baseline_fails( ], ) assert result.exit_code == 2 - assert "No host-grants baseline" in result.output + assert "No readable host-grants baseline" in result.output corrupt = tmp_path / "corrupt-baseline.json" corrupt.write_text("{", encoding="utf-8") diff --git a/tests/test_trust_root.py b/tests/test_trust_root.py index 2a8796cc..13bfc737 100644 --- a/tests/test_trust_root.py +++ b/tests/test_trust_root.py @@ -7,7 +7,9 @@ import json import shutil +import sys from pathlib import Path +from types import SimpleNamespace import pytest from typer.testing import CliRunner @@ -19,7 +21,10 @@ from agents_shipgate.core.context import ScanContext from agents_shipgate.core.domain import Agent from agents_shipgate.core.findings.mutations import apply_suppressions -from agents_shipgate.core.trust_roots import is_configured_manifest +from agents_shipgate.core.trust_roots import ( + inspect_lexical_path_identity, + is_configured_manifest, +) from agents_shipgate.schemas.manifest import SuppressionConfig from agents_shipgate.schemas.verification import VerificationContext @@ -77,6 +82,7 @@ def test_non_trust_root_files_are_not_flagged(): (".github/workflows/agents-shipgate.yml", "ci_gate"), (".github/workflows/agents-shipgate.yaml", "ci_gate"), ("AGENTS.md", "agent_instructions"), + ("agents.md", "agent_instructions"), ("CLAUDE.md", "agent_instructions"), (".claude/commands/shipgate.md", "agent_instructions"), (".cursor/rules/agents-shipgate.mdc", "agent_instructions"), @@ -137,6 +143,88 @@ def test_configured_manifest_identity_is_canonical_within_workspace(): ) +def test_configured_manifest_identity_has_no_same_basename_fallback(): + assert not is_configured_manifest( + "/repo/config/release.gate", + "release.gate", + exact=True, + ) + + +def test_verify_uses_the_logical_configured_manifest_identity(): + context = _context(changed_files=["release.gate"]) + context.config_path = Path("/tmp/archive/config/release.gate") + context.verification.configured_manifest_path = "config/release.gate" + + assert verify_run(context) == [] + + context.verification.changed_files = ["config/release.gate"] + findings = verify_run(context) + assert len(findings) == 1 + assert findings[0].evidence["changed_file"] == "config/release.gate" + + +@pytest.mark.parametrize( + ("actual_name", "alias_name"), + [ + ("new-gate.yml", "NEW-GATE.yml"), + ("caf\u00e9.yml", "cafe\u0301.yml"), + ], +) +def test_lexical_path_identity_rejects_filesystem_resolved_aliases( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + actual_name: str, + alias_name: str, +): + actual = tmp_path / actual_name + actual.write_text("manifest\n", encoding="utf-8") + alias = tmp_path / alias_name + real_lstat = Path.lstat + + def aliased_lstat(path: Path, *args, **kwargs): + if path == alias: + return real_lstat(actual, *args, **kwargs) + return real_lstat(path, *args, **kwargs) + + monkeypatch.setattr(Path, "lstat", aliased_lstat) + + issue = inspect_lexical_path_identity(tmp_path, Path(alias_name)) + assert issue is not None + assert issue.kind == "alias" + assert issue.requested == alias + assert issue.actual == actual + + +def test_lexical_path_identity_rejects_windows_reparse_components( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bridge = tmp_path / "bridge" + bridge.mkdir() + (bridge / "gate.yml").write_text("manifest\n", encoding="utf-8") + real_lstat = Path.lstat + + def reparse_lstat(path: Path, *args, **kwargs): + metadata = real_lstat(path, *args, **kwargs) + if path == bridge: + return SimpleNamespace( + st_mode=metadata.st_mode, + st_file_attributes=0x0400, + ) + return metadata + + monkeypatch.setattr(Path, "lstat", reparse_lstat) + + issue = inspect_lexical_path_identity( + tmp_path, + Path("bridge/gate.yml"), + ) + assert issue is not None + assert issue.kind == "reparse_point" + assert issue.requested == bridge + + @pytest.mark.parametrize( ("config", "changed"), [ @@ -164,8 +252,12 @@ def test_first_match_wins_classification_order(): assert findings[0].evidence["trust_root_class"] == "agent_instructions" -def test_scan_changed_files_emits_finding_through_the_gate(tmp_path): +def test_scan_changed_files_emits_finding_through_the_gate( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): shutil.copytree("samples/clean_read_only_agent", tmp_path / "wk") + monkeypatch.chdir(tmp_path / "wk") changed = tmp_path / "changed.txt" changed.write_text("shipgate.yaml\nsrc/agent.py\nREADME.md\n", encoding="utf-8") @@ -194,6 +286,329 @@ def test_scan_changed_files_emits_finding_through_the_gate(tmp_path): assert verify_findings[0]["requires_human_review"] is True +def test_scan_changed_files_does_not_suffix_match_a_custom_manifest( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "wk" + manifest_dir = workspace / "config" + shutil.copytree("samples/clean_read_only_agent", manifest_dir) + (manifest_dir / "shipgate.yaml").rename(manifest_dir / "release.gate") + changed = tmp_path / "changed.txt" + changed.write_text("release.gate\n", encoding="utf-8") + monkeypatch.chdir(workspace) + + result = runner.invoke( + app, + [ + "scan", + "-c", + str(manifest_dir / "release.gate"), + "--changed-files", + str(changed), + ], + ) + + assert result.exit_code == 0, result.output + report = json.loads( + (manifest_dir / "agents-shipgate-reports" / "report.json").read_text() + ) + assert all( + finding["evidence"].get("changed_file") != "release.gate" + for finding in report["findings"] + if finding["check_id"].startswith("SHIP-VERIFY-") + ) + + changed.write_text("config/release.gate\n", encoding="utf-8") + result = runner.invoke( + app, + [ + "scan", + "-c", + str(manifest_dir / "release.gate"), + "--changed-files", + str(changed), + ], + ) + assert result.exit_code == 0, result.output + report = json.loads( + (manifest_dir / "agents-shipgate-reports" / "report.json").read_text() + ) + assert any( + finding["check_id"] == CHECK_ID + and finding["evidence"].get("changed_file") == "config/release.gate" + for finding in report["findings"] + ) + + +def test_scan_changed_files_maps_relative_config_from_a_git_subdirectory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "wk" + manifest_dir = workspace / "config" + shutil.copytree("samples/clean_read_only_agent", manifest_dir) + (manifest_dir / "shipgate.yaml").rename(manifest_dir / "release.gate") + (workspace / ".git").mkdir() + changed = tmp_path / "changed.txt" + changed.write_text("config/release.gate\n", encoding="utf-8") + monkeypatch.chdir(manifest_dir) + + result = runner.invoke( + app, + [ + "scan", + "-c", + "release.gate", + "--changed-files", + str(changed), + ], + ) + + assert result.exit_code == 0, result.output + report = json.loads( + (manifest_dir / "agents-shipgate-reports" / "report.json").read_text() + ) + assert any( + finding["check_id"] == CHECK_ID + and finding["evidence"].get("changed_file") == "config/release.gate" + for finding in report["findings"] + ) + + +def test_scan_changed_files_maps_a_nested_workspace_to_the_git_root( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + service = repository / "services" / "api" + shutil.copytree("samples/clean_read_only_agent", service) + (repository / ".git").mkdir() + changed = tmp_path / "changed.txt" + changed.write_text("services/api/shipgate.yaml\n", encoding="utf-8") + + result = runner.invoke( + app, + [ + "scan", + "--workspace", + str(service), + "--changed-files", + str(changed), + ], + ) + + assert result.exit_code == 0, result.output + report = json.loads( + (service / "agents-shipgate-reports" / "report.json").read_text() + ) + assert any( + finding["check_id"] == CHECK_ID + and finding["evidence"].get("changed_file") + == "services/api/shipgate.yaml" + for finding in report["findings"] + ) + + +@pytest.mark.parametrize("with_git_root", [False, True]) +def test_scan_changed_files_maps_config_through_repository_symlink( + tmp_path: Path, + with_git_root: bool, +) -> None: + repository = tmp_path / "repo" + shutil.copytree("samples/clean_read_only_agent", repository) + (repository / "shipgate.yaml").rename(repository / "release.gate") + if with_git_root: + (repository / ".git").mkdir() + alias = tmp_path / "repo-alias" + alias.symlink_to(repository, target_is_directory=True) + changed = tmp_path / "changed.txt" + changed.write_text("release.gate\n", encoding="utf-8") + + result = runner.invoke( + app, + [ + "scan", + "-c", + str(alias / "release.gate"), + "--changed-files", + str(changed), + ], + ) + + if not with_git_root: + assert result.exit_code == 2, result.output + assert "no repository root could be proven" in result.output + return + assert result.exit_code == 0, result.output + report = json.loads( + (repository / "agents-shipgate-reports" / "report.json").read_text() + ) + assert any( + finding["check_id"] == CHECK_ID + and finding["evidence"].get("changed_file") == "release.gate" + for finding in report["findings"] + ) + + +def test_scan_changed_files_rejects_ambiguous_absolute_custom_config_root( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + manifest_dir = repository / "config" + shutil.copytree("samples/clean_read_only_agent", manifest_dir) + (manifest_dir / "shipgate.yaml").rename(manifest_dir / "release.gate") + changed = tmp_path / "changed.txt" + changed.write_text("config/release.gate\n", encoding="utf-8") + + result = runner.invoke( + app, + [ + "scan", + "-c", + str(manifest_dir / "release.gate"), + "--changed-files", + str(changed), + ], + ) + + assert result.exit_code == 2, result.output + assert "no repository root could be proven" in result.output + + +def test_scan_changed_files_rejects_repository_internal_config_symlink( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + shutil.copytree("samples/clean_read_only_agent", repository) + (repository / "shipgate.yaml").rename(repository / "release.gate") + (repository / ".git").mkdir() + (repository / "gate-dir").symlink_to(".", target_is_directory=True) + changed = tmp_path / "changed.txt" + changed.write_text("gate-dir\n", encoding="utf-8") + + result = runner.invoke( + app, + [ + "scan", + "-c", + str(repository / "gate-dir" / "release.gate"), + "--changed-files", + str(changed), + ], + ) + + assert result.exit_code == 2, result.output + assert "--config must not contain repository symlink components" in result.output + + +@pytest.mark.parametrize("target_has_git", [False, True]) +def test_scan_changed_files_rejects_cross_repository_config_symlink( + tmp_path: Path, + target_has_git: bool, +) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + (source / ".git").mkdir() + shutil.copytree("samples/clean_read_only_agent", target) + (target / "shipgate.yaml").rename(target / "release.gate") + if target_has_git: + (target / ".git").mkdir() + (source / "gate-dir").symlink_to(target, target_is_directory=True) + changed = tmp_path / "changed.txt" + changed.write_text("gate-dir\n", encoding="utf-8") + + result = runner.invoke( + app, + [ + "scan", + "-c", + str(source / "gate-dir" / "release.gate"), + "--changed-files", + str(changed), + ], + ) + + assert result.exit_code == 2, result.output + assert "--config must not contain repository symlink components" in result.output + + +def test_scan_changed_files_prefers_non_git_cwd_over_symlink_target_repo( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + shutil.copytree("samples/clean_read_only_agent", target) + (target / "shipgate.yaml").rename(target / "release.gate") + (target / ".git").mkdir() + (source / "gate-dir").symlink_to(target, target_is_directory=True) + changed = tmp_path / "changed.txt" + changed.write_text("gate-dir\n", encoding="utf-8") + monkeypatch.chdir(source) + + result = runner.invoke( + app, + [ + "scan", + "-c", + "gate-dir/release.gate", + "--changed-files", + str(changed), + ], + ) + + assert result.exit_code == 2, result.output + assert "--config must not contain repository symlink components" in result.output + + +@pytest.mark.skipif( + sys.platform != "darwin", + reason="exercises the /var to /private/var filesystem alias on macOS", +) +def test_scan_changed_files_maps_config_through_var_alias( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + shutil.copytree("samples/clean_read_only_agent", repository) + (repository / "shipgate.yaml").rename(repository / "release.gate") + (repository / ".git").mkdir() + manifest = repository / "release.gate" + alias_text = str(manifest).replace("/private/var/", "/var/", 1) + if alias_text == str(manifest): + pytest.skip("temporary directory is not below /private/var") + alias = Path(alias_text) + try: + if not alias.is_file() or not alias.samefile(manifest): + pytest.skip("/var does not resolve to the same manifest") + except OSError: + pytest.skip("/var alias is unavailable") + changed = tmp_path / "changed.txt" + changed.write_text("release.gate\n", encoding="utf-8") + + result = runner.invoke( + app, + [ + "scan", + "-c", + str(alias), + "--changed-files", + str(changed), + ], + ) + + assert result.exit_code == 0, result.output + report = json.loads( + (repository / "agents-shipgate-reports" / "report.json").read_text() + ) + assert any( + finding["check_id"] == CHECK_ID + and finding["evidence"].get("changed_file") == "release.gate" + for finding in report["findings"] + ) + + def test_scan_without_changed_files_is_unchanged(tmp_path): shutil.copytree("samples/clean_read_only_agent", tmp_path / "wk") result = runner.invoke( @@ -206,6 +621,41 @@ def test_scan_without_changed_files_is_unchanged(tmp_path): assert [f for f in report["findings"] if f["check_id"] == CHECK_ID] == [] +@pytest.mark.parametrize("manifest_name", [" gate.yml", "gate\u2028prod.yml"]) +def test_scan_changed_files_preserves_exact_custom_manifest_names( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + manifest_name: str, +) -> None: + shutil.copytree("samples/clean_read_only_agent", tmp_path / "wk") + workspace = tmp_path / "wk" + manifest = workspace / manifest_name + (workspace / "shipgate.yaml").rename(manifest) + changed = tmp_path / "changed.txt" + changed.write_text(f"{manifest_name}\n", encoding="utf-8") + monkeypatch.chdir(workspace) + + result = runner.invoke( + app, + ["scan", "-c", str(manifest), "--changed-files", str(changed)], + ) + + assert result.exit_code == 0, result.output + report = json.loads( + (workspace / "agents-shipgate-reports" / "report.json").read_text( + encoding="utf-8" + ) + ) + findings = [ + finding + for finding in report["findings"] + if finding["check_id"] == CHECK_ID + ] + assert [finding["evidence"]["changed_file"] for finding in findings] == [ + manifest_name + ] + + # --- Reward-hacking guard: the trust-root finding cannot be silenced ------- @@ -222,7 +672,10 @@ def test_apply_suppressions_cannot_suppress_trust_root_finding(): assert findings[0].suppressed is False -def test_scan_ignore_entry_does_not_silence_trust_root(tmp_path): +def test_scan_ignore_entry_does_not_silence_trust_root( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): """End-to-end: shipgate.yaml adding `checks.ignore` for the trust-root check does not suppress it; the finding stays active and the gate still routes to review_required.""" @@ -236,6 +689,7 @@ def test_scan_ignore_entry_does_not_silence_trust_root(tmp_path): ) changed = tmp_path / "changed.txt" changed.write_text("shipgate.yaml\n", encoding="utf-8") + monkeypatch.chdir(tmp_path / "wk") result = runner.invoke( app, ["scan", "-c", str(manifest), "--changed-files", str(changed)] @@ -250,7 +704,10 @@ def test_scan_ignore_entry_does_not_silence_trust_root(tmp_path): assert report["release_decision"]["decision"] == "review_required" -def test_scan_severity_override_below_floor_is_rejected(tmp_path): +def test_scan_severity_override_below_floor_is_rejected( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): """Weakening the trust-root check below its medium floor via checks.severity_overrides is a hard ConfigError (exit 2), not a silent downgrade.""" @@ -264,6 +721,7 @@ def test_scan_severity_override_below_floor_is_rejected(tmp_path): ) changed = tmp_path / "changed.txt" changed.write_text("shipgate.yaml\n", encoding="utf-8") + monkeypatch.chdir(tmp_path / "wk") result = runner.invoke( app, ["scan", "-c", str(manifest), "--changed-files", str(changed)] diff --git a/tests/test_verify.py b/tests/test_verify.py index 5a67dbf2..78bb64af 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -2,7 +2,10 @@ import json import os +import shlex import subprocess +import sys +import unicodedata from pathlib import Path from typing import Any @@ -11,13 +14,15 @@ from agents_shipgate.cli.main import app from agents_shipgate.cli.verify import git as verify_git +from agents_shipgate.cli.verify import orchestrator as verify_orchestrator from agents_shipgate.cli.verify.capability_review import build_capability_review from agents_shipgate.cli.verify.fix_task import build_fix_task from agents_shipgate.cli.verify.git import carries_manifest_like_yaml, read_file_at_ref -from agents_shipgate.cli.verify.orchestrator import _prune_base_scan_cache +from agents_shipgate.cli.verify.orchestrator import _prune_base_scan_cache, _rerun_options from agents_shipgate.cli.verify.pr_comment import render_pr_comment from agents_shipgate.core.agent_control import derive_agent_control from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError +from agents_shipgate.core.trust_roots import PathIdentityIssue from agents_shipgate.report.json_report import report_json_payload from agents_shipgate.schemas.agent_control import HumanControlAction from agents_shipgate.schemas.capabilities import ( @@ -61,6 +66,247 @@ runner = CliRunner() +def test_verify_rejects_a_retargeted_tracked_config_symlink( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + repo = _init_repo(tmp_path) + _write_manifest(repo) + strict = repo / "strict.yml" + (repo / "shipgate.yaml").rename(strict) + weak = repo / "weak.yml" + weak.write_text(strict.read_text("utf-8"), encoding="utf-8") + (repo / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + gate = repo / "gate.yml" + gate.symlink_to("strict.yml") + _commit_all(repo, "tracked strict gate") + + gate.unlink() + gate.symlink_to("weak.yml") + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(repo), + "--config", + "gate.yml", + "--no-base", + "--json", + ], + ) + + assert result.exit_code == 2, result.output + assert "--config must not contain symlink components: gate.yml" in result.output + assert "weak.yml" not in result.output + assert not (repo / "agents-shipgate-reports" / "verifier.json").exists() + json_lines = [line for line in result.output.splitlines() if line.startswith("{")] + payload = json.loads(json_lines[-1]) + assert len(payload["next_actions"]) == 1 + action = payload["next_actions"][0] + assert action["kind"] == "review" + assert action["command"] is None + assert action["path"] is None + + +@pytest.mark.parametrize("flag", ["--policy-pack", "--baseline"]) +def test_ref_bound_verify_rejects_retargeted_static_input_symlinks( + tmp_path: Path, + flag: str, +) -> None: + repo = _init_repo(tmp_path) + _write_manifest(repo) + (repo / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + strict = repo / "strict-input.yml" + weak = repo / "weak-input.yml" + strict.write_text("version: 1\n", encoding="utf-8") + weak.write_text("version: 1\n", encoding="utf-8") + _commit_all(repo, "tracked verifier inputs") + + strict.unlink() + strict.symlink_to(weak.name) + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(repo), + "--head", + "HEAD", + "--no-base", + flag, + strict.name, + "--json", + ], + ) + + assert result.exit_code == 2, result.output + assert f"{flag} must not contain symlink components" in result.output + assert not (repo / "agents-shipgate-reports" / "verifier.json").exists() + + +def test_verify_rejects_a_filesystem_resolved_config_alias( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _init_repo(tmp_path) + _write_manifest(repo) + actual = repo / "new-gate.yml" + (repo / "shipgate.yaml").rename(actual) + (repo / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + _commit_all(repo, "custom manifest") + alias = repo / "NEW-GATE.yml" + real_lstat = Path.lstat + + def aliased_lstat(path: Path, *args, **kwargs): + if path == alias: + return real_lstat(actual, *args, **kwargs) + return real_lstat(path, *args, **kwargs) + + monkeypatch.setattr(Path, "lstat", aliased_lstat) + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(repo), + "--config", + alias.name, + "--no-base", + "--json", + ], + ) + + assert result.exit_code == 2, result.output + assert "--config must use the exact filesystem spelling" in result.output + assert "NEW-GATE.yml resolves to new-gate.yml" in result.output + assert not (repo / "agents-shipgate-reports" / "verifier.json").exists() + + +def test_archive_alias_allowance_requires_a_proven_same_entry( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + requested = tmp_path / "Gate.gate" + monkeypatch.setattr( + verify_orchestrator, + "inspect_lexical_path_identity", + lambda _root, _relative: PathIdentityIssue( + kind="alias", + requested=requested, + actual=None, + ), + ) + + with pytest.raises( + ConfigError, + match=r"must use the exact filesystem spelling", + ): + verify_orchestrator._reject_symlink_components( + tmp_path, + Path("Gate.gate"), + label="Head manifest Gate.gate", + allow_filesystem_alias=True, + ) + + +def test_tree_identity_rejects_portable_collision_even_on_an_exact_hit( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + (repo / "README.md").write_text("base\n", encoding="utf-8") + _commit_all(repo, "base") + parent = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + blob = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], + cwd=repo, + check=True, + input=b'version: "0.1"\n', + capture_output=True, + ).stdout.decode("ascii").strip() + tree_input = b"".join( + f"100644 blob {blob}\t{name}".encode() + b"\0" + for name in sorted(("GATE.yml", "gate.yml")) + ) + tree = subprocess.run( + ["git", "mktree", "-z"], + cwd=repo, + check=True, + input=tree_input, + capture_output=True, + ).stdout.decode("ascii").strip() + commit = subprocess.run( + ["git", "commit-tree", tree, "-p", parent, "-m", "portable collision"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + with pytest.raises(ConfigError, match="filesystem-colliding paths"): + verify_git.resolve_tree_path_identity(repo, commit, Path("gate.yml")) + + +def test_verify_accepts_absolute_config_under_direct_nested_workspace_alias( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + nested = repo / "services" / "api" + nested.mkdir(parents=True) + _write_manifest(repo) + (repo / "shipgate.yaml").rename(nested / "gate.yml") + (nested / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + _commit_all(repo, "nested manifest") + alias = tmp_path / "api-alias" + alias.symlink_to(nested, target_is_directory=True) + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(alias), + "--config", + str(alias / "gate.yml"), + "--no-base", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["head_status"] == "succeeded" + assert (repo / "agents-shipgate-reports" / "report.json").is_file() + + +def test_diff_context_retains_both_sides_of_a_rename(tmp_path: Path) -> None: + repo = _init_repo(tmp_path) + policy = repo / "policies" / "review.yml" + policy.parent.mkdir() + policy.write_text("review: required\n", encoding="utf-8") + _commit_all(repo, "policy") + subprocess.run( + ["git", "mv", "policies/review.yml", "retired.txt"], + cwd=repo, + check=True, + ) + _commit_all(repo, "retire policy") + + changed_files, _diff = verify_git.diff_context(repo, "HEAD~1", "HEAD") + + assert changed_files == ["policies/review.yml", "retired.txt"] + + def test_verify_manifest_present_force_runs_even_docs_only_diff(tmp_path: Path) -> None: repo = _repo_with_manifest(tmp_path) _set_origin_main(repo) @@ -101,6 +347,86 @@ def test_verify_manifest_present_force_runs_even_docs_only_diff(tmp_path: Path) assert payload["base_status"] == "succeeded" +def test_no_base_worktree_runs_exclude_their_own_outputs_from_identity( + tmp_path: Path, +) -> None: + repo = _repo_with_manifest(tmp_path) + (repo / "tools.json").write_text( + '{"tools":[{"name":"docs.lookup","description":"Read docs."}]}\n', + encoding="utf-8", + ) + args = [ + "verify", + "--workspace", + str(repo), + "--config", + "shipgate.yaml", + "--no-base", + "--json", + ] + + first = runner.invoke(app, args) + assert first.exit_code == 0, first.output + first_payload = json.loads(first.output) + first_diff = ( + repo / "agents-shipgate-reports" / "verification-input.diff" + ).read_bytes() + + second = runner.invoke(app, args) + assert second.exit_code == 0, second.output + second_payload = json.loads(second.output) + second_diff = ( + repo / "agents-shipgate-reports" / "verification-input.diff" + ).read_bytes() + + assert first_payload["changed_files"] == second_payload["changed_files"] + assert first_payload["changed_files"] == ["tools.json"] + assert not any( + path.startswith("agents-shipgate-reports/") + for path in second_payload["changed_files"] + ) + assert first_diff == second_diff + for field in ("request_id", "subject_id", "input_set_id", "decision_id"): + assert first_payload[field] == second_payload[field] + + +def test_verify_rejects_an_index_hidden_source_in_worktree_mode( + tmp_path: Path, +) -> None: + repo = _repo_with_manifest(tmp_path) + subprocess.run( + ["git", "update-index", "--assume-unchanged", "tools.json"], + cwd=repo, + check=True, + ) + (repo / "tools.json").write_text( + '{"tools":[{"name":"hidden.delete","description":"Delete data."}]}\n', + encoding="utf-8", + ) + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(repo), + "--config", + "shipgate.yaml", + "--no-base", + "--json", + ], + ) + + assert result.exit_code == 2, result.output + payload = json.loads(result.output) + assert payload["head_status"] == "failed" + assert payload["control"]["state"] == "human_review_required" + assert "Git index flags hide paths from worktree collection" in " ".join( + payload["base_notes"] + ) + assert not (repo / "agents-shipgate-reports" / "report.json").exists() + + def test_verify_missing_config_docs_only_diff_fails_closed(tmp_path: Path) -> None: repo = _init_repo(tmp_path) (repo / "README.md").write_text("base\n", encoding="utf-8") @@ -142,7 +468,8 @@ def test_verify_missing_config_docs_only_diff_fails_closed(tmp_path: Path) -> No assert payload["control"]["must_stop"] is False assert payload["control"]["human_review"]["required"] is False assert payload["control"]["next_action"]["command"] == ( - "agents-shipgate verify --preview --json" + f"agents-shipgate verify --workspace {repo} --config missing.yaml " + "--preview --base origin/main --head HEAD --json" ) assert (out_dir / "verifier.json").is_file() assert not (out_dir / "verify-run.json").exists() @@ -178,10 +505,54 @@ def test_verify_json_missing_config_emits_verifier_unknown(tmp_path: Path) -> No assert payload["head_exit_code"] == 2 assert payload["can_merge_without_human"] is False assert payload["control"]["state"] == "agent_action_required" + assert payload["control"]["next_action"]["command"] == ( + f"agents-shipgate verify --workspace {repo} --config missing.yaml " + "--preview --json" + ) assert "schema_version" not in payload assert not (repo / "agents-shipgate-reports" / "report.json").exists() +def test_missing_config_preview_recovery_preserves_custom_request( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + (repo / "README.md").write_text("base\n", encoding="utf-8") + _commit_all(repo, "base") + _set_origin_main(repo) + (repo / "README.md").write_text("head\n", encoding="utf-8") + _commit_all(repo, "head") + out = repo / "custom reports" + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(repo), + "--config", + "config/custom gate.yml", + "--base", + "origin/main", + "--head", + "HEAD", + "--out", + str(out), + "--pr-comment-style", + "findings", + "--json", + ], + ) + + assert result.exit_code == 2, result.output + payload = json.loads(result.output) + assert payload["control"]["next_action"]["command"] == ( + f"agents-shipgate verify --workspace {repo} " + "--config 'config/custom gate.yml' --preview --base origin/main " + f"--head HEAD --out '{out}' --pr-comment-style findings --json" + ) + + def test_verify_missing_config_relevant_diff_fails_before_head_scan( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -760,7 +1131,7 @@ def test_capability_review_pr_comment_unknown_when_head_scan_failed() -> None: assert "Head scan did not produce a report" in comment -def test_fix_task_structures_allowed_and_forbidden_repairs() -> None: +def test_worktree_fix_task_structures_allowed_and_forbidden_repairs() -> None: report = _report(decision="blocked", exit_code=20) finding = Finding( id="F-stale", @@ -801,6 +1172,7 @@ def test_fix_task_structures_allowed_and_forbidden_repairs() -> None: capability_review=VerifierCapabilityReview(), base_ref="origin/main", head_ref="HEAD", + worktree=True, ) assert task is not None @@ -1019,7 +1391,8 @@ def test_verify_base_scan_cache_hit_skips_second_base_scan( assert json.loads(first.output)["base_status"] == "succeeded" assert json.loads(second.output)["base_status"] == "succeeded" cache_path = json.loads(second.output)["base_report_json"] - assert "agents-shipgate-reports/.cache" not in cache_path + assert cache_path == "agents-shipgate-reports/verification-base-report.json" + assert (repo / cache_path).is_file() assert not (repo / "agents-shipgate-reports" / ".cache").exists() assert len(calls) == 3 assert calls[0]["config_path"] != calls[1]["config_path"] # base then head @@ -1384,6 +1757,47 @@ def test_read_file_at_ref_reads_single_blob(tmp_path: Path) -> None: assert read_file_at_ref(repo, "HEAD", Path("missing.json")) is None +def test_authorization_rerun_path_is_invocation_absolute_and_lexical( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + invocation_dir = tmp_path / "invocation" + invocation_dir.mkdir() + host_dir = tmp_path / "host" + host_dir.mkdir() + target = host_dir / "grant-target.json" + target.write_text("{}\n", encoding="utf-8") + link = host_dir / "grant.json" + link.symlink_to(target) + monkeypatch.chdir(invocation_dir) + + options = _rerun_options( + git_root=repo, + out_dir=repo / "agents-shipgate-reports", + pr_comment_style="capability-review", + base=None, + auto_base=False, + ci_mode=None, + fail_on=None, + baseline_path=None, + baseline_mode="new-findings", + diff_from=None, + policy_pack_paths=None, + plugins_enabled=None, + strict_plugins=False, + suggest_patches=False, + no_heuristics=False, + authorization=Path("../host/grant.json"), + ) + + tokens = shlex.split(" ".join(options)) + serialized = tokens[tokens.index("--authorization") + 1] + assert serialized == str(link) + assert serialized != str(target) + + def test_retained_manifest_probe_parses_quoted_flow_mapping(tmp_path: Path) -> None: repo = _init_repo(tmp_path) (repo / "old-gate.yml").write_text( @@ -1396,6 +1810,191 @@ def test_retained_manifest_probe_parses_quoted_flow_mapping(tmp_path: Path) -> N assert carries_manifest_like_yaml(repo, "HEAD") is True +def test_retained_manifest_probe_batches_large_small_file_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _init_repo(tmp_path) + for index in range(500): + (repo / f"source-{index:03d}.txt").write_text( + f"ordinary source {index}\n", + encoding="utf-8", + ) + (repo / "release.gate").write_text( + '{"version": "0.1", "project": {"name": "demo"}, ' + '"agent": {"name": "assistant"}}\n', + encoding="utf-8", + ) + _commit_all(repo, "large small-file tree") + calls: list[tuple[str, ...]] = [] + bounded_calls: list[tuple[str, ...]] = [] + original = verify_git._run_git + original_bounded = verify_git._run_git_bounded_output + + def recording_run_git(workspace, args, **kwargs): + calls.append(tuple(args)) + return original(workspace, args, **kwargs) + + def recording_bounded(workspace, args, **kwargs): + bounded_calls.append(tuple(args)) + return original_bounded(workspace, args, **kwargs) + + monkeypatch.setattr(verify_git, "_run_git", recording_run_git) + monkeypatch.setattr(verify_git, "_run_git_bounded_output", recording_bounded) + + assert ( + carries_manifest_like_yaml( + repo, + "HEAD", + protected_names=frozenset({"shipgate.yaml"}), + ) + is True + ) + assert [args[0] for args in bounded_calls] == ["ls-tree"] + assert [args[0] for args in calls] == ["rev-parse", "cat-file"] + assert calls[1][:2] == ("cat-file", "--batch") + + +def test_retained_manifest_name_guard_reuses_the_bounded_listing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _init_repo(tmp_path) + (repo / "shipgate.yaml").write_text("not: an operational manifest\n", encoding="utf-8") + _commit_all(repo, "named gate") + calls: list[tuple[str, ...]] = [] + bounded_calls: list[tuple[str, ...]] = [] + original = verify_git._run_git + original_bounded = verify_git._run_git_bounded_output + + def recording_run_git(workspace, args, **kwargs): + calls.append(tuple(args)) + return original(workspace, args, **kwargs) + + def recording_bounded(workspace, args, **kwargs): + bounded_calls.append(tuple(args)) + return original_bounded(workspace, args, **kwargs) + + monkeypatch.setattr(verify_git, "_run_git", recording_run_git) + monkeypatch.setattr(verify_git, "_run_git_bounded_output", recording_bounded) + + assert ( + carries_manifest_like_yaml( + repo, + "HEAD", + protected_names=frozenset({"shipgate.yaml"}), + ) + is True + ) + assert [args[0] for args in bounded_calls] == ["ls-tree"] + assert [args[0] for args in calls] == ["rev-parse"] + + +@pytest.mark.parametrize("filename", ["old-gate.json", "old-gate.conf", "MANIFEST"]) +def test_retained_manifest_probe_is_suffix_agnostic( + tmp_path: Path, + filename: str, +) -> None: + repo = _init_repo(tmp_path) + (repo / filename).write_text( + """ +version: "0.1" +project: + name: demo +agent: + name: assistant + declared_purpose: + - test +environment: + target: local +""".lstrip(), + encoding="utf-8", + ) + _commit_all(repo, "custom named manifest") + + assert carries_manifest_like_yaml(repo, "HEAD") is True + + +def test_retained_manifest_probe_treats_unknown_version_as_manifest_like( + tmp_path: Path, +) -> None: + """A future or legacy gate must conservatively suppress adoption wording.""" + + repo = _init_repo(tmp_path) + (repo / "old-policy.conf").write_text( + """ +version: "0.2" +project: + name: demo +agent: + name: assistant +""".lstrip(), + encoding="utf-8", + ) + _commit_all(repo, "unknown-version gate") + + assert carries_manifest_like_yaml(repo, "HEAD") is True + + +@pytest.mark.skipif( + sys.platform != "darwin", + reason="exercises Git NFC/worktree NFD spelling drift on macOS", +) +def test_explicit_head_nfd_manifest_adoption_keeps_adoption_wording( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + (repo / "README.md").write_text("base\n", encoding="utf-8") + (repo / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + _commit_all(repo, "base") + + nfc_name = "café.gate" + nfd_name = unicodedata.normalize("NFD", nfc_name) + assert nfd_name != nfc_name + _write_manifest(repo) + (repo / "shipgate.yaml").rename(repo / nfd_name) + _commit_all(repo, "adopt decomposed manifest") + + emitted = subprocess.run( + [ + "git", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "HEAD~1", + "HEAD", + ], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + if nfc_name not in emitted or nfd_name in emitted: + pytest.skip("Git did not precompose the configured filename") + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(repo), + "--config", + nfd_name, + "--base", + "HEAD~1", + "--head", + "HEAD", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert "also introduces the configured manifest" in payload["headline"] + assert "weakens the release policy" not in payload["headline"] + + def test_retained_manifest_probe_fails_closed_on_unparseable_yaml( tmp_path: Path, ) -> None: @@ -1406,6 +2005,71 @@ def test_retained_manifest_probe_fails_closed_on_unparseable_yaml( assert carries_manifest_like_yaml(repo, "HEAD") is None +def test_retained_manifest_probe_fails_closed_on_batch_read_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _init_repo(tmp_path) + (repo / "candidate.txt").write_text("ordinary text\n", encoding="utf-8") + _commit_all(repo, "candidate") + original = verify_git._run_git + + def failing_batch(workspace, args, **kwargs): + if args[:2] == ["cat-file", "--batch"]: + return subprocess.CompletedProcess(args, 1, stdout=b"", stderr=b"failed") + return original(workspace, args, **kwargs) + + monkeypatch.setattr(verify_git, "_run_git", failing_batch) + + assert carries_manifest_like_yaml(repo, "HEAD") is None + + +def test_retained_manifest_probe_fails_closed_on_batch_timeout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _init_repo(tmp_path) + (repo / "candidate.txt").write_text("ordinary text\n", encoding="utf-8") + _commit_all(repo, "candidate") + original = verify_git._run_git + + def timing_out_batch(workspace, args, **kwargs): + if args[:2] == ["cat-file", "--batch"]: + raise subprocess.TimeoutExpired(args, 60) + return original(workspace, args, **kwargs) + + monkeypatch.setattr(verify_git, "_run_git", timing_out_batch) + + assert carries_manifest_like_yaml(repo, "HEAD") is None + + +def test_retained_manifest_probe_skips_non_yaml_constructor_errors( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + (repo / "digits.txt").write_text("9" * 5_000, encoding="utf-8") + _commit_all(repo, "large integer-like source") + + assert carries_manifest_like_yaml(repo, "HEAD") is False + + +def test_bounded_git_output_stops_before_buffering_the_full_blob( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + (repo / "large.txt").write_text("x" * 32_000, encoding="utf-8") + _commit_all(repo, "large blob") + + assert ( + verify_git._run_git_bounded_output( + repo, + ["show", "HEAD:large.txt"], + max_output_bytes=128, + ) + is None + ) + + def test_retained_manifest_probe_rejects_oversized_blob_before_reading_it( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1426,8 +2090,20 @@ def recording_run_git(workspace, args, **kwargs): monkeypatch.setattr(verify_git, "_run_git", recording_run_git) assert carries_manifest_like_yaml(repo, "HEAD") is None - assert any(args[:2] == ("cat-file", "-s") for args in calls) - assert not any(args and args[0] == "show" for args in calls) + assert [args[0] for args in calls] == ["rev-parse"] + + +def test_retained_manifest_probe_fails_closed_above_candidate_bound( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _init_repo(tmp_path) + (repo / "one.txt").write_text("one\n", encoding="utf-8") + (repo / "two.txt").write_text("two\n", encoding="utf-8") + _commit_all(repo, "two candidates") + monkeypatch.setattr(verify_git, "_MAX_MANIFEST_CANDIDATES", 1) + + assert carries_manifest_like_yaml(repo, "HEAD") is None def test_retained_manifest_probe_fails_closed_on_non_utf8_yaml( @@ -1457,7 +2133,9 @@ def test_prune_base_scan_cache_keeps_newest_entries(tmp_path: Path) -> None: assert newest.exists() -def test_verify_preview_requires_no_manifest_and_exits_zero(tmp_path: Path) -> None: +def test_non_git_preview_with_omitted_config_uses_workspace_default( + tmp_path: Path, +) -> None: workspace = tmp_path / "fresh" workspace.mkdir() @@ -1478,6 +2156,130 @@ def test_verify_preview_requires_no_manifest_and_exits_zero(tmp_path: Path) -> N assert not (workspace / "shipgate.yaml").exists() +def test_verify_preview_rejects_output_that_overlaps_its_config( + tmp_path: Path, +) -> None: + workspace = tmp_path / "fresh" + workspace.mkdir() + config = workspace / "verifier.json" + original = '{"manifest": "must survive"}\n' + config.write_text(original, encoding="utf-8") + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(workspace), + "--config", + "verifier.json", + "--out", + ".", + "--preview", + "--format", + "json", + ], + ) + + assert result.exit_code == 2 + assert "Verifier --out cannot be the workspace root" in result.output + assert config.read_text(encoding="utf-8") == original + + +def test_verify_preview_rejects_a_manifest_directory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "fresh" + workspace.mkdir() + (workspace / "shipgate.yaml").mkdir() + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(workspace), + "--preview", + "--format", + "json", + ], + ) + + assert result.exit_code == 2, result.output + assert "must identify one singly-linked regular file" in result.output + payload = json.loads( + [line for line in result.output.splitlines() if line.startswith("{")][-1] + ) + assert payload["error"] == "config_error" + assert payload["next_actions"][0]["kind"] == "command" + assert " verify " in payload["next_actions"][0]["command"] + assert "--preview --json" in payload["next_actions"][0]["command"] + + +def test_verify_preview_rejects_a_symlinked_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "fresh" + workspace.mkdir() + (workspace / "target.yml").write_text('version: "0.1"\n', encoding="utf-8") + (workspace / "gate.yml").symlink_to("target.yml") + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(workspace), + "--config", + "gate.yml", + "--preview", + "--json", + ], + ) + + assert result.exit_code == 2, result.output + assert "--config must not contain symlink components" in result.output + payload = json.loads( + [line for line in result.output.splitlines() if line.startswith("{")][-1] + ) + assert [action["kind"] for action in payload["next_actions"]] == ["review"] + + +def test_verify_preview_rejects_an_external_absolute_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTS_SHIPGATE_AGENT_MODE", "1") + workspace = tmp_path / "fresh" + workspace.mkdir() + external = tmp_path / "external.yml" + external.write_text('version: "0.1"\n', encoding="utf-8") + + result = runner.invoke( + app, + [ + "verify", + "--workspace", + str(workspace), + "--config", + str(external), + "--preview", + "--json", + ], + ) + + assert result.exit_code == 2, result.output + assert "--config must be inside --workspace" in result.output + payload = json.loads( + [line for line in result.output.splitlines() if line.startswith("{")][-1] + ) + assert [action["kind"] for action in payload["next_actions"]] == ["review"] + + def test_verify_preview_docs_only_diff_does_not_recommend_init(tmp_path: Path) -> None: repo = _init_repo(tmp_path) (repo / "README.md").write_text("base\n", encoding="utf-8") @@ -1539,6 +2341,7 @@ def test_verify_preview_missing_base_without_manifest_recommends_init(tmp_path: assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload["mode"] == "preview" + assert payload["config"] == "shipgate.yaml" assert payload["control"]["state"] == "agent_action_required" assert payload["control"]["next_action"]["kind"] == "initialize" assert payload["control"]["next_action"]["command"] == ( @@ -1587,6 +2390,58 @@ def test_verify_preview_configured_repo_preserves_exact_verify_args(tmp_path: Pa ) +def test_nested_workspace_preview_command_verifies_the_same_manifest( + tmp_path: Path, +) -> None: + repo = _init_repo(tmp_path) + _write_manifest(repo) + root_gate = repo / "gate.yml" + (repo / "shipgate.yaml").rename(root_gate) + (repo / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + nested = repo / "services" / "api" + nested.mkdir(parents=True) + nested_gate = nested / "gate.yml" + nested_gate.write_text( + root_gate.read_text(encoding="utf-8").replace( + "name: test\n", + "name: nested-test\n", + 1, + ), + encoding="utf-8", + ) + (nested / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + _commit_all(repo, "root and nested gates") + + preview = runner.invoke( + app, + [ + "verify", + "--workspace", + str(nested), + "--config", + "gate.yml", + "--preview", + "--json", + ], + ) + + assert preview.exit_code == 0, preview.output + preview_payload = json.loads(preview.output) + assert preview_payload["config"] == "services/api/gate.yml" + command = preview_payload["control"]["next_action"]["command"] + assert command is not None + + verified = runner.invoke(app, shlex.split(command)[1:]) + + assert verified.exit_code == 0, verified.output + verified_payload = json.loads(verified.output) + assert verified_payload["config"] == "services/api/gate.yml" + report = json.loads( + (repo / "agents-shipgate-reports" / "report.json").read_text(encoding="utf-8") + ) + assert report["project"]["name"] == "nested-test" + + def test_verify_preview_configured_repo_missing_base_fetches_base(tmp_path: Path) -> None: repo = _init_repo(tmp_path) (repo / "shipgate.yaml").write_text('version: "0.1"\n', encoding="utf-8") diff --git a/tests/test_verify_orchestrator.py b/tests/test_verify_orchestrator.py index 1515353a..7157cccb 100644 --- a/tests/test_verify_orchestrator.py +++ b/tests/test_verify_orchestrator.py @@ -296,7 +296,7 @@ def test_verify_fails_closed_when_worktree_diff_cannot_be_collected(monkeypatch, _git(repo, "add", ".") _git(repo, "commit", "-m", "base") - def fail_worktree_context(_repo): + def fail_worktree_context(_repo, **_kwargs): raise RuntimeError("simulated worktree diff failure") monkeypatch.setattr( From 8e41eba6fbbc54005cd7bcb2aadfc494fead8de2 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Thu, 30 Jul 2026 23:05:15 -0700 Subject: [PATCH 11/12] Fix verifier input snapshot integrity --- src/agents_shipgate/cli/agent_result.py | 20 ++ src/agents_shipgate/cli/verify/git.py | 38 +++- .../cli/verify/orchestrator.py | 129 +++++++----- src/agents_shipgate/core/static_inputs.py | 15 ++ src/agents_shipgate/inputs/codex_config.py | 8 +- src/agents_shipgate/inputs/codex_plugin.py | 10 +- src/agents_shipgate/inputs/common.py | 68 +++++++ src/agents_shipgate/inputs/n8n/_adapter.py | 3 +- src/agents_shipgate/inputs/n8n/_workflows.py | 7 +- tests/test_adapter_static_only.py | 4 +- tests/test_static_inputs.py | 27 +++ tests/test_verification_git_snapshot.py | 20 +- tests/test_verify_orchestrator.py | 188 +++++++++++++++++- 13 files changed, 467 insertions(+), 70 deletions(-) create mode 100644 tests/test_static_inputs.py diff --git a/src/agents_shipgate/cli/agent_result.py b/src/agents_shipgate/cli/agent_result.py index d3006e8e..a6e68562 100644 --- a/src/agents_shipgate/cli/agent_result.py +++ b/src/agents_shipgate/cli/agent_result.py @@ -7,6 +7,7 @@ from typing import Any from agents_shipgate.cli.verify.git import ( + BinaryCapabilityDiffError, carries_manifest_like_yaml, read_file_at_ref, removes_a_yaml_file, @@ -544,6 +545,25 @@ def git_boundary_change_set( raise RuntimeError("Workspace is not inside a Git repository.") try: changed_paths, diff_text = _git_diff_context(revspec, cwd=workspace) + except BinaryCapabilityDiffError as exc: + return BoundaryChangeSet( + mode="git_range" if revspec else "worktree", + scope="repository", + completeness="partial", + diff_text=exc.diff_text, + changed_paths=exc.changed_paths, + issues=tuple( + BoundaryInputIssue( + code="boundary_input_binary", + path=path, + message=( + "A recognized tracked boundary or capability source is " + "binary and cannot be evaluated statically." + ), + ) + for path in exc.paths + ), + ) except ConfigError as exc: # Preserve trust-root/index-identity failures so ``shipgate check`` # keeps the typed exit-2 contract instead of degrading them into the diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index d6c0b69b..ce0303cf 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -46,6 +46,21 @@ } ) + +class BinaryCapabilityDiffError(ConfigError): + """A diff whose capability-like binary paths cannot be read statically.""" + + def __init__(self, paths: list[str]) -> None: + self.paths = tuple(sorted(paths)) + self.changed_paths: tuple[str, ...] = () + self.diff_text = "" + super().__init__( + "Git classified source-like changed paths as binary, so their " + "capability text cannot be evaluated statically: " + + ", ".join(self.paths[:3]) + ) + + _SAFE_DIFF_CONFIG = [ "-c", "core.fsmonitor=false", @@ -484,8 +499,13 @@ def diff_revspec_context(workspace: Path, revspec: str) -> tuple[list[str], str] if names is None or body is None: raise ConfigError("Git diff exceeded static output bounds or could not be read.") paths = sorted(_paths_from_name_status(names)) - _reject_binary_capability_paths(workspace, revspec) diff_text = _decode_diff_body(body) + try: + _reject_binary_capability_paths(workspace, revspec) + except BinaryCapabilityDiffError as exc: + exc.changed_paths = tuple(paths) + exc.diff_text = diff_text + raise return paths, diff_text @@ -539,8 +559,8 @@ def _reject_binary_capability_paths( "diff", *_DETERMINISTIC_DIFF_OPTIONS, "--no-renames", - "--numstat", "--no-patch", + "--numstat", "-z", revspec, *(["--", *pathspec] if pathspec else []), @@ -570,11 +590,7 @@ def _reject_binary_capability_paths( ): hidden.append(path) if hidden: - raise ConfigError( - "Git classified source-like changed paths as binary, so their " - "capability text cannot be evaluated statically: " - + ", ".join(sorted(hidden)[:3]) - ) + raise BinaryCapabilityDiffError(hidden) def read_file_at_ref(workspace: Path, ref: str, path: Path) -> str | None: @@ -915,8 +931,13 @@ def working_tree_context( "Git worktree diff exceeded static output bounds or could not be read." ) paths = sorted(_paths_from_name_status(names)) - _reject_binary_capability_paths(workspace, "HEAD", pathspec=pathspec) diff_text = _decode_diff_body(body) + try: + _reject_binary_capability_paths(workspace, "HEAD", pathspec=pathspec) + except BinaryCapabilityDiffError as exc: + exc.changed_paths = tuple(paths) + exc.diff_text = diff_text + raise untracked = _run_git_bounded_output( workspace, [ @@ -1538,6 +1559,7 @@ def staged_paths_under(workspace: Path, subdir: str) -> list[str]: __all__ = [ "active_replace_refs", "archive_tree", + "BinaryCapabilityDiffError", "commit_date", "commit_sha", "DefaultBaseDetection", diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index cd385f43..eba23248 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -562,40 +562,6 @@ def run_verify( ) return verifier, None, 0 - if diff_from is not None: - base_status = "diff_from_provided" - assert static_diff_from_path is not None - base_report = static_diff_from_path - base_notes.append(f"Using explicit diff reference: {_display_path(base_report, git_root)}") - elif base and base_exists: - ( - base_status, - base_tree, - base_report, - base_capability_lock, - cache_notes, - ) = _prepare_base_report( - git_root=git_root, - base=base, - config_relative=config_relative, - baseline_path=baseline_path, - policy_packs=policy_pack_paths or [], - plugins_enabled=plugins_enabled, - no_heuristics=no_heuristics, - verbose=verbose, - evaluation_date=verification_date, - ) - base_notes.extend(cache_notes) - - manifest_introduced = _manifest_introduced( - git_root=git_root, - config_relative=config_relative, - base_status=base_status, - base=base, - head=head, - changed_files=changed_files, - ) - report: ReadinessReport | None = None head_status = "failed" head_exit_code = 4 @@ -613,23 +579,22 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: nonlocal head_capability_lock head_capability_lock = lock - static_snapshot: StaticInputSnapshot | None = None - static_snapshot_token = None + external_snapshot_paths = [ + path + for path in [ + baseline_path, + *list(policy_pack_paths or []), + static_diff_from_path, + ] + if path is not None + ] + static_snapshot = StaticInputSnapshot( + git_root, + external_paths=external_snapshot_paths, + excluded_paths=[out_dir], + ) if not archive_head: assert worktree_manifest_text is not None - external_snapshot_paths = [ - path - for path in [ - baseline_path, - *list(policy_pack_paths or []), - static_diff_from_path, - ] - if path is not None - ] - static_snapshot = StaticInputSnapshot( - git_root, - external_paths=external_snapshot_paths, - ) static_snapshot.preload( config_path, worktree_manifest_text.encode("utf-8"), @@ -660,7 +625,47 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: f"Changed worktree input {relative!r} could not be captured " f"for verification: {exc}" ) from exc - static_snapshot_token = activate_static_input_snapshot(static_snapshot) + static_snapshot_token = activate_static_input_snapshot(static_snapshot) + + try: + if diff_from is not None: + base_status = "diff_from_provided" + assert static_diff_from_path is not None + base_report = static_diff_from_path + base_notes.append( + f"Using explicit diff reference: {_display_path(base_report, git_root)}" + ) + elif base and base_exists: + ( + base_status, + base_tree, + base_report, + base_capability_lock, + cache_notes, + ) = _prepare_base_report( + git_root=git_root, + base=base, + config_relative=config_relative, + baseline_path=baseline_path, + policy_packs=policy_pack_paths or [], + plugins_enabled=plugins_enabled, + no_heuristics=no_heuristics, + verbose=verbose, + evaluation_date=verification_date, + ) + base_notes.extend(cache_notes) + + manifest_introduced = _manifest_introduced( + git_root=git_root, + config_relative=config_relative, + base_status=base_status, + base=base, + head=head, + changed_files=changed_files, + ) + except Exception: + reset_static_input_snapshot(static_snapshot_token) + raise try: if archive_head: @@ -1027,12 +1032,12 @@ def _cache_report_path( if baseline_path is not None else None ), - "sha256": _sha256_file(baseline_path), + "sha256": _static_input_sha256(baseline_path), }, "policy_packs": [ { "path": _display_path(path.resolve(), git_root), - "sha256": _sha256_file(path), + "sha256": _static_input_sha256(path), } for path in policy_packs ], @@ -2268,13 +2273,21 @@ def _write_artifacts( ) for path in policy_pack_paths ] + baseline_was_captured = ( + baseline_path is not None + and ( + active_snapshot.has(baseline_path) + if active_snapshot is not None and active_snapshot.contains(baseline_path) + else baseline_path.is_file() + ) + ) portable_baseline_path = ( _write_portable_static_input( baseline_path, root=external_input_root, category="baseline", ) - if baseline_path is not None and baseline_path.is_file() + if baseline_path is not None and baseline_was_captured else None ) logical_config = config_logical_path or ( @@ -2595,6 +2608,16 @@ def _sha256_file(path: Path | None) -> str | None: return digest.hexdigest() +def _static_input_sha256(path: Path | None) -> str | None: + """Hash the same cached bytes consumed by a verification scan.""" + + if path is None or not path.is_file(): + return None + return hashlib.sha256( + read_static_input_bytes(path, max_bytes=64 * 1024 * 1024) + ).hexdigest() + + def _write_capability_review_artifacts( *, git_root: Path, diff --git a/src/agents_shipgate/core/static_inputs.py b/src/agents_shipgate/core/static_inputs.py index 685039c5..87bd8b3f 100644 --- a/src/agents_shipgate/core/static_inputs.py +++ b/src/agents_shipgate/core/static_inputs.py @@ -30,6 +30,7 @@ def __init__( root: Path, *, external_paths: Iterable[Path] = (), + excluded_paths: Iterable[Path] = (), max_total_bytes: int = DEFAULT_STATIC_INPUT_TOTAL_BYTES, max_files: int = DEFAULT_STATIC_INPUT_FILES, ) -> None: @@ -40,6 +41,10 @@ def __init__( Path(os.path.abspath(os.path.normpath(os.fspath(path)))) for path in external_paths } + self._excluded_paths = { + Path(os.path.abspath(os.path.normpath(os.fspath(path)))) + for path in excluded_paths + } self._entries: dict[Path, bytes] = {} self._total_bytes = 0 self._budget = IdentityReadBudget( @@ -68,12 +73,22 @@ def preload(self, path: Path, data: bytes) -> None: def contains(self, path: Path) -> bool: raw = path if path.is_absolute() else self.root / path key = Path(os.path.abspath(os.path.normpath(os.fspath(raw)))) + if self.excludes(key): + return False try: key.relative_to(self.root) except ValueError: return key in self._external_paths return key != self.root + def excludes(self, path: Path) -> bool: + raw = path if path.is_absolute() else self.root / path + key = Path(os.path.abspath(os.path.normpath(os.fspath(raw)))) + return any( + key == excluded or excluded in key.parents + for excluded in self._excluded_paths + ) + def has(self, path: Path) -> bool: raw = path if path.is_absolute() else self.root / path key = Path(os.path.abspath(os.path.normpath(os.fspath(raw)))) diff --git a/src/agents_shipgate/inputs/codex_config.py b/src/agents_shipgate/inputs/codex_config.py index ca96267b..1a56f191 100644 --- a/src/agents_shipgate/inputs/codex_config.py +++ b/src/agents_shipgate/inputs/codex_config.py @@ -4,7 +4,11 @@ from typing import ClassVar, Literal from agents_shipgate.core.artifact_models import CodexBoundaryArtifacts -from agents_shipgate.inputs.common import manifest_relative_path, resolve_input_path +from agents_shipgate.inputs.common import ( + manifest_relative_path, + resolve_input_path, + walk_input_tree, +) from agents_shipgate.inputs.mcp_manifest import load_codex_config_mcp_sources from agents_shipgate.inputs.protocol import LoadedAdapterResult from agents_shipgate.schemas.manifest import AgentsShipgateManifest, ToolSourceConfig @@ -43,7 +47,7 @@ def _collect_codex_boundary_artifacts(root: Path, base_dir: Path) -> CodexBounda if not root.exists(): artifact.warnings.append(f"Codex config root does not exist: {root}") return artifact - for path in sorted(root.rglob("*")): + for path in walk_input_tree(root): if not path.is_file(): continue rel = _relative(path, root) diff --git a/src/agents_shipgate/inputs/codex_plugin.py b/src/agents_shipgate/inputs/codex_plugin.py index f0b4dda3..5fdccfe3 100644 --- a/src/agents_shipgate/inputs/codex_plugin.py +++ b/src/agents_shipgate/inputs/codex_plugin.py @@ -13,6 +13,7 @@ from agents_shipgate.inputs.common import ( PositionIndex, json_pointer_escape, + list_input_directory, load_structured_file, load_structured_file_with_positions, load_text_file, @@ -867,7 +868,14 @@ def _resolve_plugin_path(root: Path, raw_path: str) -> Path: def _skill_files(path: Path) -> list[Path]: if path.name == "SKILL.md": return [path] - return sorted(child for child in path.glob("*/SKILL.md") if child.is_file()) + skill_files: list[Path] = [] + for child in list_input_directory(path): + if not child.is_dir(): + continue + for candidate in list_input_directory(child): + if candidate.name == "SKILL.md" and candidate.is_file(): + skill_files.append(candidate) + return sorted(skill_files) def _skill_frontmatter(text: str) -> dict[str, str]: diff --git a/src/agents_shipgate/inputs/common.py b/src/agents_shipgate/inputs/common.py index 06a51839..0fbebebf 100644 --- a/src/agents_shipgate/inputs/common.py +++ b/src/agents_shipgate/inputs/common.py @@ -3,6 +3,7 @@ import json import os import re +import stat from collections.abc import Iterator from pathlib import Path from typing import Any @@ -88,6 +89,73 @@ def resolve_input_path(base_dir: Path, value: str) -> Path: return resolved +def list_input_directory(directory: Path) -> list[Path]: + """List one directory through the active identity-bound snapshot.""" + + lexical = Path(os.path.abspath(os.path.normpath(os.fspath(directory)))) + snapshot = active_static_input_snapshot() + if snapshot is None or ( + lexical != snapshot.root and not snapshot.contains(lexical) + ): + try: + return sorted(lexical.iterdir()) + except OSError as exc: + raise InputParseError( + f"Input directory {lexical} could not be inspected safely: {exc}" + ) from exc + try: + names = snapshot.bind_directory(lexical) + except (OSError, ValueError) as exc: + raise InputParseError( + f"Input directory {lexical} could not be captured safely: {exc}" + ) from exc + return [lexical / name for name in names] + + +def walk_input_tree(root: Path) -> list[Path]: + """Return a deterministic recursive inventory bound to the active snapshot. + + ``Path.rglob`` discovers descendants without exposing which directories it + traversed. A verifier snapshot must bind every one of those directory + inventories, including empty directories, so a relevant file cannot appear + after enumeration without invalidating the run. + """ + + snapshot = active_static_input_snapshot() + lexical_root = Path(os.path.abspath(os.path.normpath(os.fspath(root)))) + + pending = [lexical_root] + paths: list[Path] = [] + visited_entries = 0 + while pending: + directory = pending.pop() + children = list_input_directory(directory) + visited_entries += len(children) + if snapshot is not None and visited_entries > snapshot.max_files: + raise InputParseError( + "Input directory tree exceeds the " + f"{snapshot.max_files}-entry snapshot limit: {lexical_root}" + ) + + child_directories: list[Path] = [] + for child in children: + try: + metadata = child.lstat() + except OSError as exc: + raise InputParseError( + f"Input directory entry {child} could not be inspected safely: {exc}" + ) from exc + paths.append(child) + if ( + stat.S_ISDIR(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and not (snapshot is not None and snapshot.excludes(child)) + ): + child_directories.append(child) + pending.extend(reversed(sorted(child_directories))) + return sorted(paths) + + def manifest_relative_path(value: str, base_dir: Path) -> str: """Return ``value`` as a forward-slash, manifest-relative POSIX path. diff --git a/src/agents_shipgate/inputs/n8n/_adapter.py b/src/agents_shipgate/inputs/n8n/_adapter.py index 35807c48..fa42abfc 100644 --- a/src/agents_shipgate/inputs/n8n/_adapter.py +++ b/src/agents_shipgate/inputs/n8n/_adapter.py @@ -30,6 +30,7 @@ from agents_shipgate.inputs.common import ( load_structured_file, resolve_input_path, + walk_input_tree, ) from agents_shipgate.inputs.mcp import load_mcp_tools from agents_shipgate.inputs.n8n._common import ( @@ -183,7 +184,7 @@ def _artifact_paths( sorted( ( item - for item in path.rglob("*") + for item in walk_input_tree(path) if item.is_file() and item.suffix.lower() in {".json", ".yaml", ".yml"} and not _skip_path(item, path) diff --git a/src/agents_shipgate/inputs/n8n/_workflows.py b/src/agents_shipgate/inputs/n8n/_workflows.py index 85b6b20b..9e99a812 100644 --- a/src/agents_shipgate/inputs/n8n/_workflows.py +++ b/src/agents_shipgate/inputs/n8n/_workflows.py @@ -37,6 +37,7 @@ json_pointer_escape, load_structured_file, resolve_input_path, + walk_input_tree, ) from agents_shipgate.inputs.n8n._auth_risk import _record_credentials from agents_shipgate.inputs.n8n._common import ( @@ -139,7 +140,11 @@ def _workflow_paths(path: Path, base_dir: Path) -> list[Path]: if not path.is_dir(): raise InputParseError(f"n8n workflow source must be a file or directory: {path}") return sorted( - (candidate for candidate in path.rglob("*.json") if candidate.is_file()), + ( + candidate + for candidate in walk_input_tree(path) + if candidate.suffix == ".json" and candidate.is_file() + ), key=lambda item: _display_path(item, base_dir), ) diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index a8f5be7e..07617077 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -257,7 +257,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.Popen", - line=1383, + line=1404, snippet=( "subprocess.Popen(cmd, env=env, stderr=subprocess.DEVNULL, " "stdin=subprocess.PIPE if input is not None else " @@ -274,7 +274,7 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=1493, + line=1514, snippet=( "subprocess.run(cmd, capture_output=capture_output, check=check, " "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " diff --git a/tests/test_static_inputs.py b/tests/test_static_inputs.py new file mode 100644 index 00000000..161b0ac8 --- /dev/null +++ b/tests/test_static_inputs.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agents_shipgate.core.static_inputs import ( + StaticInputSnapshot, + activate_static_input_snapshot, + reset_static_input_snapshot, +) +from agents_shipgate.inputs.common import walk_input_tree + + +def test_recursive_input_inventory_rejects_late_nested_file(tmp_path: Path) -> None: + source = tmp_path / "source" + nested = source / "nested" + nested.mkdir(parents=True) + snapshot = StaticInputSnapshot(tmp_path) + token = activate_static_input_snapshot(snapshot) + try: + assert walk_input_tree(source) == [nested] + (nested / "late.json").write_text("{}\n", encoding="utf-8") + with pytest.raises(ValueError, match="changed (?:identity|entries)"): + snapshot.finish() + finally: + reset_static_input_snapshot(token) diff --git a/tests/test_verification_git_snapshot.py b/tests/test_verification_git_snapshot.py index d41bb8cc..f676e9d1 100644 --- a/tests/test_verification_git_snapshot.py +++ b/tests/test_verification_git_snapshot.py @@ -6,7 +6,11 @@ import pytest -from agents_shipgate.cli.verify.git import archive_tree, repository_identity +from agents_shipgate.cli.verify.git import ( + archive_tree, + diff_revspec_context, + repository_identity, +) from agents_shipgate.core.errors import ConfigError @@ -88,6 +92,20 @@ def test_exact_snapshot_rejects_blob_bytes_that_do_not_match_their_oid(tmp_path: archive_tree(root, "HEAD", tmp_path / "snapshot") +def test_diff_rejects_binary_source_like_paths(tmp_path: Path) -> None: + root = _repo(tmp_path) + source = root / "agent.py" + source.write_text("print('base')\n", encoding="utf-8") + _git(root, "add", "agent.py") + _git(root, "commit", "-m", "base") + source.write_bytes(b"\x00binary capability payload\n") + _git(root, "add", "agent.py") + _git(root, "commit", "-m", "binary head") + + with pytest.raises(ConfigError, match="source-like changed paths as binary"): + diff_revspec_context(root, "HEAD~1...HEAD") + + def test_repository_identity_normalizes_ssh_and_https_remotes(tmp_path: Path) -> None: root = _repo(tmp_path) _git(root, "remote", "add", "origin", "git@GitHub.com:ThreeMoonsLab/shipgate.git") diff --git a/tests/test_verify_orchestrator.py b/tests/test_verify_orchestrator.py index 7157cccb..910a7474 100644 --- a/tests/test_verify_orchestrator.py +++ b/tests/test_verify_orchestrator.py @@ -10,13 +10,16 @@ import pytest from agents_shipgate.cli.verification import assemble, worker +from agents_shipgate.cli.verify import orchestrator as verify_orchestrator from agents_shipgate.cli.verify.git import commit_date from agents_shipgate.cli.verify.orchestrator import run_verify -from agents_shipgate.core.errors import ConfigError +from agents_shipgate.core.errors import ConfigError, InputParseError +from agents_shipgate.core.static_inputs import StaticInputSnapshot from agents_shipgate.core.verification_identity import ( build_terminal_receipt, validate_receipt_artifacts, ) +from agents_shipgate.schemas.baseline import BaselineFile from agents_shipgate.schemas.verification_identity import ( VerificationPlan, VerificationReceipt, @@ -104,6 +107,189 @@ def test_verify_backdated_commit_cannot_extend_expired_override(tmp_path: Path) ) +def test_archived_verify_rejects_external_baseline_change_after_scan( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = tmp_path / "repo" + sample_dst = repo / "samples" / "support_refund_agent" + sample_dst.parent.mkdir(parents=True) + shutil.copytree(REPO_ROOT / "samples" / "support_refund_agent", sample_dst) + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.test") + _git(repo, "config", "user.name", "Test User") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "fixture") + + baseline_path = tmp_path / "external-baseline.json" + baseline_path.write_text( + BaselineFile( + created_at="2026-01-01T00:00:00Z", + source_report_run_id="baseline-test", + ).model_dump_json(indent=2) + + "\n", + encoding="utf-8", + ) + real_run_scan = verify_orchestrator.run_scan + changed = False + + def mutate_baseline_after_scan(**kwargs): + nonlocal changed + result = real_run_scan(**kwargs) + if not changed: + changed = True + baseline_path.write_text("{}\n", encoding="utf-8") + return result + + monkeypatch.setattr(verify_orchestrator, "run_scan", mutate_baseline_after_scan) + + with pytest.raises( + InputParseError, + match="Verification inputs changed while they were being evaluated", + ): + run_verify( + workspace=repo, + config=Path("samples/support_refund_agent/shipgate.yaml"), + base=None, + head="HEAD", + archive_head=True, + out=repo / "agents-shipgate-reports", + ci_mode="advisory", + fail_on=None, + baseline=baseline_path, + baseline_mode="new-findings", + diff_from=None, + policy_packs=None, + plugins_enabled=False, + strict_plugins=False, + suggest_patches=False, + no_heuristics=False, + verbose=False, + ) + + +def test_finalized_snapshot_keeps_deleted_baseline_in_plan( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = tmp_path / "repo" + sample_dst = repo / "samples" / "support_refund_agent" + sample_dst.parent.mkdir(parents=True) + shutil.copytree(REPO_ROOT / "samples" / "support_refund_agent", sample_dst) + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.test") + _git(repo, "config", "user.name", "Test User") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "fixture") + + baseline_path = tmp_path / "external-baseline.json" + baseline_bytes = ( + BaselineFile( + created_at="2026-01-01T00:00:00Z", + source_report_run_id="baseline-test", + ).model_dump_json(indent=2) + + "\n" + ).encode("utf-8") + baseline_path.write_bytes(baseline_bytes) + real_finish = StaticInputSnapshot.finish + deleted = False + + def finish_then_delete(snapshot: StaticInputSnapshot) -> None: + nonlocal deleted + real_finish(snapshot) + if not deleted and snapshot.has(baseline_path): + deleted = True + baseline_path.unlink() + + monkeypatch.setattr(StaticInputSnapshot, "finish", finish_then_delete) + out_dir = repo / "agents-shipgate-reports" + + _verifier, report, _exit_code = run_verify( + workspace=repo, + config=Path("samples/support_refund_agent/shipgate.yaml"), + base=None, + head="HEAD", + archive_head=True, + out=out_dir, + ci_mode="advisory", + fail_on=None, + baseline=baseline_path, + baseline_mode="new-findings", + diff_from=None, + policy_packs=None, + plugins_enabled=False, + strict_plugins=False, + suggest_patches=False, + no_heuristics=False, + verbose=False, + ) + + assert report is not None + assert deleted is True + plan = VerificationPlan.model_validate( + json.loads((out_dir / "verification-plan.json").read_text(encoding="utf-8")) + ) + assert plan.inputs.baseline is not None + portable_baseline = out_dir / plan.inputs.baseline.path + assert portable_baseline.read_bytes() == baseline_bytes + + +def test_worktree_recursive_source_excludes_verifier_output(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".codex").mkdir() + (repo / ".codex" / "config.toml").write_text( + "[features]\nresponses_websockets = true\n", + encoding="utf-8", + ) + (repo / "shipgate.yaml").write_text( + """ +version: "0.1" +project: + name: root-config +agent: + name: root-agent + declared_purpose: + - inspect repository config +environment: + target: local +tool_sources: + - id: codex + type: codex_config + path: . +""".lstrip(), + encoding="utf-8", + ) + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.test") + _git(repo, "config", "user.name", "Test User") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "fixture") + + _verifier, report, _exit_code = run_verify( + workspace=repo, + config=Path("shipgate.yaml"), + base=None, + head="HEAD", + archive_head=False, + out=repo / "agents-shipgate-reports", + ci_mode="advisory", + fail_on=None, + baseline=None, + baseline_mode="new-findings", + diff_from=None, + policy_packs=None, + plugins_enabled=False, + strict_plugins=False, + suggest_patches=False, + no_heuristics=False, + verbose=False, + ) + + assert report is not None + assert (repo / "agents-shipgate-reports" / "verification-plan.json").is_file() + + def test_verify_threads_changed_files_into_head_scan(tmp_path): repo = tmp_path / "repo" sample_dst = repo / "samples" / "support_refund_agent" From 4e3dae8ea658add047d0fc59a5837e7984b7dfea Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Thu, 30 Jul 2026 23:19:03 -0700 Subject: [PATCH 12/12] Preserve archived verifier input scope --- src/agents_shipgate/inputs/common.py | 4 +++- tests/test_static_inputs.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/agents_shipgate/inputs/common.py b/src/agents_shipgate/inputs/common.py index 0fbebebf..a1b401af 100644 --- a/src/agents_shipgate/inputs/common.py +++ b/src/agents_shipgate/inputs/common.py @@ -72,7 +72,9 @@ def resolve_input_path(base_dir: Path, value: str) -> Path: f"Input path {value!r} does not identify one exact, non-aliased " f"worktree entry: {issue.kind}" ) - if lexical.is_dir(): + if lexical.is_dir() and ( + lexical == snapshot.root or snapshot.contains(lexical) + ): try: snapshot.bind_directory(lexical) except (OSError, ValueError) as exc: diff --git a/tests/test_static_inputs.py b/tests/test_static_inputs.py index 161b0ac8..6f5380b5 100644 --- a/tests/test_static_inputs.py +++ b/tests/test_static_inputs.py @@ -9,7 +9,7 @@ activate_static_input_snapshot, reset_static_input_snapshot, ) -from agents_shipgate.inputs.common import walk_input_tree +from agents_shipgate.inputs.common import resolve_input_path, walk_input_tree def test_recursive_input_inventory_rejects_late_nested_file(tmp_path: Path) -> None: @@ -25,3 +25,19 @@ def test_recursive_input_inventory_rejects_late_nested_file(tmp_path: Path) -> N snapshot.finish() finally: reset_static_input_snapshot(token) + + +def test_outer_snapshot_does_not_capture_immutable_archive_directory( + tmp_path: Path, +) -> None: + worktree = tmp_path / "worktree" + worktree.mkdir() + archive = tmp_path / "archive" + source = archive / "plugins" + source.mkdir(parents=True) + snapshot = StaticInputSnapshot(worktree) + token = activate_static_input_snapshot(snapshot) + try: + assert resolve_input_path(archive, "plugins") == source.resolve() + finally: + reset_static_input_snapshot(token)