safe_chains/targets/
agy.rs1use 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 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#[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 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 fn gated_policy(&self) -> GatedPolicy {
128 GatedPolicy::Ask
129 }
130
131 fn render_ask(&self, reason: &str) -> HookResponse {
132 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 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 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 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 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}