diff --git a/CHANGELOG.md b/CHANGELOG.md index 452c3cbc..acc8a623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- **Zero-base verification now fails closed.** Omitting `--base` no longer + silently turns an unresolved default branch into a head/worktree-only scan. + The resolver prefers an authoritative `origin/HEAD`, accepts one + unambiguous `origin/main`/`origin/master` commit, and uses local + `main`/`master` only to prove that HEAD is already at the default commit — + never as an implicit comparison base. A missing remote ref routes + `control.state=agent_action_required` with `next_action.kind=fetch_base`; + ambiguous or divergent candidates route `human_review_required`. Both exit + 2 before `run_scan`, remove stale scan/receipt artifacts, and retain failed + verifier, plan, unit, verify-run, handoff, and PR-comment evidence with no + release decision. Only an explicit `--no-base` requests intentional + head/worktree-only verification. Shallow missing-history failures now route + to fetch, while unrelated non-shallow histories remain human-owned. - **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", diff --git a/README.md b/README.md index cd59037c..709f9f90 100644 --- a/README.md +++ b/README.md @@ -174,14 +174,21 @@ agents-shipgate verify --workspace . --config shipgate.yaml \ --ci-mode advisory --format json --base origin/main --head HEAD ``` -For local, uncommitted work, omit `--base`/`--head` so your working-tree edits -are scanned instead: +For local, uncommitted work, omit `--base`/`--head`. Verify scans the worktree +only after it safely selects a remote default or proves HEAD is already at the +default commit: ```bash agents-shipgate verify --workspace . --config shipgate.yaml \ --ci-mode advisory --format json ``` +If that committed comparison scope cannot be proved, verify exits 2 before +the head scan and returns nonterminal control: fetch missing remote history or +ask a human to select `--base `. Use `--no-base` only when a person or +calling workflow intentionally chooses head/worktree-only verification; a +coding agent must not add it merely to clear the failure. + If a repo is not configured yet, use the verify flow's preview entry point: ```bash @@ -654,7 +661,7 @@ artifacts — in read order: | Code | Meaning | |---|---| | `0` | Pass (advisory mode or strict-no-blockers) | -| `2` | Manifest config error | +| `2` | Configuration/input-scope error, including an unresolved comparison base | | `3` | Input parse error (file missing, malformed, path traversal blocked) | | `4` | Other Agents Shipgate error | | `20` | Strict-mode gate failure | diff --git a/STABILITY.md b/STABILITY.md index 46716b0f..ca38400e 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -495,6 +495,13 @@ changes only by bumping `contract_version` and updating this file. | `agents-shipgate self-check` | `--json` | | `agents-shipgate agent handoff` | `--from`, `--report`, `--verify-run`, `--out`, `--json` | +For `agents-shipgate verify`, omitting `--base` means safe auto-resolution, +not an implicit head-only scan. The verifier selects an available trustworthy +remote default, proves HEAD already equals the default, or exits 2 before +scanning with nonterminal control. Only explicit `--no-base` selects +head/worktree-only verification. This is a fail-closed clarification of the +stable flags, not a command-surface or top-level schema change. + ### Provisional CLI command surface The org/fleet governance commands are preview surfaces in the current diff --git a/docs/agent-contract-current.md b/docs/agent-contract-current.md index 9d0e0f53..6205a1ae 100644 --- a/docs/agent-contract-current.md +++ b/docs/agent-contract-current.md @@ -284,7 +284,17 @@ Markdown/HTML/PDF rendering. Read `verifier.json.base_status` to understand whether base diff enrichment ran; do not use it as a release verdict. The release gate is still `report.json.release_decision.decision`. `verify` never fetches, so CI callers -must make the base ref available before invocation. Supplying `--head` makes +must make the base ref available before invocation. When `--base` is omitted, +verify resolves one safe default-branch state: `selected`, `head_at_default`, +`fetch_required`, or `selection_required`. The first two may proceed; the last +two exit 2 before the head scan with no `release_decision`. Fetchable missing +history produces `control.state="agent_action_required"` and +`next_action.kind="fetch_base"`; ambiguous selection produces +`human_review_required` with no allowed command. `verification-plan.json` +records the additive audit options `base_mode`, `base_resolution`, and +`resolved_default_ref`. Only explicit `--no-base` sets intentional +head/worktree-only scope; coding agents must not invent it as recovery. +Supplying `--head` makes verify scan an isolated archive of that ref; omitting it scans the checked-out workspace. If an explicit `--base` ref or PR diff cannot be inspected, verify skips a head-only scan; `verifier.json.merge_verdict` is `unknown` and the diff --git a/llms-full.txt b/llms-full.txt index bc83396a..18df1295 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1308,7 +1308,17 @@ Markdown/HTML/PDF rendering. Read `verifier.json.base_status` to understand whether base diff enrichment ran; do not use it as a release verdict. The release gate is still `report.json.release_decision.decision`. `verify` never fetches, so CI callers -must make the base ref available before invocation. Supplying `--head` makes +must make the base ref available before invocation. When `--base` is omitted, +verify resolves one safe default-branch state: `selected`, `head_at_default`, +`fetch_required`, or `selection_required`. The first two may proceed; the last +two exit 2 before the head scan with no `release_decision`. Fetchable missing +history produces `control.state="agent_action_required"` and +`next_action.kind="fetch_base"`; ambiguous selection produces +`human_review_required` with no allowed command. `verification-plan.json` +records the additive audit options `base_mode`, `base_resolution`, and +`resolved_default_ref`. Only explicit `--no-base` sets intentional +head/worktree-only scope; coding agents must not invent it as recovery. +Supplying `--head` makes verify scan an isolated archive of that ref; omitting it scans the checked-out workspace. If an explicit `--base` ref or PR diff cannot be inspected, verify skips a head-only scan; `verifier.json.merge_verdict` is `unknown` and the diff --git a/src/agents_shipgate/cli/verify/command.py b/src/agents_shipgate/cli/verify/command.py index c8a317e2..fe637e40 100644 --- a/src/agents_shipgate/cli/verify/command.py +++ b/src/agents_shipgate/cli/verify/command.py @@ -69,15 +69,17 @@ def verify( "omitted, verify auto-detects the default branch (origin/HEAD, " "origin/main, origin/master) if it points at a different commit " "than the head. Local main/master are used only when passed " - "explicitly; --no-base disables auto-detection." + "explicitly. If trustworthy comparison scope cannot be proved, " + "verify exits 2 without running a head-only scan." ), ), no_base: bool = typer.Option( False, "--no-base", help=( - "Disable base auto-detection when --base is omitted; scan only " - "the working tree or explicit head." + "Explicitly choose intentional head/worktree-only verification " + "when --base is omitted. This is the only opt-out from safe " + "auto-detection." ), ), head: str | None = typer.Option( @@ -201,9 +203,7 @@ def verify( 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") + 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 '-', " diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index ce0303cf..20585092 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -56,8 +56,7 @@ def __init__(self, paths: list[str]) -> None: 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]) + "capability text cannot be evaluated statically: " + ", ".join(self.paths[:3]) ) @@ -127,12 +126,21 @@ def ensure_git_workspace(workspace: Path) -> Path: REMOTE_BASE_CANDIDATES = ("origin/main", "origin/master") LOCAL_BASE_CANDIDATES = ("main", "master") +DefaultBaseResolution = Literal[ + "selected", + "head_at_default", + "fetch_required", + "selection_required", +] @dataclass(frozen=True) class DefaultBaseDetection: base: str | None notes: list[str] + state: DefaultBaseResolution + default_ref: str | None = None + candidates: tuple[str, ...] = () @dataclass(frozen=True) @@ -268,48 +276,186 @@ def _normalized_sha_hint(value: str | None, *, label: str) -> str | None: def detect_default_base(workspace: Path, head: str = "HEAD") -> str | None: - """Best-effort default base ref for PR-style diff enrichment. - - Tries the remote default branch (``origin/HEAD``) first, then remote - conventional candidates (``origin/main``, ``origin/master``). A - candidate qualifies only when it exists locally and points at a - different commit than ``head`` — diffing a branch against itself adds - scan cost without diff signal. Local ``main``/``master`` are never - selected implicitly because they are often stale in CI and worktrees; - pass ``--base main`` explicitly when that is intended. Never fetches; - this only reads refs that already exist in the checkout. + """Return a safely selected default base ref for PR-style verification. + + A ``None`` return is intentionally lossy compatibility: callers that need + to distinguish "the head is already the default" from "comparison scope + is unresolved" must use :func:`detect_default_base_with_notes` and switch + on its ``state``. Verification does that and fails closed for unresolved + states. This function never fetches or implicitly selects a local branch. """ return detect_default_base_with_notes(workspace, head).base def detect_default_base_with_notes(workspace: Path, head: str = "HEAD") -> DefaultBaseDetection: - """Return the implicit base plus warnings for skipped local defaults.""" + """Resolve the implicit comparison scope without guessing or fetching. + + ``origin/HEAD`` is authoritative when it exists, even when its target is + not available locally. Without it, conventional remote refs may establish + one unambiguous default commit. Local ``main``/``master`` refs can prove + only that ``head`` is already at a default commit; they are never selected + as an implicit comparison base. + """ head_sha = commit_sha(workspace, head) if head_sha is None: - return DefaultBaseDetection(base=None, notes=[]) - candidates: list[str] = [] - origin_head = _run_git(workspace, ["rev-parse", "--abbrev-ref", "origin/HEAD"], check=False) - if origin_head.returncode == 0: - name = origin_head.stdout.strip() - if name and name != "origin/HEAD": - candidates.append(name) - candidates.extend(c for c in REMOTE_BASE_CANDIDATES if c not in candidates) - selected_base: str | None = None - selected_base_sha: str | None = None - for candidate in candidates: - sha = commit_sha(workspace, candidate) - if sha is not None and sha != head_sha: - selected_base = candidate - selected_base_sha = sha - break - notes = _skipped_local_base_notes( + return DefaultBaseDetection( + base=None, + notes=[f"Head ref {head!r} is not available locally."], + state="selection_required", + ) + + origin_head = _run_git( workspace, - head_sha, - selected_base_sha=selected_base_sha, + ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + check=False, ) - return DefaultBaseDetection(base=selected_base, notes=notes) + if origin_head.returncode == 0: + target = _remote_ref_display(origin_head.stdout.strip()) + if target is None: + return DefaultBaseDetection( + base=None, + notes=[ + "origin/HEAD does not target a refs/remotes/origin/* ref; " + "select --base explicitly." + ], + state="selection_required", + candidates=("origin/HEAD",), + ) + target_sha = commit_sha(workspace, target) + if target_sha is None: + return DefaultBaseDetection( + base=None, + notes=[ + f"origin/HEAD targets {target!r}, but that commit is not " + "available locally. Fetch the remote default branch and rerun verify." + ], + state="fetch_required", + default_ref=target, + candidates=(target,), + ) + notes = _skipped_local_base_notes( + workspace, + head_sha, + selected_base_sha=target_sha, + ) + if target_sha == head_sha: + return DefaultBaseDetection( + base=None, + notes=notes, + state="head_at_default", + default_ref=target, + candidates=(target,), + ) + return DefaultBaseDetection( + base=target, + notes=notes, + state="selected", + default_ref=target, + candidates=(target,), + ) + + remote_refs = [ + (candidate, sha) + for candidate in REMOTE_BASE_CANDIDATES + if (sha := commit_sha(workspace, candidate)) is not None + ] + remote_commits = {sha for _, sha in remote_refs} + if len(remote_commits) > 1: + candidates = tuple(candidate for candidate, _ in remote_refs) + return DefaultBaseDetection( + base=None, + notes=[ + "origin/main and origin/master resolve to different commits, so " + "Shipgate cannot infer which branch is the intended comparison base." + ], + state="selection_required", + candidates=candidates, + ) + if remote_refs: + selected, selected_sha = remote_refs[0] + notes = _skipped_local_base_notes( + workspace, + head_sha, + selected_base_sha=selected_sha, + ) + candidates = tuple(candidate for candidate, _ in remote_refs) + if selected_sha == head_sha: + return DefaultBaseDetection( + base=None, + notes=notes, + state="head_at_default", + default_ref=selected, + candidates=candidates, + ) + return DefaultBaseDetection( + base=selected, + notes=notes, + state="selected", + default_ref=selected, + candidates=candidates, + ) + + if _origin_is_configured(workspace): + return DefaultBaseDetection( + base=None, + notes=[ + "The origin remote is configured, but no remote default branch ref " + "is available locally. Fetch the remote default branch and rerun verify." + ], + state="fetch_required", + ) + + local_refs = [ + (candidate, sha) + for candidate in LOCAL_BASE_CANDIDATES + if (sha := commit_sha(workspace, candidate)) is not None + ] + local_candidates = tuple(candidate for candidate, _ in local_refs) + if local_refs and all(sha == head_sha for _, sha in local_refs): + selected = local_refs[0][0] + return DefaultBaseDetection( + base=None, + notes=[], + state="head_at_default", + default_ref=selected, + candidates=local_candidates, + ) + if local_refs: + return DefaultBaseDetection( + base=None, + notes=[ + "Only local default-branch refs are available and at least one differs " + "from HEAD. Shipgate will not guess from potentially stale local refs; " + "select --base explicitly." + ], + state="selection_required", + candidates=local_candidates, + ) + return DefaultBaseDetection( + base=None, + notes=[ + "No remote or local default-branch evidence is available. Select --base " + "explicitly, or use --no-base only for intentional head/worktree-only verification." + ], + state="selection_required", + ) + + +def _remote_ref_display(ref: str) -> str | None: + prefix = "refs/remotes/" + if not ref.startswith(prefix): + return None + display = ref[len(prefix) :] + if not display.startswith("origin/") or display == "origin/HEAD": + return None + return display + + +def _origin_is_configured(workspace: Path) -> bool: + result = _run_git(workspace, ["remote", "get-url", "origin"], check=False) + return result.returncode == 0 and bool(result.stdout.strip()) def _skipped_local_base_notes( @@ -380,6 +526,17 @@ def merge_base_sha(workspace: Path, base: str, head: str) -> str | None: return result.stdout.strip() or None +def is_shallow_repository(workspace: Path) -> bool: + """Return whether Git considers the checkout shallow.""" + + result = _run_git( + workspace, + ["rev-parse", "--is-shallow-repository"], + check=False, + ) + return result.returncode == 0 and result.stdout.strip() == "true" + + def commit_date(workspace: Path, ref: str) -> str: commit = commit_sha(workspace, ref) if commit is None: @@ -569,8 +726,7 @@ def _reject_binary_capability_paths( ) if payload is None: raise ConfigError( - "Git binary-path metadata exceeded static output bounds or could " - "not be read." + "Git binary-path metadata exceeded static output bounds or could not be read." ) hidden: list[str] = [] for record in payload.split(b"\0"): @@ -639,15 +795,10 @@ def resolve_tree_path_identity( ) if listing is None: raise ConfigError( - "Git tree path identity could not be established within static " - "resource bounds." + "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 - ] + 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) @@ -670,6 +821,7 @@ def resolve_tree_path_identity( # 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. @@ -773,9 +925,7 @@ def carries_manifest_like_yaml( ["cat-file", "--batch"], check=False, text=False, - input=b"".join( - object_id + b"\n" for object_id, _path, _size in candidates - ), + input=b"".join(object_id + b"\n" for object_id, _path, _size in candidates), ) except (OSError, subprocess.TimeoutExpired): return None @@ -793,11 +943,7 @@ def carries_manifest_like_yaml( 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 - ): + if object_id != expected_id or object_type != b"blob" or byte_count != expected_size: return None content_start = header_end + 1 content_end = content_start + byte_count @@ -820,9 +966,7 @@ def carries_manifest_like_yaml( # 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 - }: + if isinstance(document, dict) and _MANIFEST_REQUIRED_KEYS <= {str(key) for key in document}: return True if offset != len(output): return None @@ -927,9 +1071,7 @@ def working_tree_context( 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." - ) + raise ConfigError("Git worktree diff exceeded static output bounds or could not be read.") paths = sorted(_paths_from_name_status(names)) diff_text = _decode_diff_body(body) try: @@ -952,9 +1094,7 @@ def working_tree_context( max_output_bytes=_DIFF_METADATA_LIMIT, ) if untracked is None: - raise ConfigError( - "Git untracked-path inventory exceeded static output bounds." - ) + raise ConfigError("Git untracked-path inventory exceeded static output bounds.") for raw_path in untracked.split(b"\0"): if not raw_path: continue @@ -1000,9 +1140,7 @@ def _reject_index_hidden_capability_paths( max_output_bytes=_DIFF_METADATA_LIMIT, ) if payload is None: - raise ConfigError( - "Git index-visibility metadata exceeded static output bounds." - ) + raise ConfigError("Git index-visibility metadata exceeded static output bounds.") hidden: list[str] = [] for record in payload.split(b"\0"): if not record: @@ -1016,8 +1154,7 @@ def _reject_index_hidden_capability_paths( hidden.append(os.fsdecode(raw_path)) if hidden: raise ConfigError( - "Git index flags hide paths from worktree collection: " - + ", ".join(sorted(hidden)[:3]) + "Git index flags hide paths from worktree collection: " + ", ".join(sorted(hidden)[:3]) ) @@ -1075,9 +1212,7 @@ def _reject_executable_worktree_filters(workspace: Path) -> None: "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 - ] + 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( @@ -1128,9 +1263,7 @@ def _reject_unbound_diff_configuration(workspace: Path) -> None: except FileNotFoundError: return except OSError as exc: - raise ConfigError( - "Could not inspect repository-local Git attributes safely." - ) from 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 " @@ -1242,8 +1375,7 @@ def _materialize_isolated_tree(git_dir: Path, *, commit: str, destination: Path) prior = portable_paths.setdefault(portable_key, path_text) if prior != path_text: raise ConfigError( - "Git tree contains filesystem-colliding paths: " - f"{prior!r} and {path_text!r}" + f"Git tree contains filesystem-colliding paths: {prior!r} and {path_text!r}" ) if object_type != "blob" or mode in {"120000", "160000"}: raise ConfigError( @@ -1347,11 +1479,7 @@ def _resolved_diff_revspec(workspace: Path, revspec: str) -> str: def _git_object_environment() -> dict[str, str]: - env = { - key: value - for key, value in os.environ.items() - if not key.startswith("GIT_") - } + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} env.update( { "GIT_ATTR_NOSYSTEM": "1", @@ -1446,11 +1574,7 @@ def _write_stdin() -> None: except (BrokenPipeError, OSError): write_failed = True - writer = ( - threading.Thread(target=_write_stdin, daemon=True) - if input is not None - else None - ) + writer = threading.Thread(target=_write_stdin, daemon=True) if input is not None else None if writer is not None: writer.start() try: @@ -1465,12 +1589,7 @@ def _write_stdin() -> None: reader.join() if writer is not None: writer.join() - if ( - returncode not in allowed_returncodes - or exceeded - or read_failed - or write_failed - ): + if returncode not in allowed_returncodes or exceeded or read_failed or write_failed: return None return bytes(output) @@ -1570,6 +1689,7 @@ def staged_paths_under(workspace: Path, subdir: str) -> list[str]: "ensure_git_workspace", "git_path", "GitPushEndpoint", + "is_shallow_repository", "merge_base_sha", "read_file_at_ref", "repository_identity", diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index eba23248..f805dec1 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -107,6 +107,7 @@ diff_context, ensure_git_workspace, git_path, + is_shallow_repository, merge_base_sha, read_file_at_ref, ref_exists, @@ -153,6 +154,9 @@ def run_verify( authorization: Path | None = None, ) -> tuple[VerifierArtifact, ReadinessReport | None, int]: git_root = ensure_git_workspace(workspace.resolve()) + base_mode = "explicit" if base is not None else ("auto" if auto_base else "disabled") + base_resolution = "selected" if base is not None else ("pending" if auto_base else "disabled") + resolved_default_ref: str | None = None config_path, config_relative = _resolve_config_under_workspace( git_root, config, @@ -196,11 +200,7 @@ def run_verify( ("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 [] - ), + *([("diff-from", static_diff_from_path)] if static_diff_from_path is not None else []), ], ) out_dir.mkdir(parents=True, exist_ok=True) @@ -306,7 +306,7 @@ def run_verify( base_capability_lock: CapabilityLockFileV1 | None = None base_notes: list[str] = [] diff_unavailable = False - diff_failure_action: HumanControlAction | None = None + diff_failure_action: AgentControlAction | None = None head_exists = ref_exists(git_root, head) if not head_exists: @@ -389,32 +389,140 @@ def run_verify( if base is None and auto_base: detection = detect_default_base_with_notes(git_root, head) base_notes.extend(detection.notes) - if detection.base is not None: + base_resolution = detection.state + resolved_default_ref = detection.default_ref + if detection.state == "selected": + assert detection.base is not None base = detection.base base_notes.append( f"Auto-detected base {detection.base!r} for diff context; " "pass --base to override or --no-base to disable." ) + elif detection.state in {"fetch_required", "selection_required"}: + unresolved_base = ( + detection.default_ref + if detection.state == "fetch_required" and detection.default_ref + else None + ) + trigger = evaluate( + paths=[], + diff_text="", + manifest_present=True, + user_requested=True, + ) + headline = ( + "Shipgate could not bind a trustworthy comparison base, so it " + "did not run a head-only scan." + ) + if detection.state == "fetch_required": + action: AgentControlAction = CodingAgentFetchBaseAction( + kind="fetch_base", + expects=(detection.default_ref or "the remote default branch"), + why=( + "Make the remote default branch available locally, then rerun " + "verify without adding --no-base." + ), + ) + else: + action = HumanControlAction( + kind="stop", + why=( + "A human must select the intended --base , or explicitly " + "choose --no-base only when head/worktree-only verification is " + "the intended scope." + ), + ) + verifier = _build_verifier( + git_root=git_root, + config_path=config_path, + base=unresolved_base, + head=head, + changed_files=[], + diff_text="", + trigger=trigger, + base_status="ref_missing", + base_tree=None, + base_report=None, + base_notes=base_notes, + report=None, + head_status="failed", + head_exit_code=2, + out_dir=out_dir, + ci_mode=ci_mode, + headline_override=headline, + first_next_action_override=action, + worktree=not archive_head, + rerun_options=rerun_options, + ) + _assert_base_input_failure(verifier) + _remove_scan_artifacts(out_dir) + _write_artifacts( + verifier, + verifier_path, + verify_run_path, + pr_comment_path, + 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, + no_heuristics=no_heuristics, + fail_on=fail_on, + pr_comment_style=pr_comment_style, + verification_options={ + "base_mode": base_mode, + "base_resolution": base_resolution, + "resolved_default_ref": resolved_default_ref, + }, + evaluation_date=verification_date, + ) + return verifier, None, 2 base_exists = False if base: base_exists = ref_exists(git_root, base) if base_exists: - try: - changed_files, diff_text = diff_context(git_root, base, head) - except Exception as exc: # noqa: BLE001 - diff context degrades only. + if merge_base_sha(git_root, base, head) is None: diff_unavailable = True base_status = "archive_failed" - detail = f"Could not collect diff for {base}...{head}: {exc}" + detail = f"Could not find a merge base for {base}...{head}" 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." - ), - ) + if is_shallow_repository(git_root): + diff_failure_action = CodingAgentFetchBaseAction( + kind="fetch_base", + expects=base, + why=( + f"{detail} in a shallow repository. Fetch enough history " + "to establish the merge base, then rerun verify." + ), + ) + else: + diff_failure_action = HumanControlAction( + kind="review", + why=( + f"{detail}. The repository is not shallow, so a human " + "must confirm the intended comparison refs or unrelated-history " + "boundary before rerunning." + ), + ) + else: + try: + changed_files, diff_text = diff_context(git_root, base, head) + except Exception as exc: # noqa: BLE001 - diff context degrades only. + diff_unavailable = True + base_status = "archive_failed" + 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 and merge base 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" @@ -503,6 +611,8 @@ def run_verify( worktree=not archive_head, rerun_options=rerun_options, ) + if verifier.base_status in {"ref_missing", "archive_failed"}: + _assert_base_input_failure(verifier) _write_artifacts( verifier, verifier_path, @@ -518,6 +628,12 @@ def run_verify( no_heuristics=no_heuristics, fail_on=fail_on, pr_comment_style=pr_comment_style, + verification_options={ + "base_mode": base_mode, + "base_resolution": base_resolution, + "resolved_default_ref": resolved_default_ref, + }, + evaluation_date=verification_date, ) return verifier, None, 2 @@ -559,6 +675,12 @@ def run_verify( no_heuristics=no_heuristics, fail_on=fail_on, pr_comment_style=pr_comment_style, + verification_options={ + "base_mode": base_mode, + "base_resolution": base_resolution, + "resolved_default_ref": resolved_default_ref, + }, + evaluation_date=verification_date, ) return verifier, None, 0 @@ -600,11 +722,7 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: worktree_manifest_text.encode("utf-8"), ) for relative in changed_files: - candidate = Path( - os.path.abspath( - os.path.normpath(os.fspath(git_root / relative)) - ) - ) + candidate = Path(os.path.abspath(os.path.normpath(os.fspath(git_root / relative)))) try: metadata = candidate.lstat() except FileNotFoundError: @@ -811,7 +929,10 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: authorization_path=authorization, verification_options={ "archive_head": archive_head, + "base_mode": base_mode, + "base_resolution": base_resolution, "baseline_mode": baseline_mode, + "resolved_default_ref": resolved_default_ref, "strict_plugins": strict_plugins, "suggest_patches": suggest_patches, "evaluated_head_commit_sha": (os.getenv("EVALUATED_HEAD_SHA") or None), @@ -1253,9 +1374,7 @@ def _rerun_options( # 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(str(authorization_path))] - ) + options.extend(["--authorization", shlex.quote(str(authorization_path))]) return options @@ -1413,11 +1532,7 @@ def _self_approval_note( # 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 - ): + if manifest_introduced and pure_adoption_review and not capability_review.policy_weakened: manifest = ( f"the configured manifest {configured_manifest!r}" if configured_manifest @@ -1472,9 +1587,7 @@ def _evidence_gap_identities(payload: object) -> Counter[tuple[str, str]] | None def _stable_subject(subject: str) -> str: """A subject with run-specific absolute paths collapsed.""" - return _VOLATILE_PATH_RE.sub( - lambda match: f"/…/{PurePosixPath(match.group(0)).name}", subject - ) + return _VOLATILE_PATH_RE.sub(lambda match: f"/…/{PurePosixPath(match.group(0)).name}", subject) def _gap_provenance_note( @@ -1498,15 +1611,12 @@ def _gap_provenance_note( if coverage is None or not coverage.evidence_gaps: return None head = Counter( - (str(gap.kind), _stable_subject(str(gap.subject or ""))) - for gap in coverage.evidence_gaps + (str(gap.kind), _stable_subject(str(gap.subject or ""))) for gap in coverage.evidence_gaps ) if base_report is None or not base_report.is_file(): return None try: - base = _evidence_gap_identities( - json.loads(base_report.read_text(encoding="utf-8")) - ) + base = _evidence_gap_identities(json.loads(base_report.read_text(encoding="utf-8"))) except (OSError, UnicodeDecodeError, json.JSONDecodeError): return None if base is None: @@ -1529,8 +1639,7 @@ def _gap_provenance_note( if scaffolded: subset = "all of them" if scaffolded == total else f"{scaffolded} of them" remedy = ( - f" A one-time human declaration closes {subset} " - f"({SUGGESTED_DECLARATIONS_FILENAME})." + f" A one-time human declaration closes {subset} ({SUGGESTED_DECLARATIONS_FILENAME})." ) else: remedy = "" @@ -1654,16 +1763,9 @@ def _derive_verifier_control( ) if fix_task is not None and fix_task.actor == "coding_agent" and fix_task.safe_to_attempt: - repair_commands = [ - repair.command - for repair in fix_task.allowed_repairs - if repair.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 - ): + 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") @@ -1842,9 +1944,7 @@ def _build_verifier( base_tree_sha=base_tree, head_tree_sha=head_tree, base_report_json=( - artifacts.get("verification_base_report_json") - 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] @@ -1954,6 +2054,28 @@ def _remove_scan_artifacts(out_dir: Path) -> None: path.unlink() +def _assert_base_input_failure(verifier: VerifierArtifact) -> None: + """Reject any failed base-input route that accidentally carries a gate.""" + + if not ( + verifier.execution == "failed" + and verifier.head_status == "failed" + and verifier.head_exit_code == 2 + and verifier.base_status in {"ref_missing", "archive_failed"} + and verifier.release_decision is None + and verifier.decision is None + and verifier.merge_verdict == "unknown" + and verifier.applicability == "failed" + and not verifier.can_merge_without_human + and verifier.fix_task is None + and not verifier.control.completion_allowed + ): + raise ValueError( + "base-input recovery must be a failed, nonterminal verifier without " + "release-decision or merge authority" + ) + + def _clear_trusted_handoff(out_dir: Path) -> None: """Remove every prior terminal/projection artifact before a new run.""" @@ -2048,9 +2170,7 @@ def _evaluate_authorization_overlay( ) if plan.inputs.options.get("plugins_enabled") is not False: return ( - AuthorizationEvaluationV1.not_applicable( - "authorization_requires_plugins_disabled" - ), + AuthorizationEvaluationV1.not_applicable("authorization_requires_plugins_disabled"), None, ) try: @@ -2177,9 +2297,7 @@ def _write_artifacts( 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" - ) + 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, @@ -2252,15 +2370,10 @@ def _write_artifacts( 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 + 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 - ] + [path for path in active_snapshot.paths() if path not in original_static_inputs] if active_snapshot is not None else None ) @@ -2273,13 +2386,10 @@ 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() - ) + 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( @@ -2613,9 +2723,7 @@ def _static_input_sha256(path: Path | None) -> str | None: 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() + return hashlib.sha256(read_static_input_bytes(path, max_bytes=64 * 1024 * 1024)).hexdigest() def _write_capability_review_artifacts( @@ -2721,9 +2829,7 @@ def _resolve_static_input_path( 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}" - ) + raise ConfigError(f"{label} must identify one singly-linked regular file: {path}") return lexical @@ -2742,15 +2848,11 @@ def _resolve_config_under_workspace( """ requested_anchor = ( - requested_workspace.resolve() - if requested_workspace is not None - else workspace + 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))) - ) + 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: @@ -2833,12 +2935,9 @@ def _reject_symlink_components( else "a differently spelled filesystem entry" ) raise ConfigError( - f"{label} must use the exact filesystem spelling: " - f"{requested} resolves to {actual}" + f"{label} must use the exact filesystem spelling: {requested} resolves to {actual}" ) - raise ConfigError( - f"{label} could not be inspected safely: {requested}: {issue.detail}" - ) + raise ConfigError(f"{label} could not be inspected safely: {requested}: {issue.detail}") def _dedupe_paths(paths: list[str]) -> list[str]: @@ -2909,8 +3008,7 @@ def _project_archived_report_paths( ) from exc if patch_target != archived_target: raise ConfigError( - "Archived suggested patch targets an unsupported file: " - f"{target_file}" + f"Archived suggested patch targets an unsupported file: {target_file}" ) patch.target_file = str(checkout_target) @@ -2998,8 +3096,7 @@ def _reject_output_input_overlap( except ValueError: continue raise ConfigError( - f"Verifier --out overlaps the {label} input at " - f"{_display_path(candidate, git_root)}." + f"Verifier --out overlaps the {label} input at {_display_path(candidate, git_root)}." ) diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index 07617077..d4ab7268 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=1404, + line=1532, 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=1514, + line=1633, 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_verify_auto_base.py b/tests/test_verify_auto_base.py index 11481195..fc7f38a5 100644 --- a/tests/test_verify_auto_base.py +++ b/tests/test_verify_auto_base.py @@ -1,11 +1,10 @@ """Base auto-detection for zero-flag ``agents-shipgate verify``. When ``--base`` is omitted, verify auto-detects the default branch so the -capability diff exists without the nine-flag canonical incantation. The -detection never fetches, considers only remote refs, and only fires when the -detected ref points at a different commit than the head — diffing a branch -against itself adds scan cost without diff signal. Local ``main``/``master`` -must be passed explicitly. ``--no-base`` restores the pure working-tree mode. +capability diff exists without the nine-flag canonical incantation. Detection +never fetches and never selects a local ``main``/``master`` implicitly. It must +either select a trustworthy remote ref, prove the head is already at the +default, or stop before scanning. ``--no-base`` is the only explicit opt-out. """ from __future__ import annotations @@ -14,6 +13,7 @@ import re import subprocess from pathlib import Path +from unittest.mock import patch from typer.testing import CliRunner @@ -109,6 +109,12 @@ def _feature_repo_with_local_main_equal_origin_main(tmp_path: Path) -> Path: return repo +def _feature_repo_with_only_local_main(tmp_path: Path) -> Path: + repo = _feature_repo_with_local_main_equal_origin_main(tmp_path) + _git(repo, "update-ref", "-d", "refs/remotes/origin/main") + return repo + + def _repo_with_stale_local_main_and_origin_main_head(tmp_path: Path) -> Path: repo = _init_repo(tmp_path) (repo / "shipgate.yaml").write_text( @@ -175,10 +181,9 @@ def test_does_not_auto_detect_local_main_from_feature_branch(tmp_path: Path) -> assert detect_default_base(repo) is None detection = detect_default_base_with_notes(repo) assert detection.base is None - assert any( - "Skipped local base 'main'" in note and "--base main" in note - for note in detection.notes - ) + assert detection.state == "selection_required" + assert detection.candidates == ("main",) + assert any("select --base explicitly" in note for note in detection.notes) def test_warns_when_stale_local_main_is_skipped(tmp_path: Path) -> None: @@ -187,10 +192,10 @@ def test_warns_when_stale_local_main_is_skipped(tmp_path: Path) -> None: detection = detect_default_base_with_notes(repo) assert detection.base is None + assert detection.state == "head_at_default" + assert detection.default_ref == "origin/main" assert any( - "Skipped local base 'main'" in note - and "origin/main" in note - and "--base main" in note + "Skipped local base 'main'" in note and "origin/main" in note and "--base main" in note for note in detection.notes ) @@ -203,12 +208,70 @@ def test_does_not_warn_when_local_main_matches_selected_origin_main( detection = detect_default_base_with_notes(repo) assert detection.base == "origin/main" + assert detection.state == "selected" assert not any("Skipped local base 'main'" in note for note in detection.notes) def test_returns_none_in_empty_repo(tmp_path: Path) -> None: repo = _init_repo(tmp_path) assert detect_default_base(repo) is None + assert detect_default_base_with_notes(repo).state == "selection_required" + + +def test_origin_head_missing_target_requires_fetch(tmp_path: Path) -> None: + repo = _init_repo(tmp_path) + (repo / "README.md").write_text("base\n", encoding="utf-8") + _commit_all(repo, "base") + _git( + repo, + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/trunk", + ) + + detection = detect_default_base_with_notes(repo) + + assert detection.state == "fetch_required" + assert detection.base is None + assert detection.default_ref == "origin/trunk" + + +def test_divergent_remote_candidates_require_selection(tmp_path: Path) -> None: + repo = _init_repo(tmp_path) + (repo / "README.md").write_text("base\n", encoding="utf-8") + _commit_all(repo, "base") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + (repo / "README.md").write_text("master\n", encoding="utf-8") + _commit_all(repo, "master") + _git(repo, "update-ref", "refs/remotes/origin/master", "HEAD") + + detection = detect_default_base_with_notes(repo, "HEAD~1") + + assert detection.state == "selection_required" + assert detection.candidates == ("origin/main", "origin/master") + + +def test_equivalent_remote_candidates_select_stable_main_name(tmp_path: Path) -> None: + repo = _docs_only_repo_with_origin_main(tmp_path) + _git(repo, "update-ref", "refs/remotes/origin/master", "origin/main") + + detection = detect_default_base_with_notes(repo) + + assert detection.state == "selected" + assert detection.base == "origin/main" + assert detection.candidates == ("origin/main", "origin/master") + + +def test_configured_origin_without_refs_requires_fetch(tmp_path: Path) -> None: + repo = _init_repo(tmp_path) + (repo / "README.md").write_text("base\n", encoding="utf-8") + _commit_all(repo, "base") + _git(repo, "remote", "add", "origin", "https://example.com/acme/repo.git") + + detection = detect_default_base_with_notes(repo) + + assert detection.state == "fetch_required" + assert detection.default_ref is None # --- CLI wiring --------------------------------------------------------------- @@ -250,6 +313,11 @@ def test_no_base_flag_disables_auto_detection(tmp_path: Path) -> None: payload = json.loads(result.output) assert payload["base_ref"] is None assert payload["base_status"] == "not_requested" + plan = json.loads( + (repo / "agents-shipgate-reports" / "verification-plan.json").read_text(encoding="utf-8") + ) + assert plan["inputs"]["options"]["base_mode"] == "disabled" + assert plan["inputs"]["options"]["base_resolution"] == "disabled" def test_explicit_base_wins_over_auto_detection(tmp_path: Path) -> None: @@ -262,6 +330,11 @@ def test_explicit_base_wins_over_auto_detection(tmp_path: Path) -> None: payload = json.loads(result.output) assert payload["base_ref"] == "origin/master" assert not any("Auto-detected base" in note for note in payload["base_notes"]) + plan = json.loads( + (repo / "agents-shipgate-reports" / "verification-plan.json").read_text(encoding="utf-8") + ) + assert plan["inputs"]["options"]["base_mode"] == "explicit" + assert plan["inputs"]["options"]["base_resolution"] == "selected" def test_zero_base_verify_warns_but_skips_stale_local_main(tmp_path: Path) -> None: @@ -274,13 +347,198 @@ def test_zero_base_verify_warns_but_skips_stale_local_main(tmp_path: Path) -> No assert payload["base_ref"] is None assert payload["base_status"] == "not_requested" assert any( - "Skipped local base 'main'" in note - and "origin/main" in note - and "--base main" in note + "Skipped local base 'main'" in note and "origin/main" in note and "--base main" in note for note in payload["base_notes"] ) +def test_zero_base_verify_fails_closed_without_trusted_comparison( + tmp_path: Path, +) -> None: + repo = _feature_repo_with_only_local_main(tmp_path) + out = repo / "agents-shipgate-reports" + out.mkdir() + for name in ("report.json", "capabilities.lock.json", "verification-receipt.json"): + (out / name).write_text('{"stale":true}\n', encoding="utf-8") + + with patch("agents_shipgate.cli.verify.orchestrator.run_scan") as run_scan: + result = _verify(repo) + + assert result.exit_code == 2, result.output + run_scan.assert_not_called() + payload = json.loads(result.output) + assert payload["base_ref"] is None + assert payload["base_status"] == "ref_missing" + assert payload["execution"] == "failed" + assert payload["head_exit_code"] == 2 + assert payload["release_decision"] is None + assert payload["decision"] is None + assert payload["merge_verdict"] == "unknown" + assert payload["applicability"] == "failed" + assert payload["can_merge_without_human"] is False + assert payload["fix_task"] is None + assert payload["control"]["state"] == "human_review_required" + assert payload["control"]["allowed_next_commands"] == [] + assert "did not run a head-only scan" in payload["headline"] + assert not (out / "report.json").exists() + assert not (out / "capabilities.lock.json").exists() + assert not (out / "verification-receipt.json").exists() + assert (out / "verifier.json").is_file() + assert (out / "verify-run.json").is_file() + assert (out / "agent-handoff.json").is_file() + plan = json.loads((out / "verification-plan.json").read_text(encoding="utf-8")) + assert ( + plan["inputs"]["options"] + | { + "base_mode": "auto", + "base_resolution": "selection_required", + "resolved_default_ref": None, + } + == plan["inputs"]["options"] + ) + + first_request_id = plan["request_id"] + second = _verify(repo) + assert second.exit_code == 2, second.output + second_plan = json.loads((out / "verification-plan.json").read_text(encoding="utf-8")) + assert second_plan["request_id"] == first_request_id + + +def test_zero_base_missing_remote_default_routes_fetch_without_command( + tmp_path: Path, +) -> None: + repo = _feature_repo_with_only_local_main(tmp_path) + _git(repo, "remote", "add", "origin", "https://example.com/acme/repo.git") + + with patch("agents_shipgate.cli.verify.orchestrator.run_scan") as run_scan: + result = _verify(repo) + + assert result.exit_code == 2, result.output + run_scan.assert_not_called() + payload = json.loads(result.output) + assert payload["control"]["state"] == "agent_action_required" + assert payload["control"]["next_action"]["kind"] == "fetch_base" + assert payload["control"]["allowed_next_commands"] == [] + assert payload["release_decision"] is None + + +def test_local_default_at_head_is_safe_without_remote(tmp_path: Path) -> None: + repo = _init_repo(tmp_path) + (repo / "shipgate.yaml").write_text( + """ +version: "0.1" +project: + name: test +agent: + name: test-agent + declared_purpose: [test] +environment: + target: local +tool_sources: + - id: tools + type: mcp + path: tools.json +""".lstrip(), + encoding="utf-8", + ) + (repo / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + _commit_all(repo, "base") + + result = _verify(repo) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["base_ref"] is None + plan = json.loads( + (repo / "agents-shipgate-reports" / "verification-plan.json").read_text(encoding="utf-8") + ) + assert plan["inputs"]["options"]["base_resolution"] == "head_at_default" + + +def test_local_default_at_head_includes_dirty_worktree(tmp_path: Path) -> None: + repo = _init_repo(tmp_path) + (repo / "shipgate.yaml").write_text( + """ +version: "0.1" +project: {name: test} +agent: + name: test-agent + declared_purpose: [test] +environment: {target: local} +tool_sources: + - {id: tools, type: mcp, path: tools.json} +""".lstrip(), + encoding="utf-8", + ) + (repo / "tools.json").write_text('{"tools":[]}\n', encoding="utf-8") + (repo / "staged.txt").write_text("base\n", encoding="utf-8") + (repo / "unstaged.txt").write_text("base\n", encoding="utf-8") + _commit_all(repo, "base") + (repo / "staged.txt").write_text("staged\n", encoding="utf-8") + _git(repo, "add", "staged.txt") + (repo / "unstaged.txt").write_text("unstaged\n", encoding="utf-8") + (repo / "untracked.txt").write_text("untracked\n", encoding="utf-8") + + result = _verify(repo) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert {"staged.txt", "unstaged.txt", "untracked.txt"} <= set(payload["changed_files"]) + + +def test_explicit_local_base_recovers_committed_and_worktree_scope( + tmp_path: Path, +) -> None: + repo = _feature_repo_with_only_local_main(tmp_path) + (repo / "dirty.txt").write_text("dirty\n", encoding="utf-8") + + unresolved = _verify(repo) + explicit = _verify(repo, "--base", "main") + + assert unresolved.exit_code == 2, unresolved.output + assert explicit.exit_code == 0, explicit.output + payload = json.loads(explicit.output) + assert {"README.md", "dirty.txt"} <= set(payload["changed_files"]) + + +def test_shallow_missing_merge_base_routes_fetch(tmp_path: Path) -> None: + repo = _feature_repo_with_local_main_equal_origin_main(tmp_path) + + with ( + patch("agents_shipgate.cli.verify.orchestrator.merge_base_sha", return_value=None), + patch("agents_shipgate.cli.verify.orchestrator.is_shallow_repository", return_value=True), + patch("agents_shipgate.cli.verify.orchestrator.run_scan") as run_scan, + ): + result = _verify(repo, "--base", "origin/main") + + assert result.exit_code == 2, result.output + run_scan.assert_not_called() + payload = json.loads(result.output) + assert payload["base_status"] == "archive_failed" + assert payload["control"]["state"] == "agent_action_required" + assert payload["control"]["next_action"]["kind"] == "fetch_base" + assert "shallow repository" in payload["control"]["next_action"]["why"] + + +def test_unrelated_history_requires_human_selection(tmp_path: Path) -> None: + repo = _feature_repo_with_local_main_equal_origin_main(tmp_path) + + with ( + patch("agents_shipgate.cli.verify.orchestrator.merge_base_sha", return_value=None), + patch("agents_shipgate.cli.verify.orchestrator.is_shallow_repository", return_value=False), + patch("agents_shipgate.cli.verify.orchestrator.run_scan") as run_scan, + ): + result = _verify(repo, "--base", "origin/main") + + assert result.exit_code == 2, result.output + run_scan.assert_not_called() + payload = json.loads(result.output) + assert payload["base_status"] == "archive_failed" + assert payload["control"]["state"] == "human_review_required" + assert payload["control"]["allowed_next_commands"] == [] + assert "not shallow" in payload["control"]["next_action"]["why"] + + def test_zero_base_verify_does_not_warn_for_equivalent_local_main( tmp_path: Path, ) -> None: @@ -291,9 +549,7 @@ def test_zero_base_verify_does_not_warn_for_equivalent_local_main( assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload["base_ref"] == "origin/main" - assert not any( - "Skipped local base 'main'" in note for note in payload["base_notes"] - ) + assert not any("Skipped local base 'main'" in note for note in payload["base_notes"]) def test_explicit_local_base_main_remains_supported(tmp_path: Path) -> None: @@ -307,13 +563,56 @@ def test_explicit_local_base_main_remains_supported(tmp_path: Path) -> None: assert not any("Skipped local base 'main'" in note for note in payload["base_notes"]) +def test_check_recovery_command_cannot_fall_through_to_head_only( + tmp_path: Path, +) -> None: + repo = _feature_repo_with_only_local_main(tmp_path) + (repo / "tools.json").write_text( + '{"tools":[{"name":"search","description":"Search records."}]}\n', + encoding="utf-8", + ) + + check_result = runner.invoke( + app, + [ + "check", + "--agent", + "codex", + "--workspace", + str(repo), + "--format", + "agent-boundary-json", + ], + ) + + assert check_result.exit_code == 0, check_result.output + check_payload = json.loads(check_result.output) + assert check_payload["control"]["state"] == "agent_action_required" + verify_commands = [ + command + for command in check_payload["control"]["allowed_next_commands"] + if "agents-shipgate verify" in command + ] + assert len(verify_commands) == 1 + assert " --base " not in verify_commands[0] + assert " --no-base" not in verify_commands[0] + + verify_result = _verify(repo) + + assert verify_result.exit_code == 2, verify_result.output + verify_payload = json.loads(verify_result.output) + assert verify_payload["control"]["state"] == "human_review_required" + assert verify_payload["release_decision"] is None + assert "did not run a head-only scan" in verify_payload["headline"] + + def test_base_help_documents_remote_only_auto_detection() -> None: result = runner.invoke(app, ["verify", "--help"], color=True) assert result.exit_code == 0, result.output - normalized_output = " ".join( - _strip_ansi(result.output).replace("│", " ").split() - ) + normalized_output = " ".join(_strip_ansi(result.output).replace("│", " ").split()) assert "origin/HEAD, origin/main, origin/master" in normalized_output assert "origin/main, origin/master, main, master" not in normalized_output assert "Local main/master are used only" in normalized_output + assert "exits 2 without running a head-only scan" in normalized_output + assert "intentional head/worktree-only verification" in normalized_output