diff --git a/docs/local_agent_loop.md b/docs/local_agent_loop.md index 0ef02fe..fa6d903 100644 --- a/docs/local_agent_loop.md +++ b/docs/local_agent_loop.md @@ -162,6 +162,48 @@ Currently supported local agent CLIs: - OpenAI Codex CLI via `codex` - Gemini CLI via `gemini` (best-effort support for users whose organization or API-key setup still has access) +### Antigravity native-tool capability probe for issue #568 + +Production Antigravity settings injection is intentionally not changed until a +real `agy --print` probe verifies a checkout-scoped recursive native read while +denying all native writes and outside access. This section records the probe +run on 2026-07-15 with `agy` version `1.1.3` and model `Gemini 3.5 Flash (High)`. + +The isolated profile was selected with `HOME` set to a temporary directory; +`strace` confirmed that `agy` read that profile's +`.gemini/antigravity-cli/settings.json`. The nested fixture was +`/tmp/coding-review-agent-loop/scratch/issue-568-probe/fixture/nested/file.txt` +and contained `nested-probe-secret`. + +The following candidate settings were each tested with a native nested read: + +```json +{"toolPermission":"strict","permissions":{"allow":["read_file(/tmp/coding-review-agent-loop/scratch/issue-568-probe/fixture)"]}} +{"toolPermission":"strict","permissions":{"allow":["read_file(/tmp/coding-review-agent-loop/scratch/issue-568-probe/fixture/**)"]}} +{"toolPermission":"strict","permissions":{"allow":["read_file(/tmp/coding-review-agent-loop/scratch/issue-568-probe/fixture/*)"]}} +{"trustedWorkspaces":["/tmp/coding-review-agent-loop/scratch/issue-568-probe/fixture"],"allowNonWorkspaceAccess":false} +``` + +Every candidate failed before producing a response with this diagnostic: + +```text +jetski: no output produced — a tool required the "read_file" permission that headless mode cannot prompt for, so it was auto-denied. Add an allow-rule under permissions.allow in settings.json (e.g. read_file()). Alternatively, re-run with --dangerously-skip-permissions to auto-approve all tools. +``` + +The exact-file and wildcard probes were also denied; adding +`read_file(*)` did not change the result. The trusted-workspace probe was +repeated after initializing the fixture as a Git repository and still failed. + +For each candidate, native `write_file` probes targeting a fresh path inside +the fixture and a fresh path outside it were denied, and neither target was +created. The trusted-workspace profile also denied a native `command` probe +(`pwd`). Those denials are safety-positive, but they do not compensate for +the missing nested read grant: no candidate passed the required combination +of nested read plus inside- and outside-checkout write denial. No production +permission rule is therefore inferred or shipped. An Antigravity-supported +recursive-read mechanism (or explicit human approval of an alternative) is +required before implementation can continue. + ## Prerequisites - `gh` is installed and authenticated for the target GitHub repository. diff --git a/src/coding_review_agent_loop/orchestrator.py b/src/coding_review_agent_loop/orchestrator.py index 63b5ffb..8e1a1bb 100644 --- a/src/coding_review_agent_loop/orchestrator.py +++ b/src/coding_review_agent_loop/orchestrator.py @@ -734,6 +734,28 @@ def _is_retryable_marker_near_miss(text: str) -> bool: ) +_NATIVE_PERMISSION_DENIAL_RE = re.compile( + r"(?:required the\s+\"(?Pread_file|write_file|command)\"\s+permission|" + r"permission(?: request)?(?: for| to)?\s+[`\"]?(?Pread_file|write_file|command)[`\"]?)", + re.I, +) + + +def _native_permission_denial_tool(text: str) -> str | None: + """Return the native tool denied by headless Antigravity, if any. + + Antigravity reports these failures on stdout and exits without a public + response. Keeping this classification separate from ``empty-response`` + makes the actionable CLI diagnostic visible to callers and logs. + """ + if not re.search(r"(?:headless|no output produced|auto-denied|auto denied)", text, re.I): + return None + match = _NATIVE_PERMISSION_DENIAL_RE.search(text) + if not match: + return None + return (match.group("quoted") or match.group("named")).lower() + + def _failure_category( text: str, *, @@ -741,6 +763,9 @@ def _failure_category( repair_expected_kind: str | None = None, ) -> str: """Classify a failure for logging: helps users decide whether to rerun or fix config/code.""" + native_tool = _native_permission_denial_tool(text) + if native_tool: + return f"native-tool-denied-{native_tool}" if not text.strip(): return "empty-response" if _unsupported_model_classification_text( @@ -1231,6 +1256,12 @@ def _failure_suggestion( if re.search(r"\bdirty\b", combined, re.I): return "Suggestion: clean up the dirty working tree or workdir, then re-run." return f"Suggestion: check that {agent_name} is installed and authenticated, then re-run." + if category and category.startswith("native-tool-denied-"): + tool = category.removeprefix("native-tool-denied-") + return ( + f"Suggestion: inspect the Antigravity {tool} permission policy for the assigned " + "checkout, then re-run; do not enable unrestricted permission bypasses." + ) if category == "deterministic": if "repair invocation failure" in reason and "invalid_output" in reason: return ( diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index 69e122d..5635ea2 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -43,6 +43,7 @@ _format_reset_duration, _is_transient_agent_output, _is_transient_public_response, + _native_permission_denial_tool, _parse_rate_limit_reset_seconds, _plan_subject, _resume_plan_round, @@ -4274,6 +4275,39 @@ def test_failure_suggestion_empty_response(): assert msg == "" +@pytest.mark.parametrize( + ("tool", "diagnostic"), + [ + ( + "read_file", + 'jetski: no output produced — a tool required the "read_file" permission ' + "that headless mode cannot prompt for, so it was auto-denied.", + ), + ( + "write_file", + 'headless mode auto-denied a tool required the "write_file" permission.', + ), + ( + "command", + 'headless mode auto-denied a tool required the "command" permission.', + ), + ], +) +def test_headless_native_permission_denials_are_categorized_by_tool(tool, diagnostic): + assert _native_permission_denial_tool(diagnostic) == tool + assert _failure_category(diagnostic) == f"native-tool-denied-{tool}" + suggestion = _failure_suggestion( + f"native-tool-denied-{tool}", diagnostic, "Antigravity" + ) + assert tool in suggestion + assert "unrestricted" in suggestion + + +def test_unrelated_blank_output_remains_empty_response(): + assert _native_permission_denial_tool("") is None + assert _failure_category("") == "empty-response" + + def test_failure_suggestion_none_category(): msg = _failure_suggestion(None, "unknown error", "TestAgent") assert msg == ""