safe_chains/targets/
gemini.rs1use 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 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 session_id: None,
117 })
118 }
119
120 fn decision_pointer(&self) -> &'static str {
121 "/decision" }
123
124 fn render_response(&self, verdict: Verdict) -> HookResponse {
125 if verdict.is_allowed() {
126 let reason = allow_reason(verdict);
127 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 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 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 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 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 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}