Skip to main content

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 allow rule based on a specific tool call (fine-grained)
130    ///
131    /// This generates a rule based on the actual tool invocation, providing
132    /// finer-grained permissions. For example:
133    /// - Bash: generates `Bash(command_prefix:*)` instead of just `Bash`
134    /// - Read/Edit/Write: generates `Tool(directory/**)` for the file's directory
135    pub fn add_allow_rule_for_tool_call(
136        &mut self,
137        tool_name: &str,
138        tool_input: &serde_json::Value,
139    ) {
140        let rule = self.generate_rule_from_call(tool_name, tool_input);
141        tracing::info!(
142            tool_name = %tool_name,
143            generated_rule = %rule,
144            "Adding fine-grained allow rule"
145        );
146        let parsed = ParsedRule::parse_with_glob(&rule, &self.cwd);
147        self.allow_rules.push((rule, parsed));
148    }
149
150    /// Generate a permission rule string from a tool call
151    fn generate_rule_from_call(&self, tool_name: &str, tool_input: &serde_json::Value) -> String {
152        let stripped = tool_name.strip_prefix("mcp__acp__").unwrap_or(tool_name);
153
154        match stripped {
155            "Bash" => {
156                // Extract command and generate prefix rule
157                if let Some(cmd) = tool_input.get("command").and_then(|v| v.as_str()) {
158                    let prefix = Self::extract_command_prefix(cmd);
159                    if !prefix.is_empty() {
160                        format!("Bash({}:*)", prefix) // e.g., "Bash(cargo build:*)"
161                    } else {
162                        stripped.to_string()
163                    }
164                } else {
165                    stripped.to_string()
166                }
167            }
168            "Read" | "Grep" | "Glob" | "LS" => {
169                // Extract file path and generate directory rule
170                Self::generate_file_rule("Read", tool_input, &self.cwd)
171            }
172            "Edit" | "Write" => {
173                // Extract file path and generate directory rule
174                Self::generate_file_rule(stripped, tool_input, &self.cwd)
175            }
176            _ => stripped.to_string(),
177        }
178    }
179
180    /// Extract command prefix (first two space-separated parts)
181    fn extract_command_prefix(cmd: &str) -> String {
182        let parts: Vec<&str> = cmd.split_whitespace().take(2).collect();
183        parts.join(" ")
184    }
185
186    /// Generate a file-based permission rule
187    fn generate_file_rule(tool_name: &str, tool_input: &serde_json::Value, cwd: &Path) -> String {
188        if let Some(path) = tool_input.get("file_path").and_then(|v| v.as_str()) {
189            let path = Path::new(path);
190
191            // Try to get the directory
192            if let Some(dir) = path.parent() {
193                // Make path relative to cwd if possible
194                let dir_str = if let Ok(relative) = dir.strip_prefix(cwd) {
195                    format!("./{}", relative.display())
196                } else {
197                    dir.to_string_lossy().to_string()
198                };
199
200                // Generate glob rule for the directory
201                if dir_str.is_empty() || dir_str == "." {
202                    format!("{}(./*)", tool_name)
203                } else {
204                    format!("{}({}/**)", tool_name, dir_str)
205                }
206            } else {
207                tool_name.to_string()
208            }
209        } else {
210            tool_name.to_string()
211        }
212    }
213
214    /// Add a runtime deny rule
215    pub fn add_deny_rule(&mut self, rule: &str) {
216        let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
217        self.deny_rules.push((rule.to_string(), parsed));
218    }
219
220    /// Get the default permission mode from settings
221    pub fn default_mode(&self) -> Option<&str> {
222        self.settings
223            .permissions
224            .as_ref()
225            .and_then(|p| p.default_mode.as_deref())
226    }
227
228    /// Get additional directories from settings
229    pub fn additional_directories(&self) -> Option<&Vec<String>> {
230        self.settings
231            .permissions
232            .as_ref()
233            .and_then(|p| p.additional_directories.as_ref())
234    }
235}
236
237impl Default for PermissionChecker {
238    fn default() -> Self {
239        Self::new(Settings::default(), PathBuf::from("."))
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::settings::{PermissionDecision, PermissionSettings};
247    use serde_json::json;
248
249    fn settings_with_permissions(permissions: PermissionSettings) -> Settings {
250        Settings {
251            permissions: Some(permissions),
252            ..Default::default()
253        }
254    }
255
256    #[test]
257    fn test_empty_rules_default_to_ask() {
258        let checker = PermissionChecker::default();
259        let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
260
261        assert_eq!(result.decision, PermissionDecision::Ask);
262        assert!(result.rule.is_none());
263    }
264
265    #[test]
266    fn test_allow_rule() {
267        let permissions = PermissionSettings {
268            allow: Some(vec!["Read".to_string()]),
269            ..Default::default()
270        };
271        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
272
273        let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
274        assert_eq!(result.decision, PermissionDecision::Allow);
275        assert_eq!(result.rule, Some("Read".to_string()));
276    }
277
278    #[test]
279    fn test_deny_rule() {
280        let permissions = PermissionSettings {
281            deny: Some(vec!["Bash".to_string()]),
282            ..Default::default()
283        };
284        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
285
286        let result = checker.check_permission("Bash", &json!({"command": "rm -rf /"}));
287        assert_eq!(result.decision, PermissionDecision::Deny);
288    }
289
290    #[test]
291    fn test_deny_takes_priority_over_allow() {
292        let permissions = PermissionSettings {
293            allow: Some(vec!["Bash".to_string()]),
294            deny: Some(vec!["Bash".to_string()]),
295            ..Default::default()
296        };
297        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
298
299        let result = checker.check_permission("Bash", &json!({"command": "ls"}));
300        assert_eq!(result.decision, PermissionDecision::Deny);
301    }
302
303    #[test]
304    fn test_allow_takes_priority_over_ask() {
305        let permissions = PermissionSettings {
306            allow: Some(vec!["Read".to_string()]),
307            ask: Some(vec!["Read".to_string()]),
308            ..Default::default()
309        };
310        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
311
312        let result = checker.check_permission("Read", &json!({}));
313        assert_eq!(result.decision, PermissionDecision::Allow);
314    }
315
316    #[test]
317    fn test_bash_wildcard_rule() {
318        let permissions = PermissionSettings {
319            allow: Some(vec!["Bash(npm run:*)".to_string()]),
320            ..Default::default()
321        };
322        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
323
324        // Should allow npm run commands
325        assert_eq!(
326            checker
327                .check_permission("Bash", &json!({"command": "npm run build"}))
328                .decision,
329            PermissionDecision::Allow
330        );
331
332        // Should not allow npm install
333        assert_eq!(
334            checker
335                .check_permission("Bash", &json!({"command": "npm install"}))
336                .decision,
337            PermissionDecision::Ask
338        );
339
340        // Should block command chaining
341        assert_eq!(
342            checker
343                .check_permission("Bash", &json!({"command": "npm run build && rm -rf /"}))
344                .decision,
345            PermissionDecision::Ask
346        );
347    }
348
349    #[test]
350    fn test_read_group_matching() {
351        let permissions = PermissionSettings {
352            allow: Some(vec!["Read".to_string()]),
353            ..Default::default()
354        };
355        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
356
357        // Read rule should allow Read, Grep, Glob, LS
358        assert_eq!(
359            checker.check_permission("Read", &json!({})).decision,
360            PermissionDecision::Allow
361        );
362        assert_eq!(
363            checker.check_permission("Grep", &json!({})).decision,
364            PermissionDecision::Allow
365        );
366        assert_eq!(
367            checker.check_permission("Glob", &json!({})).decision,
368            PermissionDecision::Allow
369        );
370        assert_eq!(
371            checker.check_permission("LS", &json!({})).decision,
372            PermissionDecision::Allow
373        );
374
375        // Should not allow Write
376        assert_eq!(
377            checker.check_permission("Write", &json!({})).decision,
378            PermissionDecision::Ask
379        );
380    }
381
382    #[test]
383    fn test_add_runtime_rule() {
384        let mut checker = PermissionChecker::default();
385
386        // Initially should ask
387        assert_eq!(
388            checker.check_permission("Read", &json!({})).decision,
389            PermissionDecision::Ask
390        );
391
392        // Add allow rule at runtime
393        checker.add_allow_rule("Read");
394
395        // Now should allow
396        assert_eq!(
397            checker.check_permission("Read", &json!({})).decision,
398            PermissionDecision::Allow
399        );
400    }
401
402    #[test]
403    fn test_acp_prefix_stripped() {
404        let permissions = PermissionSettings {
405            allow: Some(vec!["Read".to_string()]),
406            ..Default::default()
407        };
408        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
409
410        // Should work with or without ACP prefix
411        assert_eq!(
412            checker.check_permission("Read", &json!({})).decision,
413            PermissionDecision::Allow
414        );
415        assert_eq!(
416            checker
417                .check_permission("mcp__acp__Read", &json!({}))
418                .decision,
419            PermissionDecision::Allow
420        );
421    }
422
423    #[test]
424    fn test_has_rules() {
425        let checker = PermissionChecker::default();
426        assert!(!checker.has_rules());
427
428        let permissions = PermissionSettings {
429            allow: Some(vec!["Read".to_string()]),
430            ..Default::default()
431        };
432        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
433        assert!(checker.has_rules());
434    }
435
436    #[test]
437    fn test_add_allow_rule_for_bash_command() {
438        let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
439
440        // Add rule for specific bash command
441        checker.add_allow_rule_for_tool_call("Bash", &json!({"command": "cargo build --release"}));
442
443        // Should allow the same command
444        assert_eq!(
445            checker
446                .check_permission("Bash", &json!({"command": "cargo build --release"}))
447                .decision,
448            PermissionDecision::Allow
449        );
450
451        // Should allow similar commands (prefix match)
452        assert_eq!(
453            checker
454                .check_permission("Bash", &json!({"command": "cargo build --debug"}))
455                .decision,
456            PermissionDecision::Allow
457        );
458
459        // Should NOT allow completely different commands
460        assert_eq!(
461            checker
462                .check_permission("Bash", &json!({"command": "rm -rf /"}))
463                .decision,
464            PermissionDecision::Ask
465        );
466    }
467
468    #[test]
469    fn test_add_allow_rule_for_file_operation() {
470        let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
471
472        // Add rule for specific file
473        checker.add_allow_rule_for_tool_call(
474            "Read",
475            &json!({"file_path": "/tmp/project/src/main.rs"}),
476        );
477
478        // Should allow files in the same directory
479        assert_eq!(
480            checker
481                .check_permission("Read", &json!({"file_path": "/tmp/project/src/lib.rs"}))
482                .decision,
483            PermissionDecision::Allow
484        );
485
486        // Should allow subdirectories (glob **)
487        assert_eq!(
488            checker
489                .check_permission(
490                    "Read",
491                    &json!({"file_path": "/tmp/project/src/utils/helper.rs"})
492                )
493                .decision,
494            PermissionDecision::Allow
495        );
496
497        // Should NOT allow different directories
498        assert_eq!(
499            checker
500                .check_permission("Read", &json!({"file_path": "/etc/passwd"}))
501                .decision,
502            PermissionDecision::Ask
503        );
504    }
505
506    #[test]
507    fn test_add_allow_rule_for_mcp_prefixed_tool() {
508        let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
509
510        // Add rule with MCP prefix
511        checker
512            .add_allow_rule_for_tool_call("mcp__acp__Bash", &json!({"command": "npm run build"}));
513
514        // Should work for both prefixed and non-prefixed tool names
515        assert_eq!(
516            checker
517                .check_permission("Bash", &json!({"command": "npm run test"}))
518                .decision,
519            PermissionDecision::Allow
520        );
521        assert_eq!(
522            checker
523                .check_permission("mcp__acp__Bash", &json!({"command": "npm run lint"}))
524                .decision,
525            PermissionDecision::Allow
526        );
527    }
528
529    #[test]
530    fn test_extract_command_prefix() {
531        // Should extract first two parts
532        assert_eq!(
533            PermissionChecker::extract_command_prefix("cargo build --release"),
534            "cargo build"
535        );
536        assert_eq!(
537            PermissionChecker::extract_command_prefix("npm run"),
538            "npm run"
539        );
540        assert_eq!(PermissionChecker::extract_command_prefix("ls"), "ls");
541        assert_eq!(PermissionChecker::extract_command_prefix(""), "");
542    }
543}