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