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!(r#"{{"toolCall":{{"name":"{tool}","args":{{"CommandLine":"{command}"}}}},"workspacePaths":["/w"]}}"#))
44 }
45
46 fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
47 vec![home.join(".gemini/antigravity-cli")]
48 }
49
50 fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
51 let dir = home.join(".gemini/config");
53 if !dir.exists() {
54 return Ok(InstallOutcome::Skipped { reason: format!("{} not found (Antigravity CLI not set up)", dir.display()) });
55 }
56 let path = dir.join("hooks.json");
57 let binary = "safe-chains hook antigravity";
58
59 let mut settings: Value = if path.exists() {
60 let contents = std::fs::read_to_string(&path).map_err(|e| format!("Could not read {}: {e}", path.display()))?;
61 serde_json::from_str(&contents).map_err(|e| format!("Could not parse {}: {e}", path.display()))?
62 } else {
63 Value::Object(Map::new())
64 };
65
66 if has_safe_chains_hook(&settings) {
67 return Ok(InstallOutcome::AlreadyConfigured { path });
68 }
69 add_hook(&mut settings, binary)?;
70 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
71 std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
72 Ok(InstallOutcome::Installed { path })
73 }
74
75 fn hook_format(&self) -> Option<&dyn HookFormat> {
76 Some(&AntigravityHookFormat)
77 }
78}
79
80struct AntigravityHookFormat;
81
82#[derive(Deserialize)]
85struct ToolArgs {
86 #[serde(rename = "CommandLine")]
87 command_line: Option<String>,
88}
89
90#[derive(Deserialize)]
91struct ToolCall {
92 #[serde(default)]
96 name: Option<String>,
97 #[serde(default)]
98 args: Option<ToolArgs>,
99}
100
101#[derive(Deserialize)]
102struct AntigravityEnvelope {
103 #[serde(rename = "toolCall")]
104 tool_call: Option<ToolCall>,
105 #[serde(rename = "workspacePaths", default)]
106 workspace_paths: Vec<String>,
107}
108
109impl HookFormat for AntigravityHookFormat {
110 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
111 let env: AntigravityEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
112 let tool_call = env.tool_call;
113 if let Some(name) = tool_call.as_ref().and_then(|t| t.name.as_deref())
116 && name != "run_command"
117 {
118 return Err(ParseError { message: format!("not a shell tool: {name}") });
119 }
120 let command = tool_call
121 .and_then(|t| t.args)
122 .and_then(|a| a.command_line)
123 .ok_or_else(|| ParseError { message: "no toolCall.args.CommandLine".into() })?;
124 let cwd = env.workspace_paths.into_iter().next();
125 Ok(HookInput { command, root: cwd.clone(), cwd, session_id: None })
126 }
127
128 fn decision_pointer(&self) -> &'static str {
129 "/decision" }
131
132 fn render_response(&self, verdict: Verdict) -> HookResponse {
133 if verdict.is_allowed() {
138 decision("allow", "safe-chains: all commands in the chain are allowlisted")
139 } else {
140 HookResponse { stdout: String::new(), exit_code: 0 }
141 }
142 }
143
144 fn gated_policy(&self) -> GatedPolicy {
147 GatedPolicy::Ask
148 }
149
150 fn render_ask(&self, reason: &str) -> HookResponse {
151 decision("force_ask", reason)
155 }
156}
157
158fn decision(kind: &str, reason: &str) -> HookResponse {
159 let body = json!({ "decision": kind, "reason": reason });
160 HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
161}
162
163fn hook_entry(binary: &str) -> Value {
164 json!({
165 "PreToolUse": [{
166 "matcher": "run_command",
167 "hooks": [{ "type": "command", "command": binary }],
168 }]
169 })
170}
171
172fn has_safe_chains_hook(settings: &Value) -> bool {
173 settings
175 .get("safe-chains")
176 .and_then(|h| h.get("PreToolUse"))
177 .and_then(Value::as_array)
178 .is_some_and(|groups| {
179 groups.iter().any(|g| {
180 g.get("hooks").and_then(Value::as_array).is_some_and(|hs| {
181 hs.iter()
182 .any(|h| h.get("command").and_then(Value::as_str).is_some_and(|c| c.contains("safe-chains")))
183 })
184 })
185 })
186}
187
188fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
196 let Some(obj) = settings.as_object_mut() else {
197 return Err(format!("the settings file is {}, expected an object. Leaving the file unchanged.", super::json_kind(settings)));
198 };
199 obj.insert("safe-chains".to_string(), hook_entry(binary));
200 Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::verdict::SafetyLevel;
207
208 #[test]
209 fn install_skips_when_no_config_dir() {
210 let dir = tempfile::tempdir().unwrap();
211 assert!(matches!(AntigravityTarget.install(dir.path()).unwrap(), InstallOutcome::Skipped { .. }));
212 }
213
214 #[test]
221 fn install_refuses_a_non_object_settings_root() {
222 let dir = tempfile::tempdir().unwrap();
223 std::fs::create_dir_all(dir.path().join(".gemini/config")).unwrap();
224 let path = dir.path().join(".gemini/config/hooks.json");
225 const ORIGINAL: &str = "[1, 2, 3]\n";
226 std::fs::write(&path, ORIGINAL).unwrap();
227
228 match AntigravityTarget.install(dir.path()) {
229 Err(err) => assert!(err.contains("expected an object"), "unhelpful error: {err}"),
230 Ok(_) => panic!("a non-object settings root must be refused"),
231 }
232 assert_eq!(std::fs::read_to_string(&path).unwrap(), ORIGINAL, "--setup rewrote a settings file it could not read");
233 }
234
235 #[test]
236 fn install_writes_named_hook_with_run_command_matcher() {
237 let dir = tempfile::tempdir().unwrap();
238 std::fs::create_dir_all(dir.path().join(".gemini/config")).unwrap();
239 assert!(matches!(AntigravityTarget.install(dir.path()).unwrap(), InstallOutcome::Installed { .. }));
240 let s: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(".gemini/config/hooks.json")).unwrap()).unwrap();
241 assert!(has_safe_chains_hook(&s));
242 assert_eq!(s.pointer("/safe-chains/PreToolUse/0/matcher").and_then(Value::as_str), Some("run_command"),);
243 }
244
245 #[test]
246 fn install_is_idempotent() {
247 let dir = tempfile::tempdir().unwrap();
248 std::fs::create_dir_all(dir.path().join(".gemini/config")).unwrap();
249 AntigravityTarget.install(dir.path()).unwrap();
250 assert!(matches!(AntigravityTarget.install(dir.path()).unwrap(), InstallOutcome::AlreadyConfigured { .. }));
251 }
252
253 #[test]
254 fn install_preserves_other_named_hooks() {
255 let dir = tempfile::tempdir().unwrap();
257 let cfg = dir.path().join(".gemini/config");
258 std::fs::create_dir_all(&cfg).unwrap();
259 std::fs::write(
260 cfg.join("hooks.json"),
261 r#"{"lint-checker":{"PostToolUse":[{"matcher":"run_command","hooks":[{"type":"command","command":"./lint.sh"}]}]}}"#,
262 )
263 .unwrap();
264 AntigravityTarget.install(dir.path()).unwrap();
265 let s: Value = serde_json::from_str(&std::fs::read_to_string(cfg.join("hooks.json")).unwrap()).unwrap();
266 assert!(has_safe_chains_hook(&s));
267 assert!(
268 s.pointer("/lint-checker/PostToolUse/0/hooks/0/command").and_then(Value::as_str) == Some("./lint.sh"),
269 "the user's own named hook must survive install",
270 );
271 }
272
273 #[test]
274 fn parses_command_and_workspace() {
275 let input = r#"{"toolCall":{"name":"run_command","args":{"CommandLine":"cat /etc/hosts"}},"workspacePaths":["/w"]}"#;
276 let parsed = AntigravityHookFormat.parse_input(input).unwrap();
277 assert_eq!(parsed.command, "cat /etc/hosts");
278 assert_eq!(parsed.cwd.as_deref(), Some("/w"));
279 }
280
281 #[test]
282 fn safe_emits_allow_gated_asks_never_silent() {
283 let safe = AntigravityHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
285 let v: Value = serde_json::from_str(&safe.stdout).unwrap();
286 assert_eq!(v.get("decision").and_then(Value::as_str), Some("allow"));
287
288 assert_eq!(AntigravityHookFormat.gated_policy(), GatedPolicy::Ask);
289 let ask = AntigravityHookFormat.render_ask("please confirm");
290 let v: Value = serde_json::from_str(&ask.stdout).unwrap();
291 assert_eq!(v.get("decision").and_then(Value::as_str), Some("force_ask"));
293 assert_eq!(v.get("reason").and_then(Value::as_str), Some("please confirm"));
294 }
295}