Skip to main content

safe_chains/targets/
claude.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 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    /// Present in every real envelope; optional so a harness that omits it still works. When it IS
83    /// present and names a different tool, we abstain — see `parse_input`.
84    #[serde(default)]
85    tool_name: Option<String>,
86    tool_input: ToolInput,
87    #[serde(default)]
88    cwd: Option<String>,
89    /// Claude Code's per-session UUID. Used only as the unforgeable anchor for recognizing this
90    /// session's scratchpad (`pathctx::session_scratchpad`); absent → nothing is recognized.
91    #[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        // Self-filter on the tool, as gemini/copilot/cursor already do. The configured matcher is
101        // `Bash`, so normally only shell calls arrive — but a hand-edited matcher, or grok
102        // auto-loading `~/.claude/settings.json`, can deliver others, and this target is
103        // ALLOW-capable: emitting `permissionDecision: allow` for a `Read`/`Write`/`Edit` call
104        // grants permission on a tool whose semantics were never analysed. Absent tool_name still
105        // passes, so a harness that omits the field is unaffected.
106        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" // nested under hookSpecificOutput
121    }
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        // additionalContext injects model-visible text without a
147        // permissionDecision, so the normal approval flow (and the user's own
148        // allowlist) is untouched.
149        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        // Crucial: no permissionDecision, so the user's allowlist/flow is untouched.
308        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}