claude_code_acp/settings/
permission_checker.rs

1//! Permission checker implementation
2//!
3//! Checks tool permissions against settings rules.
4
5use std::path::{Path, PathBuf};
6
7use super::manager::Settings;
8use super::rule::{ParsedRule, PermissionCheckResult};
9
10/// Permission checker that evaluates tool permissions against settings rules
11#[derive(Debug)]
12pub struct PermissionChecker {
13    /// Merged settings with permission rules
14    settings: Settings,
15    /// Working directory for path resolution
16    cwd: PathBuf,
17    /// Parsed and cached allow rules
18    allow_rules: Vec<(String, ParsedRule)>,
19    /// Parsed and cached deny rules
20    deny_rules: Vec<(String, ParsedRule)>,
21    /// Parsed and cached ask rules
22    ask_rules: Vec<(String, ParsedRule)>,
23}
24
25impl PermissionChecker {
26    /// Create a new permission checker
27    pub fn new(settings: Settings, cwd: impl AsRef<Path>) -> Self {
28        let cwd = cwd.as_ref().to_path_buf();
29
30        // Pre-parse rules for efficiency
31        let allow_rules = Self::parse_rules(
32            settings.permissions.as_ref().and_then(|p| p.allow.as_ref()),
33            &cwd,
34        );
35        let deny_rules = Self::parse_rules(
36            settings.permissions.as_ref().and_then(|p| p.deny.as_ref()),
37            &cwd,
38        );
39        let ask_rules = Self::parse_rules(
40            settings.permissions.as_ref().and_then(|p| p.ask.as_ref()),
41            &cwd,
42        );
43
44        Self {
45            settings,
46            cwd,
47            allow_rules,
48            deny_rules,
49            ask_rules,
50        }
51    }
52
53    /// Parse a list of rule strings into ParsedRule objects
54    fn parse_rules(rules: Option<&Vec<String>>, cwd: &Path) -> Vec<(String, ParsedRule)> {
55        rules
56            .map(|rules| {
57                rules
58                    .iter()
59                    .map(|rule| (rule.clone(), ParsedRule::parse_with_glob(rule, cwd)))
60                    .collect()
61            })
62            .unwrap_or_default()
63    }
64
65    /// Check permission for a tool invocation
66    ///
67    /// Priority: deny > allow > ask
68    ///
69    /// Returns the permission decision and matching rule (if any).
70    pub fn check_permission(
71        &self,
72        tool_name: &str,
73        tool_input: &serde_json::Value,
74    ) -> PermissionCheckResult {
75        // Check deny rules first (highest priority)
76        for (rule_str, parsed) in &self.deny_rules {
77            if parsed.matches(tool_name, tool_input, &self.cwd) {
78                tracing::debug!("Tool {} denied by rule: {}", tool_name, rule_str);
79                return PermissionCheckResult::deny(rule_str);
80            }
81        }
82
83        // Check allow rules
84        for (rule_str, parsed) in &self.allow_rules {
85            if parsed.matches(tool_name, tool_input, &self.cwd) {
86                tracing::debug!("Tool {} allowed by rule: {}", tool_name, rule_str);
87                return PermissionCheckResult::allow(rule_str);
88            }
89        }
90
91        // Check ask rules
92        for (rule_str, parsed) in &self.ask_rules {
93            if parsed.matches(tool_name, tool_input, &self.cwd) {
94                tracing::debug!(
95                    "Tool {} requires permission (ask rule): {}",
96                    tool_name,
97                    rule_str
98                );
99                return PermissionCheckResult::ask_with_rule(rule_str);
100            }
101        }
102
103        // Default: ask
104        tracing::debug!("Tool {} has no matching rule, defaulting to ask", tool_name);
105        PermissionCheckResult::ask()
106    }
107
108    /// Get the settings
109    pub fn settings(&self) -> &Settings {
110        &self.settings
111    }
112
113    /// Get the working directory
114    pub fn cwd(&self) -> &Path {
115        &self.cwd
116    }
117
118    /// Check if there are any permission rules configured
119    pub fn has_rules(&self) -> bool {
120        !self.allow_rules.is_empty() || !self.deny_rules.is_empty() || !self.ask_rules.is_empty()
121    }
122
123    /// Add a runtime allow rule (e.g., from user's "Always Allow" choice)
124    pub fn add_allow_rule(&mut self, rule: &str) {
125        let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
126        self.allow_rules.push((rule.to_string(), parsed));
127    }
128
129    /// Add a runtime deny rule
130    pub fn add_deny_rule(&mut self, rule: &str) {
131        let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
132        self.deny_rules.push((rule.to_string(), parsed));
133    }
134
135    /// Get the default permission mode from settings
136    pub fn default_mode(&self) -> Option<&str> {
137        self.settings
138            .permissions
139            .as_ref()
140            .and_then(|p| p.default_mode.as_deref())
141    }
142
143    /// Get additional directories from settings
144    pub fn additional_directories(&self) -> Option<&Vec<String>> {
145        self.settings
146            .permissions
147            .as_ref()
148            .and_then(|p| p.additional_directories.as_ref())
149    }
150}
151
152impl Default for PermissionChecker {
153    fn default() -> Self {
154        Self::new(Settings::default(), PathBuf::from("."))
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::settings::{PermissionDecision, PermissionSettings};
162    use serde_json::json;
163
164    fn settings_with_permissions(permissions: PermissionSettings) -> Settings {
165        Settings {
166            permissions: Some(permissions),
167            ..Default::default()
168        }
169    }
170
171    #[test]
172    fn test_empty_rules_default_to_ask() {
173        let checker = PermissionChecker::default();
174        let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
175
176        assert_eq!(result.decision, PermissionDecision::Ask);
177        assert!(result.rule.is_none());
178    }
179
180    #[test]
181    fn test_allow_rule() {
182        let permissions = PermissionSettings {
183            allow: Some(vec!["Read".to_string()]),
184            ..Default::default()
185        };
186        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
187
188        let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
189        assert_eq!(result.decision, PermissionDecision::Allow);
190        assert_eq!(result.rule, Some("Read".to_string()));
191    }
192
193    #[test]
194    fn test_deny_rule() {
195        let permissions = PermissionSettings {
196            deny: Some(vec!["Bash".to_string()]),
197            ..Default::default()
198        };
199        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
200
201        let result = checker.check_permission("Bash", &json!({"command": "rm -rf /"}));
202        assert_eq!(result.decision, PermissionDecision::Deny);
203    }
204
205    #[test]
206    fn test_deny_takes_priority_over_allow() {
207        let permissions = PermissionSettings {
208            allow: Some(vec!["Bash".to_string()]),
209            deny: Some(vec!["Bash".to_string()]),
210            ..Default::default()
211        };
212        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
213
214        let result = checker.check_permission("Bash", &json!({"command": "ls"}));
215        assert_eq!(result.decision, PermissionDecision::Deny);
216    }
217
218    #[test]
219    fn test_allow_takes_priority_over_ask() {
220        let permissions = PermissionSettings {
221            allow: Some(vec!["Read".to_string()]),
222            ask: Some(vec!["Read".to_string()]),
223            ..Default::default()
224        };
225        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
226
227        let result = checker.check_permission("Read", &json!({}));
228        assert_eq!(result.decision, PermissionDecision::Allow);
229    }
230
231    #[test]
232    fn test_bash_wildcard_rule() {
233        let permissions = PermissionSettings {
234            allow: Some(vec!["Bash(npm run:*)".to_string()]),
235            ..Default::default()
236        };
237        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
238
239        // Should allow npm run commands
240        assert_eq!(
241            checker
242                .check_permission("Bash", &json!({"command": "npm run build"}))
243                .decision,
244            PermissionDecision::Allow
245        );
246
247        // Should not allow npm install
248        assert_eq!(
249            checker
250                .check_permission("Bash", &json!({"command": "npm install"}))
251                .decision,
252            PermissionDecision::Ask
253        );
254
255        // Should block command chaining
256        assert_eq!(
257            checker
258                .check_permission("Bash", &json!({"command": "npm run build && rm -rf /"}))
259                .decision,
260            PermissionDecision::Ask
261        );
262    }
263
264    #[test]
265    fn test_read_group_matching() {
266        let permissions = PermissionSettings {
267            allow: Some(vec!["Read".to_string()]),
268            ..Default::default()
269        };
270        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
271
272        // Read rule should allow Read, Grep, Glob, LS
273        assert_eq!(
274            checker.check_permission("Read", &json!({})).decision,
275            PermissionDecision::Allow
276        );
277        assert_eq!(
278            checker.check_permission("Grep", &json!({})).decision,
279            PermissionDecision::Allow
280        );
281        assert_eq!(
282            checker.check_permission("Glob", &json!({})).decision,
283            PermissionDecision::Allow
284        );
285        assert_eq!(
286            checker.check_permission("LS", &json!({})).decision,
287            PermissionDecision::Allow
288        );
289
290        // Should not allow Write
291        assert_eq!(
292            checker.check_permission("Write", &json!({})).decision,
293            PermissionDecision::Ask
294        );
295    }
296
297    #[test]
298    fn test_add_runtime_rule() {
299        let mut checker = PermissionChecker::default();
300
301        // Initially should ask
302        assert_eq!(
303            checker.check_permission("Read", &json!({})).decision,
304            PermissionDecision::Ask
305        );
306
307        // Add allow rule at runtime
308        checker.add_allow_rule("Read");
309
310        // Now should allow
311        assert_eq!(
312            checker.check_permission("Read", &json!({})).decision,
313            PermissionDecision::Allow
314        );
315    }
316
317    #[test]
318    fn test_acp_prefix_stripped() {
319        let permissions = PermissionSettings {
320            allow: Some(vec!["Read".to_string()]),
321            ..Default::default()
322        };
323        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
324
325        // Should work with or without ACP prefix
326        assert_eq!(
327            checker.check_permission("Read", &json!({})).decision,
328            PermissionDecision::Allow
329        );
330        assert_eq!(
331            checker
332                .check_permission("mcp__acp__Read", &json!({}))
333                .decision,
334            PermissionDecision::Allow
335        );
336    }
337
338    #[test]
339    fn test_has_rules() {
340        let checker = PermissionChecker::default();
341        assert!(!checker.has_rules());
342
343        let permissions = PermissionSettings {
344            allow: Some(vec!["Read".to_string()]),
345            ..Default::default()
346        };
347        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
348        assert!(checker.has_rules());
349    }
350}