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 detect_paths(&self, home: &Path) -> Vec<PathBuf> {
38        vec![home.join(".gemini/antigravity-cli")]
39    }
40
41    fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
42        // Global customization root for the CLI (per agy-customizations/docs/json_configs.md).
43        let dir = home.join(".gemini/config");
44        if !dir.exists() {
45            return Ok(InstallOutcome::Skipped {
46                reason: format!("{} not found (Antigravity CLI not set up)", dir.display()),
47            });
48        }
49        let path = dir.join("hooks.json");
50        let binary = "safe-chains hook antigravity";
51
52        let mut settings: Value = if path.exists() {
53            let contents = std::fs::read_to_string(&path)
54                .map_err(|e| format!("Could not read {}: {e}", path.display()))?;
55            serde_json::from_str(&contents)
56                .map_err(|e| format!("Could not parse {}: {e}", path.display()))?
57        } else {
58            Value::Object(Map::new())
59        };
60
61        if has_safe_chains_hook(&settings) {
62            return Ok(InstallOutcome::AlreadyConfigured { path });
63        }
64        add_hook(&mut settings, binary);
65        let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
66        std::fs::write(&path, format!("{output}\n"))
67            .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
68        Ok(InstallOutcome::Installed { path })
69    }
70
71    fn hook_format(&self) -> Option<&dyn HookFormat> {
72        Some(&AntigravityHookFormat)
73    }
74}
75
76struct AntigravityHookFormat;
77
78// Antigravity payloads are protojson (camelCase). A PreToolUse `run_command` step carries the shell
79// command at `toolCall.args.CommandLine`, and the workspace at `workspacePaths`.
80#[derive(Deserialize)]
81struct ToolArgs {
82    #[serde(rename = "CommandLine")]
83    command_line: Option<String>,
84}
85
86#[derive(Deserialize)]
87struct ToolCall {
88    #[serde(default)]
89    args: Option<ToolArgs>,
90}
91
92#[derive(Deserialize)]
93struct AntigravityEnvelope {
94    #[serde(rename = "toolCall")]
95    tool_call: Option<ToolCall>,
96    #[serde(rename = "workspacePaths", default)]
97    workspace_paths: Vec<String>,
98}
99
100impl HookFormat for AntigravityHookFormat {
101    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
102        let env: AntigravityEnvelope =
103            serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
104        let command = env
105            .tool_call
106            .and_then(|t| t.args)
107            .and_then(|a| a.command_line)
108            .ok_or_else(|| ParseError { message: "no toolCall.args.CommandLine".into() })?;
109        let cwd = env.workspace_paths.into_iter().next();
110        Ok(HookInput { command, root: cwd.clone(), cwd, session_id: None })
111    }
112
113    fn render_response(&self, verdict: Verdict) -> HookResponse {
114        // SAFE command → `allow`. agy 1.1.2 does not honor this to skip its own confirmation, but
115        // it's semantically correct + future-proof, and agy fails CLOSED on a missing decision, so
116        // we emit it explicitly rather than stay silent. (The flow only calls this for an allowed
117        // verdict; a non-allowed one defensively emits nothing — gated goes through `render_ask`.)
118        if verdict.is_allowed() {
119            decision("allow", "safe-chains: all commands in the chain are allowlisted")
120        } else {
121            HookResponse { stdout: String::new(), exit_code: 0 }
122        }
123    }
124
125    // agy has human review (default `request-review` prompts), so a gated command ESCALATES to a
126    // human prompt rather than hard-denying.
127    fn gated_policy(&self) -> GatedPolicy {
128        GatedPolicy::Ask
129    }
130
131    fn render_ask(&self, reason: &str) -> HookResponse {
132        // `force_ask`, not `ask`: `ask` respects agy's Always-Allow cache, so a coarse prefix grant
133        // ("always allow commands starting with cat") would let a gated `cat /etc/hosts` auto-run.
134        // `force_ask` ignores the cache and always prompts.
135        decision("force_ask", reason)
136    }
137}
138
139fn decision(kind: &str, reason: &str) -> HookResponse {
140    let body = json!({ "decision": kind, "reason": reason });
141    HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
142}
143
144fn hook_entry(binary: &str) -> Value {
145    json!({
146        "PreToolUse": [{
147            "matcher": "run_command",
148            "hooks": [{ "type": "command", "command": binary }],
149        }]
150    })
151}
152
153fn has_safe_chains_hook(settings: &Value) -> bool {
154    // Top-level keys are hook NAMES; ours is "safe-chains".
155    settings
156        .get("safe-chains")
157        .and_then(|h| h.get("PreToolUse"))
158        .and_then(Value::as_array)
159        .is_some_and(|groups| {
160            groups.iter().any(|g| {
161                g.get("hooks").and_then(Value::as_array).is_some_and(|hs| {
162                    hs.iter().any(|h| {
163                        h.get("command").and_then(Value::as_str).is_some_and(|c| c.contains("safe-chains"))
164                    })
165                })
166            })
167        })
168}
169
170fn add_hook(settings: &mut Value, binary: &str) {
171    if !settings.is_object() {
172        *settings = json!({});
173    }
174    settings
175        .as_object_mut()
176        .expect("settings is an object")
177        .insert("safe-chains".to_string(), hook_entry(binary));
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::verdict::SafetyLevel;
184
185    #[test]
186    fn install_skips_when_no_config_dir() {
187        let dir = tempfile::tempdir().unwrap();
188        assert!(matches!(AntigravityTarget.install(dir.path()).unwrap(), InstallOutcome::Skipped { .. }));
189    }
190
191    #[test]
192    fn install_writes_named_hook_with_run_command_matcher() {
193        let dir = tempfile::tempdir().unwrap();
194        std::fs::create_dir_all(dir.path().join(".gemini/config")).unwrap();
195        assert!(matches!(AntigravityTarget.install(dir.path()).unwrap(), InstallOutcome::Installed { .. }));
196        let s: Value = serde_json::from_str(
197            &std::fs::read_to_string(dir.path().join(".gemini/config/hooks.json")).unwrap(),
198        )
199        .unwrap();
200        assert!(has_safe_chains_hook(&s));
201        assert_eq!(
202            s.pointer("/safe-chains/PreToolUse/0/matcher").and_then(Value::as_str),
203            Some("run_command"),
204        );
205    }
206
207    #[test]
208    fn install_is_idempotent() {
209        let dir = tempfile::tempdir().unwrap();
210        std::fs::create_dir_all(dir.path().join(".gemini/config")).unwrap();
211        AntigravityTarget.install(dir.path()).unwrap();
212        assert!(matches!(
213            AntigravityTarget.install(dir.path()).unwrap(),
214            InstallOutcome::AlreadyConfigured { .. }
215        ));
216    }
217
218    #[test]
219    fn install_preserves_other_named_hooks() {
220        // hooks.json merges multiple named hooks; installing must not clobber a user's own.
221        let dir = tempfile::tempdir().unwrap();
222        let cfg = dir.path().join(".gemini/config");
223        std::fs::create_dir_all(&cfg).unwrap();
224        std::fs::write(
225            cfg.join("hooks.json"),
226            r#"{"lint-checker":{"PostToolUse":[{"matcher":"run_command","hooks":[{"type":"command","command":"./lint.sh"}]}]}}"#,
227        )
228        .unwrap();
229        AntigravityTarget.install(dir.path()).unwrap();
230        let s: Value =
231            serde_json::from_str(&std::fs::read_to_string(cfg.join("hooks.json")).unwrap()).unwrap();
232        assert!(has_safe_chains_hook(&s));
233        assert!(
234            s.pointer("/lint-checker/PostToolUse/0/hooks/0/command").and_then(Value::as_str)
235                == Some("./lint.sh"),
236            "the user's own named hook must survive install",
237        );
238    }
239
240    #[test]
241    fn parses_command_and_workspace() {
242        let input = r#"{"toolCall":{"name":"run_command","args":{"CommandLine":"cat /etc/hosts"}},"workspacePaths":["/w"]}"#;
243        let parsed = AntigravityHookFormat.parse_input(input).unwrap();
244        assert_eq!(parsed.command, "cat /etc/hosts");
245        assert_eq!(parsed.cwd.as_deref(), Some("/w"));
246    }
247
248    #[test]
249    fn safe_emits_allow_gated_asks_never_silent() {
250        // Antigravity fails CLOSED on a missing decision, so both paths emit an explicit decision.
251        let safe = AntigravityHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
252        let v: Value = serde_json::from_str(&safe.stdout).unwrap();
253        assert_eq!(v.get("decision").and_then(Value::as_str), Some("allow"));
254
255        assert_eq!(AntigravityHookFormat.gated_policy(), GatedPolicy::Ask);
256        let ask = AntigravityHookFormat.render_ask("please confirm");
257        let v: Value = serde_json::from_str(&ask.stdout).unwrap();
258        // `force_ask` (not `ask`) so agy's Always-Allow cache can't auto-run a gated command.
259        assert_eq!(v.get("decision").and_then(Value::as_str), Some("force_ask"));
260        assert_eq!(v.get("reason").and_then(Value::as_str), Some("please confirm"));
261    }
262}