1use std::path::{Path, PathBuf};
9
10use serde_json::Value;
11
12use crate::verdict::Decision;
13
14pub struct CcRules {
16 allow: Vec<String>,
17 deny: Vec<String>,
18 ask: Vec<String>,
19}
20
21impl CcRules {
22 #[must_use]
29 pub fn check(&self, command: &str) -> Option<Decision> {
30 for pattern in &self.deny {
31 if command_matches_pattern(command, pattern) {
32 return Some(Decision::Deny);
33 }
34 }
35
36 for pattern in &self.ask {
37 if command_matches_pattern(command, pattern) {
38 return Some(Decision::Ask);
39 }
40 }
41
42 for pattern in &self.allow {
43 if command_matches_pattern(command, pattern) {
44 return Some(Decision::Allow);
45 }
46 }
47
48 None
49 }
50
51 #[must_use]
53 pub const fn is_empty(&self) -> bool {
54 self.allow.is_empty() && self.deny.is_empty() && self.ask.is_empty()
55 }
56
57 #[must_use]
59 pub fn all_rules(&self) -> Vec<(Decision, &str)> {
60 let mut rules = Vec::new();
61 for p in &self.allow {
62 rules.push((Decision::Allow, p.as_str()));
63 }
64 for p in &self.deny {
65 rules.push((Decision::Deny, p.as_str()));
66 }
67 for p in &self.ask {
68 rules.push((Decision::Ask, p.as_str()));
69 }
70 rules
71 }
72}
73
74#[must_use]
83pub fn load_cc_rules(working_dir: &Path) -> CcRules {
84 load_cc_rules_with_home(working_dir, env_home_dir())
85}
86
87#[must_use]
91pub fn load_cc_rules_with_home(working_dir: &Path, home: Option<PathBuf>) -> CcRules {
92 load_rules_from_paths(&get_settings_paths_with_home(working_dir, home))
93}
94
95pub(crate) fn get_settings_paths(working_dir: &Path) -> Vec<PathBuf> {
96 get_settings_paths_with_home(working_dir, env_home_dir())
97}
98
99pub(crate) fn get_settings_paths_with_home(
100 working_dir: &Path,
101 home: Option<PathBuf>,
102) -> Vec<PathBuf> {
103 let mut paths = Vec::new();
104
105 let mut dir = working_dir.to_path_buf();
107 loop {
108 if dir.join(".claude").is_dir() {
109 paths.push(dir.join(".claude").join("settings.json"));
110 paths.push(dir.join(".claude").join("settings.local.json"));
111 break;
112 }
113 if !dir.pop() {
114 break;
115 }
116 }
117
118 if let Some(home) = home {
119 paths.push(home.join(".claude").join("settings.json"));
120 paths.push(home.join(".claude").join("settings.local.json"));
121 }
122
123 paths
124}
125
126fn env_home_dir() -> Option<PathBuf> {
127 std::env::var_os("HOME").map(PathBuf::from)
128}
129
130fn load_rules_from_paths(paths: &[PathBuf]) -> CcRules {
131 let mut allow = Vec::new();
132 let mut deny = Vec::new();
133 let mut ask = Vec::new();
134
135 for path in paths {
136 let content = match std::fs::read_to_string(path) {
137 Ok(c) => c,
138 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
139 Err(e) => {
140 eprintln!(
141 "[rippy] warning: could not read {}: {e} — failing closed",
142 path.display()
143 );
144 ask.push("*".to_string());
145 continue;
146 }
147 };
148 let json = match serde_json::from_str::<Value>(&content) {
149 Ok(v) => v,
150 Err(e) => {
151 eprintln!(
152 "[rippy] warning: could not parse {}: {e} — failing closed",
153 path.display()
154 );
155 ask.push("*".to_string());
156 continue;
157 }
158 };
159 let Some(permissions) = json.get("permissions") else {
160 continue;
161 };
162
163 append_bash_rules(permissions.get("allow"), &mut allow);
164 append_bash_rules(permissions.get("deny"), &mut deny);
165 append_bash_rules(permissions.get("ask"), &mut ask);
166 }
167
168 CcRules { allow, deny, ask }
169}
170
171fn append_bash_rules(rules_value: Option<&Value>, target: &mut Vec<String>) {
172 let Some(arr) = rules_value.and_then(Value::as_array) else {
173 return;
174 };
175 for rule in arr {
176 if let Some(s) = rule.as_str()
177 && let Some(pattern) = extract_bash_pattern(s)
178 {
179 target.push(pattern.to_string());
180 }
181 }
182}
183
184fn extract_bash_pattern(rule: &str) -> Option<&str> {
186 rule.strip_prefix("Bash(")
187 .and_then(|inner| inner.strip_suffix(')'))
188}
189
190fn command_matches_pattern(cmd: &str, pattern: &str) -> bool {
194 if !pattern.contains('*') {
195 return starts_with_word(cmd, pattern);
196 }
197
198 let ends_with_star = pattern.ends_with('*');
199 let mut split = pattern.split('*').peekable();
200 let mut pos = 0;
201 let mut is_first = true;
202
203 while let Some(segment) = split.next() {
204 let is_last = split.peek().is_none();
205 let seg = if is_first {
206 segment.trim_end_matches(':').trim_end()
207 } else {
208 segment.trim()
209 };
210
211 if seg.is_empty() {
212 is_first = false;
213 continue;
214 }
215
216 if is_first {
217 if !starts_with_word(cmd, seg) {
218 return false;
219 }
220 pos = seg.len();
221 } else if is_last && !ends_with_star {
222 return ends_with_word(cmd, seg);
223 } else {
224 match find_word(cmd, pos, seg) {
225 Some(end) => pos = end,
226 None => return false,
227 }
228 }
229
230 is_first = false;
231 }
232
233 true
234}
235
236fn starts_with_word(cmd: &str, word: &str) -> bool {
238 cmd == word
239 || (cmd.len() > word.len() && cmd.as_bytes()[word.len()] == b' ' && cmd.starts_with(word))
240}
241
242fn ends_with_word(cmd: &str, word: &str) -> bool {
244 cmd == word
245 || (cmd.len() > word.len()
246 && cmd.as_bytes()[cmd.len() - word.len() - 1] == b' '
247 && cmd.ends_with(word))
248}
249
250fn find_word(cmd: &str, from: usize, needle: &str) -> Option<usize> {
252 let haystack = &cmd[from..];
253 let mut search_from = 0;
254 while let Some(idx) = haystack[search_from..].find(needle) {
255 let abs_start = from + search_from + idx;
256 let abs_end = abs_start + needle.len();
257 let left_ok = abs_start == 0 || cmd.as_bytes()[abs_start - 1] == b' ';
258 let right_ok = abs_end == cmd.len() || cmd.as_bytes()[abs_end] == b' ';
259 if left_ok && right_ok {
260 return Some(abs_end);
261 }
262 search_from += idx + 1;
263 }
264 None
265}
266
267#[cfg(test)]
268#[allow(clippy::unwrap_used)]
269mod tests {
270 use super::*;
271
272 #[test]
275 fn exact_match() {
276 assert!(command_matches_pattern(
277 "git push --force",
278 "git push --force"
279 ));
280 }
281
282 #[test]
283 fn prefix_match_with_args() {
284 assert!(command_matches_pattern(
285 "git push --force origin",
286 "git push --force"
287 ));
288 }
289
290 #[test]
291 fn no_partial_word_match() {
292 assert!(!command_matches_pattern(
293 "git push --forceful",
294 "git push --force"
295 ));
296 }
297
298 #[test]
299 fn wildcard_all() {
300 assert!(command_matches_pattern("anything", "*"));
301 assert!(command_matches_pattern("", "*"));
302 }
303
304 #[test]
305 fn wildcard_trailing() {
306 assert!(command_matches_pattern(
307 "git push origin main",
308 "git push *"
309 ));
310 }
311
312 #[test]
313 fn wildcard_leading() {
314 assert!(command_matches_pattern("git push --force", "* --force"));
315 }
316
317 #[test]
318 fn wildcard_leading_no_partial() {
319 assert!(!command_matches_pattern("git push --forceful", "* --force"));
320 }
321
322 #[test]
323 fn wildcard_middle() {
324 assert!(command_matches_pattern("git push main", "git * main"));
325 }
326
327 #[test]
328 fn wildcard_middle_no_partial() {
329 assert!(!command_matches_pattern("git push xmain", "git * main"));
330 }
331
332 #[test]
333 fn wildcard_colon_prefix() {
334 assert!(command_matches_pattern("sudo rm -rf /", "sudo:*"));
335 }
336
337 #[test]
338 fn wildcard_colon_no_false_positive() {
339 assert!(!command_matches_pattern("sudoedit /etc/hosts", "sudo:*"));
340 }
341
342 #[test]
343 fn no_match() {
344 assert!(!command_matches_pattern("git status", "git push --force"));
345 }
346
347 #[test]
350 fn extract_bash_valid() {
351 assert_eq!(extract_bash_pattern("Bash(git push)"), Some("git push"));
352 assert_eq!(extract_bash_pattern("Bash(*)"), Some("*"));
353 }
354
355 #[test]
356 fn extract_non_bash_ignored() {
357 assert_eq!(extract_bash_pattern("Read(**/.env*)"), None);
358 assert_eq!(extract_bash_pattern("Write(*)"), None);
359 }
360
361 #[test]
364 fn check_deny_trumps_all() {
365 let rules = CcRules {
366 allow: vec!["git push".into()],
367 deny: vec!["git push --force".into()],
368 ask: vec![],
369 };
370 assert_eq!(rules.check("git push --force"), Some(Decision::Deny));
371 }
372
373 #[test]
374 fn check_ask_trumps_allow() {
375 let rules = CcRules {
376 allow: vec!["git push".into()],
377 deny: vec![],
378 ask: vec!["git push".into()],
379 };
380 assert_eq!(rules.check("git push origin"), Some(Decision::Ask));
381 }
382
383 #[test]
384 fn check_allow_matches() {
385 let rules = CcRules {
386 allow: vec!["git push".into()],
387 deny: vec![],
388 ask: vec![],
389 };
390 assert_eq!(rules.check("git push origin"), Some(Decision::Allow));
391 }
392
393 #[test]
394 fn check_no_match_returns_none() {
395 let rules = CcRules {
396 allow: vec!["git push".into()],
397 deny: vec![],
398 ask: vec![],
399 };
400 assert_eq!(rules.check("git status"), None);
401 }
402
403 #[test]
406 fn load_from_settings_file() {
407 let dir = tempfile::tempdir().unwrap();
408 let claude_dir = dir.path().join(".claude");
409 std::fs::create_dir(&claude_dir).unwrap();
410 std::fs::write(
411 claude_dir.join("settings.json"),
412 r#"{
413 "permissions": {
414 "allow": ["Bash(git status)", "Bash(cargo test *)"],
415 "deny": ["Bash(rm -rf /)"],
416 "ask": ["Bash(git push)"]
417 }
418 }"#,
419 )
420 .unwrap();
421
422 let rules = load_rules_from_paths(&[claude_dir.join("settings.json")]);
423 assert_eq!(rules.check("git status"), Some(Decision::Allow));
424 assert_eq!(rules.check("cargo test --all"), Some(Decision::Allow));
425 assert_eq!(rules.check("rm -rf /"), Some(Decision::Deny));
426 assert_eq!(rules.check("git push origin"), Some(Decision::Ask));
427 assert_eq!(rules.check("ls -la"), None);
428 }
429
430 #[test]
431 fn missing_settings_no_rules() {
432 let dir = tempfile::tempdir().unwrap();
433 let rules = load_rules_from_paths(&[dir.path().join("nonexistent.json")]);
435 assert!(rules.is_empty());
436 assert_eq!(rules.check("anything"), None);
437 }
438
439 #[test]
440 fn malformed_json_fails_closed() {
441 let dir = tempfile::tempdir().unwrap();
442 let claude_dir = dir.path().join(".claude");
443 std::fs::create_dir(&claude_dir).unwrap();
444 std::fs::write(claude_dir.join("settings.json"), "not valid json {{{").unwrap();
445
446 let rules = load_rules_from_paths(&[claude_dir.join("settings.json")]);
447 assert_eq!(rules.check("git status"), Some(Decision::Ask));
449 }
450
451 #[test]
452 fn non_bash_rules_ignored() {
453 let dir = tempfile::tempdir().unwrap();
454 let claude_dir = dir.path().join(".claude");
455 std::fs::create_dir(&claude_dir).unwrap();
456 std::fs::write(
457 claude_dir.join("settings.json"),
458 r#"{
459 "permissions": {
460 "deny": ["Read(**/.env*)", "Write(*)"]
461 }
462 }"#,
463 )
464 .unwrap();
465
466 let rules = load_rules_from_paths(&[claude_dir.join("settings.json")]);
467 assert!(rules.is_empty());
468 }
469
470 #[test]
471 fn local_settings_merged() {
472 let dir = tempfile::tempdir().unwrap();
473 let claude_dir = dir.path().join(".claude");
474 std::fs::create_dir(&claude_dir).unwrap();
475 std::fs::write(
476 claude_dir.join("settings.json"),
477 r#"{"permissions": {"deny": ["Bash(rm -rf /)"]}}"#,
478 )
479 .unwrap();
480 std::fs::write(
481 claude_dir.join("settings.local.json"),
482 r#"{"permissions": {"allow": ["Bash(git push)"]}}"#,
483 )
484 .unwrap();
485
486 let rules = load_rules_from_paths(&[
487 claude_dir.join("settings.json"),
488 claude_dir.join("settings.local.json"),
489 ]);
490 assert_eq!(rules.check("rm -rf /"), Some(Decision::Deny));
491 assert_eq!(rules.check("git push origin"), Some(Decision::Allow));
492 }
493}