Skip to main content

rippy_cli/
cc_permissions.rs

1//! Read and evaluate Claude Code's permission rules from settings files.
2//!
3//! Claude Code stores user-granted permissions in `settings.json` and
4//! `settings.local.json` files. This module reads `permissions.allow`,
5//! `permissions.deny`, and `permissions.ask` arrays, extracts `Bash(...)`
6//! patterns, and checks commands against them with word-boundary matching.
7
8use std::path::{Path, PathBuf};
9
10use serde_json::Value;
11
12use crate::verdict::Decision;
13
14/// Loaded Claude Code permission rules.
15pub struct CcRules {
16    allow: Vec<String>,
17    deny: Vec<String>,
18    ask: Vec<String>,
19}
20
21impl CcRules {
22    /// Check a command against CC permission rules.
23    ///
24    /// Returns `Some(Decision)` if a rule matches, `None` if no rule matches
25    /// (fall through to rippy's own analysis).
26    ///
27    /// Priority: deny > ask > allow.
28    #[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    /// Returns true if no rules were loaded.
52    #[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    /// Return all rules as (decision, pattern) pairs for inspection.
58    #[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/// Load CC permission rules from all settings file paths.
75///
76/// Walks up from `working_dir` to find the nearest `.claude/` directory,
77/// then reads (in order):
78/// 1. `{project}/.claude/settings.json`
79/// 2. `{project}/.claude/settings.local.json`
80/// 3. `~/.claude/settings.json`
81/// 4. `~/.claude/settings.local.json`
82#[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/// Load CC rules with an explicit home directory instead of reading `$HOME`.
88///
89/// Pass `None` to skip `~/.claude/` settings (useful for tests).
90#[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    // Walk up from working_dir to find .claude/ directory
106    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
184/// Extract the inner pattern from `Bash(pattern)`. Returns `None` for non-Bash rules.
185fn extract_bash_pattern(rule: &str) -> Option<&str> {
186    rule.strip_prefix("Bash(")
187        .and_then(|inner| inner.strip_suffix(')'))
188}
189
190/// Check if `cmd` matches a CC permission pattern.
191///
192/// Supports `*` as a wildcard with word-boundary semantics.
193fn 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
236/// Check if `cmd` equals `word` or starts with `word` followed by a space.
237fn 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
242/// Check if `cmd` ends with `word` preceded by a space (or equals `word`).
243fn 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
250/// Find `needle` in `cmd[from..]` at a word boundary.
251fn 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    // ---- Pattern matching ----
273
274    #[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    // ---- extract_bash_pattern ----
348
349    #[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    // ---- CcRules::check ----
362
363    #[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    // ---- Settings file loading ----
404
405    #[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        // Use explicit paths to avoid picking up real ~/.claude/settings.json
434        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        // Wildcard ask rule injected → everything matches as Ask
448        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}