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 shell_tool_name(&self) -> &'static str {
38 "run_command" }
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 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#[derive(Deserialize)]
92struct ToolArgs {
93 #[serde(rename = "CommandLine")]
94 command_line: Option<String>,
95}
96
97#[derive(Deserialize)]
98struct ToolCall {
99 #[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 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" }
139
140 fn render_response(&self, verdict: Verdict) -> HookResponse {
141 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 fn gated_policy(&self) -> GatedPolicy {
155 GatedPolicy::Ask
156 }
157
158 fn render_ask(&self, reason: &str) -> HookResponse {
159 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 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
197fn 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 #[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 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 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 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}