Skip to main content

safe_chains/targets/
gemini.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4use serde_json::{Map, Value, json};
5
6use super::{HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target, allow_reason};
7use crate::verdict::Verdict;
8
9pub struct GeminiTarget;
10
11impl Target for GeminiTarget {
12    fn name(&self) -> &'static str {
13        "gemini"
14    }
15
16    fn display_name(&self) -> &'static str {
17        "Gemini CLI"
18    }
19
20    #[cfg(test)]
21    fn sample_envelope(&self, tool: &str, command: &str) -> Option<String> {
22        Some(format!(r#"{{"tool_name":"{tool}","tool_input":{{"command":"{command}"}}}}"#))
23    }
24
25    fn shell_tool_name(&self) -> &'static str {
26        "run_shell_command"
27    }
28
29    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
30        vec![home.join(".gemini")]
31    }
32
33    fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
34        let dir = home.join(".gemini");
35        if !dir.exists() {
36            return Ok(InstallOutcome::Skipped {
37                reason: format!(
38                    "~/.gemini not found at {} (Gemini CLI not installed)",
39                    dir.display()
40                ),
41            });
42        }
43
44        let path = dir.join("settings.json");
45        let binary = "safe-chains hook gemini";
46
47        if path.exists() {
48            let contents = std::fs::read_to_string(&path)
49                .map_err(|e| format!("Could not read {}: {e}", path.display()))?;
50            let mut settings: Value = serde_json::from_str(&contents)
51                .map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
52
53            if has_safe_chains_hook(&settings) {
54                return Ok(InstallOutcome::AlreadyConfigured { path });
55            }
56
57            add_hook(&mut settings, binary)?;
58            let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
59            std::fs::write(&path, format!("{output}\n"))
60                .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
61            Ok(InstallOutcome::Installed { path })
62        } else {
63            let mut settings = Value::Object(Map::new());
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
72    fn hook_format(&self) -> Option<&dyn HookFormat> {
73        Some(&GeminiHookFormat)
74    }
75}
76
77struct GeminiHookFormat;
78
79#[derive(Deserialize)]
80struct ToolInput {
81    command: String,
82}
83
84#[derive(Deserialize)]
85struct GeminiHookEnvelope {
86    #[serde(default)]
87    tool_name: Option<String>,
88    tool_input: ToolInput,
89    #[serde(default)]
90    cwd: Option<String>,
91}
92
93impl HookFormat for GeminiHookFormat {
94    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
95        let envelope: GeminiHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError {
96            message: e.to_string(),
97        })?;
98        // Gemini's matcher narrows to run_shell_command in config, but
99        // some setups may dispatch all tools through the same hook.
100        // For non-shell tools, return Err so the runtime exits 0
101        // silently — equivalent to "no opinion" — and Gemini falls
102        // back to its own permission rules.
103        if let Some(name) = &envelope.tool_name
104            && name != "run_shell_command"
105            && name != "Shell"
106        {
107            return Err(ParseError {
108                message: format!("not a shell tool: {name}"),
109            });
110        }
111        Ok(HookInput {
112            command: envelope.tool_input.command,
113            cwd: envelope.cwd,
114            root: super::env_root("GEMINI_PROJECT_DIR"),
115            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
116            session_id: None,
117        })
118    }
119
120    fn decision_pointer(&self) -> &'static str {
121        "/decision" // not permissionDecision
122    }
123
124    fn render_response(&self, verdict: Verdict) -> HookResponse {
125        if verdict.is_allowed() {
126            let reason = allow_reason(verdict);
127            // Gemini contract: `decision` (not permission /
128            // permissionDecision). Values: "allow" or "deny" only —
129            // no "ask".
130            let body = json!({
131                "decision": "allow",
132                "reason": reason,
133            });
134            HookResponse {
135                stdout: serde_json::to_string(&body).unwrap_or_default(),
136                exit_code: 0,
137            }
138        } else {
139            // Empty stdout is "no opinion" — Gemini's docs note that
140            // exit code drives the outcome and an unparseable stdout
141            // is a warning. Exit 0 + empty body lets Gemini's own
142            // permission system handle it.
143            HookResponse {
144                stdout: String::new(),
145                exit_code: 0,
146            }
147        }
148    }
149}
150
151fn hook_entry(binary: &str) -> Value {
152    json!({
153        "matcher": "^run_shell_command$",
154        "hooks": [{
155            "type": "command",
156            "command": binary,
157            "timeout": 60_000,
158        }]
159    })
160}
161
162fn has_safe_chains_hook(settings: &Value) -> bool {
163    settings
164        .get("hooks")
165        .and_then(|h| h.get("BeforeTool"))
166        .and_then(|arr| arr.as_array())
167        .is_some_and(|entries| {
168            entries.iter().any(|entry| {
169                entry
170                    .get("hooks")
171                    .and_then(|h| h.as_array())
172                    .is_some_and(|hooks| {
173                        hooks.iter().any(|hook| {
174                            hook.get("command")
175                                .and_then(|c| c.as_str())
176                                .is_some_and(|cmd| cmd.contains("safe-chains"))
177                        })
178                    })
179            })
180        })
181}
182
183fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
184    super::append_hook_entry(settings, "hooks", "BeforeTool", hook_entry(binary))
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::verdict::SafetyLevel;
191
192    fn target() -> GeminiTarget {
193        GeminiTarget
194    }
195
196    /// Verbatim shape from the Gemini CLI hooks reference. The bash
197    /// command lives in tool_input.command, matched by tool_name.
198    const GEMINI_DOCS_SAMPLE: &str = r#"{
199        "session_id": "abc123",
200        "transcript_path": "/Users/me/.gemini/transcripts/abc.json",
201        "cwd": "/Users/me/project",
202        "hook_event_name": "BeforeTool",
203        "timestamp": "2026-05-06T12:00:00Z",
204        "tool_name": "run_shell_command",
205        "tool_input": {"command": "ls -la"}
206    }"#;
207
208    #[test]
209    fn install_no_gemini_dir_skips() {
210        let dir = tempfile::tempdir().unwrap();
211        let outcome = target().install(dir.path()).unwrap();
212        assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
213    }
214
215    #[test]
216    fn install_creates_settings_file() {
217        let dir = tempfile::tempdir().unwrap();
218        std::fs::create_dir(dir.path().join(".gemini")).unwrap();
219        let outcome = target().install(dir.path()).unwrap();
220        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
221        let contents = std::fs::read_to_string(dir.path().join(".gemini/settings.json")).unwrap();
222        let settings: Value = serde_json::from_str(&contents).unwrap();
223        assert!(has_safe_chains_hook(&settings));
224    }
225
226    #[test]
227    fn install_uses_subcommand_invocation() {
228        let dir = tempfile::tempdir().unwrap();
229        std::fs::create_dir(dir.path().join(".gemini")).unwrap();
230        target().install(dir.path()).unwrap();
231        let contents = std::fs::read_to_string(dir.path().join(".gemini/settings.json")).unwrap();
232        assert!(contents.contains("safe-chains hook gemini"));
233    }
234
235    #[test]
236    fn install_idempotent() {
237        let dir = tempfile::tempdir().unwrap();
238        std::fs::create_dir(dir.path().join(".gemini")).unwrap();
239        target().install(dir.path()).unwrap();
240        let outcome = target().install(dir.path()).unwrap();
241        assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
242    }
243
244    #[test]
245    fn parse_input_extracts_command_from_tool_input() {
246        let parsed = GeminiHookFormat.parse_input(GEMINI_DOCS_SAMPLE).unwrap();
247        assert_eq!(parsed.command, "ls -la");
248        assert_eq!(parsed.cwd.as_deref(), Some("/Users/me/project"));
249    }
250
251    #[test]
252    fn parse_input_skips_non_shell_tool_names() {
253        // If the matcher in config doesn't narrow to run_shell_command,
254        // a non-shell tool may dispatch through. We return Err so the
255        // runtime exits silently — Gemini falls back to its own perms.
256        let stdin = r#"{"tool_name": "list_files", "tool_input": {"command": "ignored"}}"#;
257        assert!(GeminiHookFormat.parse_input(stdin).is_err());
258    }
259
260    #[test]
261    fn parse_input_rejects_garbage() {
262        assert!(GeminiHookFormat.parse_input("not json").is_err());
263        assert!(GeminiHookFormat.parse_input("{}").is_err());
264    }
265
266    #[test]
267    fn render_response_uses_decision_key_not_permission() {
268        // Gemini contract is `decision`, NOT `permission` /
269        // `permissionDecision`. Wiring this wrong silently fails the
270        // hook (warning, action proceeds) rather than blocking.
271        let r = GeminiHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
272        let v: Value = serde_json::from_str(&r.stdout).unwrap();
273        assert_eq!(v.get("decision").and_then(|s| s.as_str()), Some("allow"));
274        assert!(v.get("permission").is_none());
275        assert!(v.get("permissionDecision").is_none());
276    }
277
278    #[test]
279    fn render_response_includes_reason() {
280        let r = GeminiHookFormat.render_response(Verdict::Allowed(SafetyLevel::SafeWrite));
281        let v: Value = serde_json::from_str(&r.stdout).unwrap();
282        assert!(v.get("reason").and_then(|s| s.as_str()).is_some());
283    }
284
285    #[test]
286    fn render_response_deny_emits_empty_body() {
287        let r = GeminiHookFormat.render_response(Verdict::Denied);
288        assert_eq!(r.stdout, "");
289    }
290
291    #[test]
292    fn install_uses_correct_matcher() {
293        // Gemini's matcher is regex on tool name; `^run_shell_command$`
294        // is the canonical shell-tool matcher.
295        let dir = tempfile::tempdir().unwrap();
296        std::fs::create_dir(dir.path().join(".gemini")).unwrap();
297        target().install(dir.path()).unwrap();
298        let contents = std::fs::read_to_string(dir.path().join(".gemini/settings.json")).unwrap();
299        assert!(contents.contains("run_shell_command"));
300    }
301}