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};
9use crate::command_safety::extract_command_basename;
10
11/// Permission checker that evaluates tool permissions against settings rules
12#[derive(Debug)]
13pub struct PermissionChecker {
14    /// Merged settings with permission rules
15    settings: Settings,
16    /// Working directory for path resolution
17    cwd: PathBuf,
18    /// Parsed and cached allow rules
19    allow_rules: Vec<(String, ParsedRule)>,
20    /// Parsed and cached deny rules
21    deny_rules: Vec<(String, ParsedRule)>,
22    /// Parsed and cached ask rules
23    ask_rules: Vec<(String, ParsedRule)>,
24}
25
26impl PermissionChecker {
27    /// Create a new permission checker
28    pub fn new(settings: Settings, cwd: impl AsRef<Path>) -> Self {
29        let cwd = cwd.as_ref().to_path_buf();
30
31        // Pre-parse rules for efficiency
32        let allow_rules = Self::parse_rules(
33            settings.permissions.as_ref().and_then(|p| p.allow.as_ref()),
34            &cwd,
35        );
36        let deny_rules = Self::parse_rules(
37            settings.permissions.as_ref().and_then(|p| p.deny.as_ref()),
38            &cwd,
39        );
40        let ask_rules = Self::parse_rules(
41            settings.permissions.as_ref().and_then(|p| p.ask.as_ref()),
42            &cwd,
43        );
44
45        Self {
46            settings,
47            cwd,
48            allow_rules,
49            deny_rules,
50            ask_rules,
51        }
52    }
53
54    /// Parse a list of rule strings into ParsedRule objects
55    fn parse_rules(rules: Option<&Vec<String>>, cwd: &Path) -> Vec<(String, ParsedRule)> {
56        rules
57            .map(|rules| {
58                rules
59                    .iter()
60                    .map(|rule| (rule.clone(), ParsedRule::parse_with_glob(rule, cwd)))
61                    .collect()
62            })
63            .unwrap_or_default()
64    }
65
66    /// Check permission for a tool invocation
67    ///
68    /// Priority: deny > allow > ask
69    ///
70    /// Returns the permission decision and matching rule (if any).
71    pub fn check_permission(
72        &self,
73        tool_name: &str,
74        tool_input: &serde_json::Value,
75    ) -> PermissionCheckResult {
76        // Check deny rules first (highest priority)
77        for (rule_str, parsed) in &self.deny_rules {
78            if parsed.matches(tool_name, tool_input, &self.cwd) {
79                tracing::debug!("Tool {} denied by rule: {}", tool_name, rule_str);
80                return PermissionCheckResult::deny(rule_str);
81            }
82        }
83
84        // Check allow rules
85        for (rule_str, parsed) in &self.allow_rules {
86            if parsed.matches(tool_name, tool_input, &self.cwd) {
87                tracing::debug!("Tool {} allowed by rule: {}", tool_name, rule_str);
88                return PermissionCheckResult::allow(rule_str);
89            }
90        }
91
92        // Check ask rules
93        for (rule_str, parsed) in &self.ask_rules {
94            if parsed.matches(tool_name, tool_input, &self.cwd) {
95                tracing::debug!(
96                    "Tool {} requires permission (ask rule): {}",
97                    tool_name,
98                    rule_str
99                );
100                return PermissionCheckResult::ask_with_rule(rule_str);
101            }
102        }
103
104        // Default: ask
105        tracing::debug!("Tool {} has no matching rule, defaulting to ask", tool_name);
106        PermissionCheckResult::ask()
107    }
108
109    /// Get the settings
110    pub fn settings(&self) -> &Settings {
111        &self.settings
112    }
113
114    /// Get the working directory
115    pub fn cwd(&self) -> &Path {
116        &self.cwd
117    }
118
119    /// Check if there are any permission rules configured
120    pub fn has_rules(&self) -> bool {
121        !self.allow_rules.is_empty() || !self.deny_rules.is_empty() || !self.ask_rules.is_empty()
122    }
123
124    /// Add a runtime allow rule (e.g., from user's "Always Allow" choice)
125    pub fn add_allow_rule(&mut self, rule: &str) {
126        let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
127        self.allow_rules.push((rule.to_string(), parsed));
128    }
129
130    /// Add a runtime allow rule for "Always Allow" permission decision
131    ///
132    /// For Bash tool: extracts the command name (first word) and generates
133    /// a rule like `Bash(find:*)` that allows all invocations of that command
134    /// regardless of arguments or paths.
135    ///
136    /// For file operations (Read/Edit/Write): generates a directory-based rule
137    /// that allows operations in the same directory tree.
138    ///
139    /// This provides reasonable granularity:
140    /// - `find /path1` → rule `Bash(find:*)` → allows all `find` commands
141    /// - `ls /path` → not matched → needs separate permission
142    pub fn add_allow_rule_for_tool_call(
143        &mut self,
144        tool_name: &str,
145        tool_input: &serde_json::Value,
146    ) {
147        // Strip mcp__acp__ prefix for consistent rule matching
148        let stripped = tool_name.strip_prefix("mcp__acp__").unwrap_or(tool_name);
149
150        let rule = match stripped {
151            "Bash" => {
152                // Extract command name (first word only) for Bash
153                if let Some(cmd) = tool_input.get("command").and_then(|v| v.as_str()) {
154                    let cmd_name = Self::extract_command_name(cmd);
155                    if cmd_name.is_empty() {
156                        stripped.to_string()
157                    } else {
158                        format!("Bash({}:*)", cmd_name) // e.g., "Bash(find:*)"
159                    }
160                } else {
161                    stripped.to_string()
162                }
163            }
164            "Read" | "Grep" | "Glob" | "LS" => {
165                // For read operations, generate directory-based rule
166                Self::generate_file_rule("Read", tool_input, &self.cwd)
167            }
168            "Edit" | "Write" => {
169                // For write operations, generate directory-based rule
170                Self::generate_file_rule(stripped, tool_input, &self.cwd)
171            }
172            _ => stripped.to_string(),
173        };
174
175        tracing::info!(
176            tool_name = %tool_name,
177            generated_rule = %rule,
178            "Adding allow rule for Always Allow"
179        );
180
181        let parsed = ParsedRule::parse_with_glob(&rule, &self.cwd);
182        self.allow_rules.push((rule, parsed));
183    }
184
185    /// Extract command name (basename only) from a shell command
186    ///
187    /// Supports both simple commands and full path commands:
188    /// - `find /path -name "*.rs"` → `find`
189    /// - `/usr/bin/find . -name "*.rs"` → `find`
190    /// - `ls -la /tmp` → `ls`
191    fn extract_command_name(cmd: &str) -> String {
192        extract_command_basename(cmd).to_string()
193    }
194
195    /// Generate a file-based permission rule
196    fn generate_file_rule(tool_name: &str, tool_input: &serde_json::Value, cwd: &Path) -> String {
197        if let Some(path) = tool_input.get("file_path").and_then(|v| v.as_str()) {
198            let path = Path::new(path);
199
200            // Try to get the directory
201            if let Some(dir) = path.parent() {
202                // Make path relative to cwd if possible
203                let dir_str = if let Ok(relative) = dir.strip_prefix(cwd) {
204                    format!("./{}", relative.display())
205                } else {
206                    dir.to_string_lossy().to_string()
207                };
208
209                // Generate glob rule for the directory
210                if dir_str.is_empty() || dir_str == "." {
211                    format!("{}(./*)", tool_name)
212                } else {
213                    format!("{}({}/**)", tool_name, dir_str)
214                }
215            } else {
216                tool_name.to_string()
217            }
218        } else {
219            tool_name.to_string()
220        }
221    }
222
223    /// Add a runtime deny rule
224    pub fn add_deny_rule(&mut self, rule: &str) {
225        let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
226        self.deny_rules.push((rule.to_string(), parsed));
227    }
228
229    /// Get the default permission mode from settings
230    pub fn default_mode(&self) -> Option<&str> {
231        self.settings
232            .permissions
233            .as_ref()
234            .and_then(|p| p.default_mode.as_deref())
235    }
236
237    /// Get additional directories from settings
238    pub fn additional_directories(&self) -> Option<&Vec<String>> {
239        self.settings
240            .permissions
241            .as_ref()
242            .and_then(|p| p.additional_directories.as_ref())
243    }
244}
245
246impl Default for PermissionChecker {
247    fn default() -> Self {
248        Self::new(Settings::default(), PathBuf::from("."))
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::settings::{PermissionDecision, PermissionSettings};
256    use serde_json::json;
257
258    fn settings_with_permissions(permissions: PermissionSettings) -> Settings {
259        Settings {
260            permissions: Some(permissions),
261            ..Default::default()
262        }
263    }
264
265    #[test]
266    fn test_empty_rules_default_to_ask() {
267        let checker = PermissionChecker::default();
268        let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
269
270        assert_eq!(result.decision, PermissionDecision::Ask);
271        assert!(result.rule.is_none());
272    }
273
274    #[test]
275    fn test_allow_rule() {
276        let permissions = PermissionSettings {
277            allow: Some(vec!["Read".to_string()]),
278            ..Default::default()
279        };
280        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
281
282        let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
283        assert_eq!(result.decision, PermissionDecision::Allow);
284        assert_eq!(result.rule, Some("Read".to_string()));
285    }
286
287    #[test]
288    fn test_deny_rule() {
289        let permissions = PermissionSettings {
290            deny: Some(vec!["Bash".to_string()]),
291            ..Default::default()
292        };
293        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
294
295        let result = checker.check_permission("Bash", &json!({"command": "rm -rf /"}));
296        assert_eq!(result.decision, PermissionDecision::Deny);
297    }
298
299    #[test]
300    fn test_deny_takes_priority_over_allow() {
301        let permissions = PermissionSettings {
302            allow: Some(vec!["Bash".to_string()]),
303            deny: Some(vec!["Bash".to_string()]),
304            ..Default::default()
305        };
306        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
307
308        let result = checker.check_permission("Bash", &json!({"command": "ls"}));
309        assert_eq!(result.decision, PermissionDecision::Deny);
310    }
311
312    #[test]
313    fn test_allow_takes_priority_over_ask() {
314        let permissions = PermissionSettings {
315            allow: Some(vec!["Read".to_string()]),
316            ask: Some(vec!["Read".to_string()]),
317            ..Default::default()
318        };
319        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
320
321        let result = checker.check_permission("Read", &json!({}));
322        assert_eq!(result.decision, PermissionDecision::Allow);
323    }
324
325    #[test]
326    fn test_bash_wildcard_rule() {
327        let permissions = PermissionSettings {
328            allow: Some(vec!["Bash(npm run:*)".to_string()]),
329            ..Default::default()
330        };
331        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
332
333        // Should allow npm run commands
334        assert_eq!(
335            checker
336                .check_permission("Bash", &json!({"command": "npm run build"}))
337                .decision,
338            PermissionDecision::Allow
339        );
340
341        // Should not allow npm install
342        assert_eq!(
343            checker
344                .check_permission("Bash", &json!({"command": "npm install"}))
345                .decision,
346            PermissionDecision::Ask
347        );
348
349        // Should block command chaining
350        assert_eq!(
351            checker
352                .check_permission("Bash", &json!({"command": "npm run build && rm -rf /"}))
353                .decision,
354            PermissionDecision::Ask
355        );
356    }
357
358    #[test]
359    fn test_read_group_matching() {
360        let permissions = PermissionSettings {
361            allow: Some(vec!["Read".to_string()]),
362            ..Default::default()
363        };
364        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
365
366        // Read rule should allow Read, Grep, Glob, LS
367        assert_eq!(
368            checker.check_permission("Read", &json!({})).decision,
369            PermissionDecision::Allow
370        );
371        assert_eq!(
372            checker.check_permission("Grep", &json!({})).decision,
373            PermissionDecision::Allow
374        );
375        assert_eq!(
376            checker.check_permission("Glob", &json!({})).decision,
377            PermissionDecision::Allow
378        );
379        assert_eq!(
380            checker.check_permission("LS", &json!({})).decision,
381            PermissionDecision::Allow
382        );
383
384        // Should not allow Write
385        assert_eq!(
386            checker.check_permission("Write", &json!({})).decision,
387            PermissionDecision::Ask
388        );
389    }
390
391    #[test]
392    fn test_add_runtime_rule() {
393        let mut checker = PermissionChecker::default();
394
395        // Initially should ask
396        assert_eq!(
397            checker.check_permission("Read", &json!({})).decision,
398            PermissionDecision::Ask
399        );
400
401        // Add allow rule at runtime
402        checker.add_allow_rule("Read");
403
404        // Now should allow
405        assert_eq!(
406            checker.check_permission("Read", &json!({})).decision,
407            PermissionDecision::Allow
408        );
409    }
410
411    #[test]
412    fn test_acp_prefix_stripped() {
413        let permissions = PermissionSettings {
414            allow: Some(vec!["Read".to_string()]),
415            ..Default::default()
416        };
417        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
418
419        // Should work with or without ACP prefix
420        assert_eq!(
421            checker.check_permission("Read", &json!({})).decision,
422            PermissionDecision::Allow
423        );
424        assert_eq!(
425            checker
426                .check_permission("mcp__acp__Read", &json!({}))
427                .decision,
428            PermissionDecision::Allow
429        );
430    }
431
432    #[test]
433    fn test_has_rules() {
434        let checker = PermissionChecker::default();
435        assert!(!checker.has_rules());
436
437        let permissions = PermissionSettings {
438            allow: Some(vec!["Read".to_string()]),
439            ..Default::default()
440        };
441        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
442        assert!(checker.has_rules());
443    }
444
445    #[test]
446    fn test_add_allow_rule_for_bash_command() {
447        let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
448
449        // Add rule for specific bash command (find)
450        checker.add_allow_rule_for_tool_call("Bash", &json!({"command": "find /path1 -name '*.rs'"}));
451
452        // Should allow ANY find command (same command name)
453        assert_eq!(
454            checker
455                .check_permission("Bash", &json!({"command": "find /different/path -type f"}))
456                .decision,
457            PermissionDecision::Allow
458        );
459
460        // Should allow find with different arguments
461        assert_eq!(
462            checker
463                .check_permission("Bash", &json!({"command": "find . -name '*.txt' -delete"}))
464                .decision,
465            PermissionDecision::Allow
466        );
467
468        // Should NOT allow different commands (ls, rm, etc.)
469        assert_eq!(
470            checker
471                .check_permission("Bash", &json!({"command": "ls -la /tmp"}))
472                .decision,
473            PermissionDecision::Ask
474        );
475
476        assert_eq!(
477            checker
478                .check_permission("Bash", &json!({"command": "rm -rf /"}))
479                .decision,
480            PermissionDecision::Ask
481        );
482    }
483
484    #[test]
485    fn test_add_allow_rule_for_file_operation() {
486        let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
487
488        // Add rule for specific file
489        checker.add_allow_rule_for_tool_call(
490            "Read",
491            &json!({"file_path": "/tmp/project/src/main.rs"}),
492        );
493
494        // Should allow files in the same directory
495        assert_eq!(
496            checker
497                .check_permission("Read", &json!({"file_path": "/tmp/project/src/lib.rs"}))
498                .decision,
499            PermissionDecision::Allow
500        );
501
502        // Should allow subdirectories (glob **)
503        assert_eq!(
504            checker
505                .check_permission(
506                    "Read",
507                    &json!({"file_path": "/tmp/project/src/utils/helper.rs"})
508                )
509                .decision,
510            PermissionDecision::Allow
511        );
512
513        // Should NOT allow different directories
514        assert_eq!(
515            checker
516                .check_permission("Read", &json!({"file_path": "/etc/passwd"}))
517                .decision,
518            PermissionDecision::Ask
519        );
520    }
521
522    #[test]
523    fn test_add_allow_rule_for_mcp_prefixed_tool() {
524        let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
525
526        // Add rule with MCP prefix
527        checker
528            .add_allow_rule_for_tool_call("mcp__acp__Bash", &json!({"command": "npm run build"}));
529
530        // Should work for both prefixed and non-prefixed tool names
531        assert_eq!(
532            checker
533                .check_permission("Bash", &json!({"command": "npm run test"}))
534                .decision,
535            PermissionDecision::Allow
536        );
537        assert_eq!(
538            checker
539                .check_permission("mcp__acp__Bash", &json!({"command": "npm run lint"}))
540                .decision,
541            PermissionDecision::Allow
542        );
543    }
544
545    #[test]
546    fn test_extract_command_name() {
547        // Should extract only the command name (basename)
548        assert_eq!(
549            PermissionChecker::extract_command_name("cargo build --release"),
550            "cargo"
551        );
552        assert_eq!(
553            PermissionChecker::extract_command_name("find /path -name '*.rs'"),
554            "find"
555        );
556        assert_eq!(
557            PermissionChecker::extract_command_name("ls -la /tmp"),
558            "ls"
559        );
560        assert_eq!(PermissionChecker::extract_command_name("npm"), "npm");
561        assert_eq!(PermissionChecker::extract_command_name(""), "");
562        // Full path commands should return just the basename
563        assert_eq!(
564            PermissionChecker::extract_command_name("/usr/bin/find . -name '*.rs'"),
565            "find"
566        );
567        assert_eq!(
568            PermissionChecker::extract_command_name("/usr/local/bin/cargo build"),
569            "cargo"
570        );
571    }
572}