Skip to main content

safe_chains/targets/
agy.rs

1//! Antigravity CLI (`agy`) — Google's successor to the retired Gemini CLI. Hooks are configured
2//! via a `hooks.json` in the customization root (`~/.gemini/config/` globally, `.agents/` per
3//! project). The `run_command` PreToolUse hook decision set is `allow` / `deny` / `ask` /
4//! `force_ask`. Verified LIVE against v1.1.2 CLI on 2026-07-13 (see HARNESS-BEHAVIORS.md):
5//!   - `deny` → hard block, our reason shown to the model. WORKS.
6//!   - `force_ask` → forces a human prompt, *ignoring* agy's Always-Allow cache. WORKS.
7//!   - `allow` → does NOT suppress agy's own `request-review` confirmation in the CLI; the user is
8//!     still prompted. So there is no effective *grant* on agy 1.1.2. We still emit `allow` for a
9//!     safe command: it is semantically correct, harmless (agy prompts anyway by default), and
10//!     future-proof if a later build honors it.
11//!
12//! agy's default `toolPermission=request-review` prompts for `run_command` on silence, so it HAS
13//! human review — a gated command therefore *escalates* (force_ask) rather than hard-denies. We use
14//! `force_ask` over plain `ask` because `ask` respects the Always-Allow cache: a user who once
15//! picked "always allow commands starting with cat" would have a gated `cat /etc/hosts` auto-run.
16//! Antigravity FAILS CLOSED on a missing/malformed decision. See docs/design/harness-capability-model.md.
17
18use std::path::{Path, PathBuf};
19
20use serde::Deserialize;
21use serde_json::{Map, Value, json};
22
23use super::{GatedPolicy, HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target};
24use crate::verdict::Verdict;
25
26pub struct AntigravityTarget;
27
28impl Target for AntigravityTarget {
29    fn name(&self) -> &'static str {
30        "antigravity"
31    }
32
33    fn display_name(&self) -> &'static str {
34        "Antigravity CLI (agy)"
35    }
36
37    fn shell_tool_name(&self) -> &'static str {
38        "run_command" // the payload name; the UI labels it "Bash"
39    }
40
41    #[cfg(test)]
42    fn sample_envelope(&self, tool: &str, command: &str) -> Option<String> {
43        Some(format!(
44            r#"{{"toolCall":{{"name":"{tool}","args":{{"CommandLine":"{command}"}}}},"workspacePaths":["/w"]}}"#
45        ))
46    }
47
48    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
49        vec![home.join(".gemini/antigravity-cli")]
50    }
51
52    fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
53        // Global customization root for the CLI (per agy-customizations/docs/json_configs.md).
54        let dir = home.join(".gemini/config");
55        if !dir.exists() {
56            return Ok(InstallOutcome::Skipped {
57                reason: format!("{} not found (Antigravity CLI not set up)", dir.display()),
58            });
59        }
60        let path = dir.join("hooks.json");
61        let binary = "safe-chains hook antigravity";
62
63        let mut settings: Value = if path.exists() {
64            let contents = std::fs::read_to_string(&path)
65                .map_err(|e| format!("Could not read {}: {e}", path.display()))?;
66            serde_json::from_str(&contents)
67                .map_err(|e| format!("Could not parse {}: {e}", path.display()))?
68        } else {
69            Value::Object(Map::new())
70        };
71
72        if has_safe_chains_hook(&settings) {
73            return Ok(InstallOutcome::AlreadyConfigured { path });
74        }
75        add_hook(&mut settings, binary)?;
76        let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
77        std::fs::write(&path, format!("{output}\n"))
78            .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
79        Ok(InstallOutcome::Installed { path })
80    }
81
82    fn hook_format(&self) -> Option<&dyn HookFormat> {
83        Some(&AntigravityHookFormat)
84    }
85}
86
87struct AntigravityHookFormat;
88
89// Antigravity payloads are protojson (camelCase). A PreToolUse `run_command` step carries the shell
90// command at `toolCall.args.CommandLine`, and the workspace at `workspacePaths`.
91#[derive(Deserialize)]
92struct ToolArgs {
93    #[serde(rename = "CommandLine")]
94    command_line: Option<String>,
95}
96
97#[derive(Deserialize)]
98struct ToolCall {
99    /// The tool being called. Documented and live-verified in HARNESS-BEHAVIORS.md: the UI labels
100    /// the shell tool "Bash" but the payload says `run_command`, and agy's matcher keys on this.
101    /// Optional, so an envelope without it still parses.
102    #[serde(default)]
103    name: Option<String>,
104    #[serde(default)]
105    args: Option<ToolArgs>,
106}
107
108#[derive(Deserialize)]
109struct AntigravityEnvelope {
110    #[serde(rename = "toolCall")]
111    tool_call: Option<ToolCall>,
112    #[serde(rename = "workspacePaths", default)]
113    workspace_paths: Vec<String>,
114}
115
116impl HookFormat for AntigravityHookFormat {
117    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
118        let env: AntigravityEnvelope =
119            serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
120        let tool_call = env.tool_call;
121        // Self-filter on the tool. agy is ASK-capable, so deciding on a foreign call escalates a
122        // tool that was never analysed to a human prompt. Absent name still passes.
123        if let Some(name) = tool_call.as_ref().and_then(|t| t.name.as_deref())
124            && name != "run_command"
125        {
126            return Err(ParseError { message: format!("not a shell tool: {name}") });
127        }
128        let command = tool_call
129            .and_then(|t| t.args)
130            .and_then(|a| a.command_line)
131            .ok_or_else(|| ParseError { message: "no toolCall.args.CommandLine".into() })?;
132        let cwd = env.workspace_paths.into_iter().next();
133        Ok(HookInput { command, root: cwd.clone(), cwd, session_id: None })
134    }
135
136    fn decision_pointer(&self) -> &'static str {
137        "/decision" // flat object; agy reads a top-level decision
138    }
139
140    fn render_response(&self, verdict: Verdict) -> HookResponse {
141        // SAFE command → `allow`. agy 1.1.2 does not honor this to skip its own confirmation, but
142        // it's semantically correct + future-proof, and agy fails CLOSED on a missing decision, so
143        // we emit it explicitly rather than stay silent. (The flow only calls this for an allowed
144        // verdict; a non-allowed one defensively emits nothing — gated goes through `render_ask`.)
145        if verdict.is_allowed() {
146            decision("allow", "safe-chains: all commands in the chain are allowlisted")
147        } else {
148            HookResponse { stdout: String::new(), exit_code: 0 }
149        }
150    }
151
152    // agy has human review (default `request-review` prompts), so a gated command ESCALATES to a
153    // human prompt rather than hard-denying.
154    fn gated_policy(&self) -> GatedPolicy {
155        GatedPolicy::Ask
156    }
157
158    fn render_ask(&self, reason: &str) -> HookResponse {
159        // `force_ask`, not `ask`: `ask` respects agy's Always-Allow cache, so a coarse prefix grant
160        // ("always allow commands starting with cat") would let a gated `cat /etc/hosts` auto-run.
161        // `force_ask` ignores the cache and always prompts.
162        decision("force_ask", reason)
163    }
164}
165
166fn decision(kind: &str, reason: &str) -> HookResponse {
167    let body = json!({ "decision": kind, "reason": reason });
168    HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
169}
170
171fn hook_entry(binary: &str) -> Value {
172    json!({
173        "PreToolUse": [{
174            "matcher": "run_command",
175            "hooks": [{ "type": "command", "command": binary }],
176        }]
177    })
178}
179
180fn has_safe_chains_hook(settings: &Value) -> bool {
181    // Top-level keys are hook NAMES; ours is "safe-chains".
182    settings
183        .get("safe-chains")
184        .and_then(|h| h.get("PreToolUse"))
185        .and_then(Value::as_array)
186        .is_some_and(|groups| {
187            groups.iter().any(|g| {
188                g.get("hooks").and_then(Value::as_array).is_some_and(|hs| {
189                    hs.iter().any(|h| {
190                        h.get("command").and_then(Value::as_str).is_some_and(|c| c.contains("safe-chains"))
191                    })
192                })
193            })
194        })
195}
196
197/// Antigravity nests differently from the others — its hook entry is a TOP-LEVEL key, with no outer
198/// `hooks` object — so it cannot use `append_hook_entry`, which exists to protect an outer key.
199/// What it CAN share is the refusal: a settings file that parses to something other than an object
200/// used to be replaced with `{}`, silently discarding whatever the user had.
201///
202/// Only reachable for a file that exists and parses to a non-object (`[1,2,3]`, `"a string"`); the
203/// caller turns a missing file into an empty object first, so refusing cannot break a fresh setup.
204fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
205    let Some(obj) = settings.as_object_mut() else {
206        return Err(format!(
207            "the settings file is {}, expected an object. Leaving the file unchanged.",
208            super::json_kind(settings)
209        ));
210    };
211    obj.insert("safe-chains".to_string(), hook_entry(binary));
212    Ok(())
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::verdict::SafetyLevel;
219
220    #[test]
221    fn install_skips_when_no_config_dir() {
222        let dir = tempfile::tempdir().unwrap();
223        assert!(matches!(AntigravityTarget.install(dir.path()).unwrap(), InstallOutcome::Skipped { .. }));
224    }
225
226    /// A settings file whose root is not an object survives `--setup` untouched.
227    ///
228    /// Antigravity inserts a TOP-LEVEL key, so it cannot use `append_hook_entry` and had its own
229    /// `if !settings.is_object() { *settings = json!({}) }` — a silent destructive edit to a file
230    /// we did not write. It is now refused, like the wrong-typed inner keys the other targets
231    /// already refuse.
232    #[test]
233    fn install_refuses_a_non_object_settings_root() {
234        let dir = tempfile::tempdir().unwrap();
235        std::fs::create_dir_all(dir.path().join(".gemini/config")).unwrap();
236        let path = dir.path().join(".gemini/config/hooks.json");
237        const ORIGINAL: &str = "[1, 2, 3]\n";
238        std::fs::write(&path, ORIGINAL).unwrap();
239
240        match AntigravityTarget.install(dir.path()) {
241            Err(err) => assert!(err.contains("expected an object"), "unhelpful error: {err}"),
242            Ok(_) => panic!("a non-object settings root must be refused"),
243        }
244        assert_eq!(
245            std::fs::read_to_string(&path).unwrap(),
246            ORIGINAL,
247            "--setup rewrote a settings file it could not read"
248        );
249    }
250
251    #[test]
252    fn install_writes_named_hook_with_run_command_matcher() {
253        let dir = tempfile::tempdir().unwrap();
254        std::fs::create_dir_all(dir.path().join(".gemini/config")).unwrap();
255        assert!(matches!(AntigravityTarget.install(dir.path()).unwrap(), InstallOutcome::Installed { .. }));
256        let s: Value = serde_json::from_str(
257            &std::fs::read_to_string(dir.path().join(".gemini/config/hooks.json")).unwrap(),
258        )
259        .unwrap();
260        assert!(has_safe_chains_hook(&s));
261        assert_eq!(
262            s.pointer("/safe-chains/PreToolUse/0/matcher").and_then(Value::as_str),
263            Some("run_command"),
264        );
265    }
266
267    #[test]
268    fn install_is_idempotent() {
269        let dir = tempfile::tempdir().unwrap();
270        std::fs::create_dir_all(dir.path().join(".gemini/config")).unwrap();
271        AntigravityTarget.install(dir.path()).unwrap();
272        assert!(matches!(
273            AntigravityTarget.install(dir.path()).unwrap(),
274            InstallOutcome::AlreadyConfigured { .. }
275        ));
276    }
277
278    #[test]
279    fn install_preserves_other_named_hooks() {
280        // hooks.json merges multiple named hooks; installing must not clobber a user's own.
281        let dir = tempfile::tempdir().unwrap();
282        let cfg = dir.path().join(".gemini/config");
283        std::fs::create_dir_all(&cfg).unwrap();
284        std::fs::write(
285            cfg.join("hooks.json"),
286            r#"{"lint-checker":{"PostToolUse":[{"matcher":"run_command","hooks":[{"type":"command","command":"./lint.sh"}]}]}}"#,
287        )
288        .unwrap();
289        AntigravityTarget.install(dir.path()).unwrap();
290        let s: Value =
291            serde_json::from_str(&std::fs::read_to_string(cfg.join("hooks.json")).unwrap()).unwrap();
292        assert!(has_safe_chains_hook(&s));
293        assert!(
294            s.pointer("/lint-checker/PostToolUse/0/hooks/0/command").and_then(Value::as_str)
295                == Some("./lint.sh"),
296            "the user's own named hook must survive install",
297        );
298    }
299
300    #[test]
301    fn parses_command_and_workspace() {
302        let input = r#"{"toolCall":{"name":"run_command","args":{"CommandLine":"cat /etc/hosts"}},"workspacePaths":["/w"]}"#;
303        let parsed = AntigravityHookFormat.parse_input(input).unwrap();
304        assert_eq!(parsed.command, "cat /etc/hosts");
305        assert_eq!(parsed.cwd.as_deref(), Some("/w"));
306    }
307
308    #[test]
309    fn safe_emits_allow_gated_asks_never_silent() {
310        // Antigravity fails CLOSED on a missing decision, so both paths emit an explicit decision.
311        let safe = AntigravityHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
312        let v: Value = serde_json::from_str(&safe.stdout).unwrap();
313        assert_eq!(v.get("decision").and_then(Value::as_str), Some("allow"));
314
315        assert_eq!(AntigravityHookFormat.gated_policy(), GatedPolicy::Ask);
316        let ask = AntigravityHookFormat.render_ask("please confirm");
317        let v: Value = serde_json::from_str(&ask.stdout).unwrap();
318        // `force_ask` (not `ask`) so agy's Always-Allow cache can't auto-run a gated command.
319        assert_eq!(v.get("decision").and_then(Value::as_str), Some("force_ask"));
320        assert_eq!(v.get("reason").and_then(Value::as_str), Some("please confirm"));
321    }
322}