safe_chains/targets/
claude.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 ClaudeTarget;
10
11impl Target for ClaudeTarget {
12 fn name(&self) -> &'static str {
13 "claude"
14 }
15
16 fn display_name(&self) -> &'static str {
17 "Claude Code"
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 detect_paths(&self, home: &Path) -> Vec<PathBuf> {
26 vec![home.join(".claude")]
27 }
28
29 fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
30 let dir = home.join(".claude");
31 if !dir.exists() {
32 return Ok(InstallOutcome::Skipped {
33 reason: format!(
34 "~/.claude not found at {} (Claude Code not installed)",
35 dir.display()
36 ),
37 });
38 }
39
40 let path = dir.join("settings.json");
41 let binary = "safe-chains";
42
43 if path.exists() {
44 let contents = std::fs::read_to_string(&path)
45 .map_err(|e| format!("Could not read {}: {e}", path.display()))?;
46 let mut settings: Value = serde_json::from_str(&contents)
47 .map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
48
49 if has_safe_chains_hook(&settings) {
50 return Ok(InstallOutcome::AlreadyConfigured { path });
51 }
52
53 add_hook(&mut settings, binary)?;
54 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
55 std::fs::write(&path, format!("{output}\n"))
56 .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
57 Ok(InstallOutcome::Installed { path })
58 } else {
59 let mut settings = Value::Object(Map::new());
60 add_hook(&mut settings, binary)?;
61 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
62 std::fs::write(&path, format!("{output}\n"))
63 .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
64 Ok(InstallOutcome::Installed { path })
65 }
66 }
67
68 fn hook_format(&self) -> Option<&dyn HookFormat> {
69 Some(&ClaudeHookFormat)
70 }
71}
72
73struct ClaudeHookFormat;
74
75#[derive(Deserialize)]
76struct ToolInput {
77 command: String,
78}
79
80#[derive(Deserialize)]
81struct ClaudeHookEnvelope {
82 #[serde(default)]
85 tool_name: Option<String>,
86 tool_input: ToolInput,
87 #[serde(default)]
88 cwd: Option<String>,
89 #[serde(default)]
92 session_id: Option<String>,
93}
94
95impl HookFormat for ClaudeHookFormat {
96 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
97 let envelope: ClaudeHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError {
98 message: e.to_string(),
99 })?;
100 if let Some(name) = &envelope.tool_name
107 && name != "Bash"
108 {
109 return Err(ParseError { message: format!("not a shell tool: {name}") });
110 }
111 Ok(HookInput {
112 command: envelope.tool_input.command,
113 cwd: envelope.cwd,
114 root: super::env_root("CLAUDE_PROJECT_DIR"),
115 session_id: envelope.session_id,
116 })
117 }
118
119 fn decision_pointer(&self) -> &'static str {
120 "/hookSpecificOutput/permissionDecision" }
122
123 fn render_response(&self, verdict: Verdict) -> HookResponse {
124 if verdict.is_allowed() {
125 let reason = allow_reason(verdict);
126 let body = json!({
127 "hookSpecificOutput": {
128 "hookEventName": "PreToolUse",
129 "permissionDecision": "allow",
130 "permissionDecisionReason": reason,
131 }
132 });
133 HookResponse {
134 stdout: serde_json::to_string(&body).unwrap_or_default(),
135 exit_code: 0,
136 }
137 } else {
138 HookResponse {
139 stdout: String::new(),
140 exit_code: 0,
141 }
142 }
143 }
144
145 fn render_context(&self, context: &str) -> HookResponse {
146 let body = json!({
150 "hookSpecificOutput": {
151 "hookEventName": "PreToolUse",
152 "additionalContext": context,
153 }
154 });
155 HookResponse {
156 stdout: serde_json::to_string(&body).unwrap_or_default(),
157 exit_code: 0,
158 }
159 }
160}
161
162fn hook_entry(binary: &str) -> Value {
163 json!({
164 "matcher": "Bash",
165 "hooks": [{
166 "type": "command",
167 "command": binary,
168 }]
169 })
170}
171
172fn has_safe_chains_hook(settings: &Value) -> bool {
173 settings
174 .get("hooks")
175 .and_then(|h| h.get("PreToolUse"))
176 .and_then(|arr| arr.as_array())
177 .is_some_and(|entries| {
178 entries.iter().any(|entry| {
179 entry
180 .get("hooks")
181 .and_then(|h| h.as_array())
182 .is_some_and(|hooks| {
183 hooks.iter().any(|hook| {
184 hook.get("command")
185 .and_then(|c| c.as_str())
186 .is_some_and(|cmd| cmd.contains("safe-chains"))
187 })
188 })
189 })
190 })
191}
192
193fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
194 super::append_hook_entry(settings, "hooks", "PreToolUse", hook_entry(binary))
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::verdict::SafetyLevel;
201
202 fn target() -> ClaudeTarget {
203 ClaudeTarget
204 }
205
206 #[test]
207 fn install_no_claude_dir_skips() {
208 let dir = tempfile::tempdir().unwrap();
209 let outcome = target().install(dir.path()).unwrap();
210 assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
211 }
212
213 #[test]
214 fn install_creates_settings_file() {
215 let dir = tempfile::tempdir().unwrap();
216 std::fs::create_dir(dir.path().join(".claude")).unwrap();
217 let outcome = target().install(dir.path()).unwrap();
218 assert!(matches!(outcome, InstallOutcome::Installed { .. }));
219 let contents =
220 std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
221 let settings: Value = serde_json::from_str(&contents).unwrap();
222 assert!(has_safe_chains_hook(&settings));
223 }
224
225 #[test]
226 fn install_preserves_existing_settings() {
227 let dir = tempfile::tempdir().unwrap();
228 let claude_dir = dir.path().join(".claude");
229 std::fs::create_dir(&claude_dir).unwrap();
230 std::fs::write(
231 claude_dir.join("settings.json"),
232 r#"{"permissions": {"allow": ["Bash(cargo test *)"]}}"#,
233 )
234 .unwrap();
235 target().install(dir.path()).unwrap();
236 let contents = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
237 let settings: Value = serde_json::from_str(&contents).unwrap();
238 assert!(has_safe_chains_hook(&settings));
239 assert!(
240 settings
241 .get("permissions")
242 .and_then(|p| p.get("allow"))
243 .is_some(),
244 "existing permissions must be preserved"
245 );
246 }
247
248 #[test]
249 fn install_idempotent() {
250 let dir = tempfile::tempdir().unwrap();
251 std::fs::create_dir(dir.path().join(".claude")).unwrap();
252 target().install(dir.path()).unwrap();
253 let outcome = target().install(dir.path()).unwrap();
254 assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
255 }
256
257 #[test]
258 fn detect_paths_returns_claude_dir() {
259 let dir = tempfile::tempdir().unwrap();
260 let paths = target().detect_paths(dir.path());
261 assert_eq!(paths, vec![dir.path().join(".claude")]);
262 }
263
264 #[test]
265 fn parse_input_extracts_command() {
266 let stdin = r#"{"tool_input": {"command": "ls -la"}, "cwd": "/tmp"}"#;
267 let parsed = ClaudeHookFormat.parse_input(stdin).unwrap();
268 assert_eq!(parsed.command, "ls -la");
269 assert_eq!(parsed.cwd.as_deref(), Some("/tmp"));
270 }
271
272 #[test]
273 fn parse_input_rejects_garbage() {
274 assert!(ClaudeHookFormat.parse_input("not json").is_err());
275 assert!(ClaudeHookFormat.parse_input("{}").is_err());
276 }
277
278 #[test]
279 fn render_response_allow_emits_allow_envelope() {
280 let r = ClaudeHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
281 assert_eq!(r.exit_code, 0);
282 let v: Value = serde_json::from_str(&r.stdout).unwrap();
283 assert_eq!(
284 v.pointer("/hookSpecificOutput/permissionDecision")
285 .and_then(|d| d.as_str()),
286 Some("allow"),
287 );
288 }
289
290 #[test]
291 fn render_response_deny_emits_empty_body() {
292 let r = ClaudeHookFormat.render_response(Verdict::Denied);
293 assert_eq!(r.exit_code, 0);
294 assert_eq!(r.stdout, "");
295 }
296
297 #[test]
298 fn render_context_injects_additional_context_without_decision() {
299 let r = ClaudeHookFormat.render_context("hello model");
300 assert_eq!(r.exit_code, 0);
301 let v: Value = serde_json::from_str(&r.stdout).unwrap();
302 assert_eq!(
303 v.pointer("/hookSpecificOutput/additionalContext")
304 .and_then(|c| c.as_str()),
305 Some("hello model"),
306 );
307 assert!(v.pointer("/hookSpecificOutput/permissionDecision").is_none());
309 }
310
311 #[test]
312 fn render_response_safewrite_carries_appropriate_reason() {
313 let r = ClaudeHookFormat.render_response(Verdict::Allowed(SafetyLevel::SafeWrite));
314 let v: Value = serde_json::from_str(&r.stdout).unwrap();
315 assert_eq!(
316 v.pointer("/hookSpecificOutput/permissionDecisionReason")
317 .and_then(|s| s.as_str()),
318 Some(allow_reason(Verdict::Allowed(SafetyLevel::SafeWrite))),
319 );
320 }
321}