claude_code_acp/settings/
rule.rs

1//! Permission rule parsing and matching
2//!
3//! Implements rule parsing for allow/deny/ask permission rules with glob pattern support.
4
5use std::path::Path;
6
7use globset::{Glob, GlobMatcher};
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10
11use crate::mcp::ExternalMcpManager;
12use crate::mcp::tools::bash::contains_shell_operator;
13
14/// Cached regex for parsing permission rules
15/// Pattern: ToolName or ToolName(argument)
16/// Compiled once and reused for better performance
17static RULE_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
18    // This regex is statically known and will always compile correctly
19    Regex::new(r"^(\w+)(?:\((.+)\))?$").expect("Invalid hardcoded regex pattern")
20});
21
22/// ACP tool name prefix
23const ACP_TOOL_PREFIX: &str = "mcp__acp__";
24
25/// Permission decision result
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PermissionDecision {
28    /// Tool execution is allowed
29    Allow,
30    /// Tool execution is denied
31    Deny,
32    /// User should be asked for permission
33    Ask,
34}
35
36/// Result of a permission check
37#[derive(Debug, Clone)]
38pub struct PermissionCheckResult {
39    /// The decision
40    pub decision: PermissionDecision,
41    /// The rule that matched (if any)
42    pub rule: Option<String>,
43    /// The source of the rule (allow, deny, ask)
44    pub source: Option<String>,
45}
46
47impl PermissionCheckResult {
48    /// Create a new allow result
49    pub fn allow(rule: impl Into<String>) -> Self {
50        Self {
51            decision: PermissionDecision::Allow,
52            rule: Some(rule.into()),
53            source: Some("allow".to_string()),
54        }
55    }
56
57    /// Create a new deny result
58    pub fn deny(rule: impl Into<String>) -> Self {
59        Self {
60            decision: PermissionDecision::Deny,
61            rule: Some(rule.into()),
62            source: Some("deny".to_string()),
63        }
64    }
65
66    /// Create a new ask result with a rule
67    pub fn ask_with_rule(rule: impl Into<String>) -> Self {
68        Self {
69            decision: PermissionDecision::Ask,
70            rule: Some(rule.into()),
71            source: Some("ask".to_string()),
72        }
73    }
74
75    /// Create a default ask result (no matching rule)
76    pub fn ask() -> Self {
77        Self {
78            decision: PermissionDecision::Ask,
79            rule: None,
80            source: None,
81        }
82    }
83}
84
85/// Permission settings from settings.json
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct PermissionSettings {
89    /// Rules that allow tool execution
90    #[serde(default)]
91    pub allow: Option<Vec<String>>,
92
93    /// Rules that deny tool execution
94    #[serde(default)]
95    pub deny: Option<Vec<String>>,
96
97    /// Rules that require asking the user
98    #[serde(default)]
99    pub ask: Option<Vec<String>>,
100
101    /// Additional directories that can be accessed
102    #[serde(default)]
103    pub additional_directories: Option<Vec<String>>,
104
105    /// Default permission mode
106    #[serde(default)]
107    pub default_mode: Option<String>,
108}
109
110/// A parsed permission rule
111#[derive(Debug, Clone)]
112pub struct ParsedRule {
113    /// The tool name (e.g., "Read", "Bash", "Edit")
114    pub tool_name: String,
115    /// The argument pattern (e.g., "./.env", "npm run:*")
116    pub argument: Option<String>,
117    /// Whether this is a wildcard rule (ends with :*)
118    pub is_wildcard: bool,
119    /// Compiled glob matcher for file paths
120    glob_matcher: Option<GlobMatcher>,
121}
122
123impl ParsedRule {
124    /// Parse a rule string like "Read", "Read(./.env)", "Bash(npm run:*)"
125    pub fn parse(rule: &str) -> Self {
126        // Use cached regex (compiled once at first use)
127        // The regex is statically known and guaranteed to compile correctly
128        if let Some(caps) = RULE_REGEX.captures(rule) {
129            let tool_name = caps.get(1).map_or("", |m| m.as_str()).to_string();
130            let argument = caps.get(2).map(|m| m.as_str().to_string());
131
132            let is_wildcard = argument
133                .as_ref()
134                .map(|a| a.ends_with(":*"))
135                .unwrap_or(false);
136
137            // Strip :* suffix if present
138            let argument = if is_wildcard {
139                argument.map(|a| a.trim_end_matches(":*").to_string())
140            } else {
141                argument
142            };
143
144            Self {
145                tool_name,
146                argument,
147                is_wildcard,
148                glob_matcher: None,
149            }
150        } else {
151            // Fallback: treat entire string as tool name
152            Self {
153                tool_name: rule.to_string(),
154                argument: None,
155                is_wildcard: false,
156                glob_matcher: None,
157            }
158        }
159    }
160
161    /// Parse with glob compilation for file path rules
162    pub fn parse_with_glob(rule: &str, cwd: &Path) -> Self {
163        let mut parsed = Self::parse(rule);
164
165        // Compile glob for file-related tools
166        if let Some(ref arg) = parsed.argument {
167            if is_file_tool(&parsed.tool_name) && !parsed.is_wildcard {
168                let normalized = normalize_path(arg, cwd);
169                if let Ok(glob) = Glob::new(&normalized) {
170                    parsed.glob_matcher = Some(glob.compile_matcher());
171                }
172            }
173        }
174
175        parsed
176    }
177
178    /// Check if this rule matches a tool invocation
179    pub fn matches(&self, tool_name: &str, tool_input: &serde_json::Value, cwd: &Path) -> bool {
180        // Strip ACP prefix if present
181        let stripped_name = tool_name.strip_prefix(ACP_TOOL_PREFIX).unwrap_or(tool_name);
182
183        // Check if tool name matches (considering tool groups and MCP tools)
184        if !self.matches_tool_name(stripped_name) {
185            return false;
186        }
187
188        // If no argument specified, match all invocations of this tool
189        let Some(ref pattern) = self.argument else {
190            return true;
191        };
192
193        // Get the relevant argument from tool input
194        let actual_arg = extract_tool_argument(stripped_name, tool_input);
195        let Some(actual_arg) = actual_arg else {
196            return false;
197        };
198
199        // Match based on tool type
200        if is_bash_tool(stripped_name) {
201            self.matches_bash_command(pattern, &actual_arg)
202        } else if is_file_tool(stripped_name) {
203            self.matches_file_path(pattern, &actual_arg, cwd)
204        } else {
205            // Exact match for other tools
206            pattern == &actual_arg
207        }
208    }
209
210    /// Check if tool name matches (considering tool groups and MCP tools)
211    fn matches_tool_name(&self, tool_name: &str) -> bool {
212        // Direct match
213        if self.tool_name == tool_name {
214            return true;
215        }
216
217        // Check if this is an external MCP tool and get its friendly name
218        // This allows rules like "deny: [WebFetch]" to match "mcp__web-fetch__webReader"
219        if let Some(friendly_name) = ExternalMcpManager::get_friendly_tool_name(tool_name) {
220            if self.tool_name == friendly_name {
221                return true;
222            }
223        }
224
225        // Tool group matching
226        match self.tool_name.as_str() {
227            // Read rule matches Read, Grep, Glob, LS
228            "Read" => matches!(tool_name, "Read" | "Grep" | "Glob" | "LS"),
229            // Edit rule matches Edit, Write
230            "Edit" => matches!(tool_name, "Edit" | "Write"),
231            // Task rule matches Task, TaskOutput
232            "Task" => matches!(tool_name, "Task" | "TaskOutput"),
233            // Web rule matches WebSearch, WebFetch
234            "Web" => matches!(tool_name, "WebSearch" | "WebFetch"),
235            _ => false,
236        }
237    }
238
239    /// Match bash command with prefix/exact matching
240    fn matches_bash_command(&self, pattern: &str, command: &str) -> bool {
241        if self.is_wildcard {
242            // Prefix match with wildcard
243            if let Some(remainder) = command.strip_prefix(pattern) {
244                // Check remainder for shell operators (security)
245                if contains_shell_operator(remainder) {
246                    return false;
247                }
248                return true;
249            }
250            false
251        } else {
252            // Exact match
253            pattern == command
254        }
255    }
256
257    /// Match file path with glob pattern
258    fn matches_file_path(&self, pattern: &str, file_path: &str, cwd: &Path) -> bool {
259        // Use pre-compiled glob if available
260        if let Some(ref matcher) = self.glob_matcher {
261            let normalized_path = normalize_path(file_path, cwd);
262            return matcher.is_match(&normalized_path);
263        }
264
265        // Fallback: compile glob on demand
266        let normalized_pattern = normalize_path(pattern, cwd);
267        let normalized_path = normalize_path(file_path, cwd);
268
269        if let Ok(glob) = Glob::new(&normalized_pattern) {
270            let matcher = glob.compile_matcher();
271            return matcher.is_match(&normalized_path);
272        }
273
274        // Last resort: exact match
275        normalized_pattern == normalized_path
276    }
277}
278
279/// Normalize a file path, expanding ~ and resolving relative paths
280fn normalize_path(path: &str, cwd: &Path) -> String {
281    let path = if let Some(rest) = path.strip_prefix("~/") {
282        if let Some(home) = dirs::home_dir() {
283            home.join(rest).to_string_lossy().to_string()
284        } else {
285            path.to_string()
286        }
287    } else if let Some(rest) = path.strip_prefix("./") {
288        cwd.join(rest).to_string_lossy().to_string()
289    } else if !Path::new(path).is_absolute() {
290        cwd.join(path).to_string_lossy().to_string()
291    } else {
292        path.to_string()
293    };
294
295    // Normalize path separators and resolve ..
296    Path::new(&path)
297        .canonicalize()
298        .map(|p| p.to_string_lossy().to_string())
299        .unwrap_or(path)
300}
301
302/// Check if tool is bash-like (command execution)
303fn is_bash_tool(tool_name: &str) -> bool {
304    matches!(tool_name, "Bash" | "BashOutput" | "KillShell")
305}
306
307/// Check if tool operates on files
308fn is_file_tool(tool_name: &str) -> bool {
309    matches!(
310        tool_name,
311        "Read" | "Write" | "Edit" | "Grep" | "Glob" | "LS" | "NotebookRead" | "NotebookEdit"
312    )
313}
314
315/// Extract the relevant argument from tool input for permission matching
316fn extract_tool_argument(tool_name: &str, input: &serde_json::Value) -> Option<String> {
317    match tool_name {
318        // Bash tools use "command"
319        "Bash" | "BashOutput" | "KillShell" => input
320            .get("command")
321            .and_then(|v| v.as_str())
322            .map(String::from),
323        // File tools use "file_path" or "path"
324        "Read" | "Write" | "Edit" | "NotebookRead" | "NotebookEdit" => input
325            .get("file_path")
326            .or_else(|| input.get("path"))
327            .and_then(|v| v.as_str())
328            .map(String::from),
329        // Search tools use "path" or "pattern"
330        "Grep" | "Glob" | "LS" => input
331            .get("path")
332            .or_else(|| input.get("pattern"))
333            .and_then(|v| v.as_str())
334            .map(String::from),
335        // Task tool: extract subagent_type for permission control
336        "Task" => input
337            .get("subagent_type")
338            .or_else(|| input.get("description"))
339            .and_then(|v| v.as_str())
340            .map(String::from),
341        // TaskOutput tool: extract task_id for permission control
342        "TaskOutput" => input
343            .get("task_id")
344            .and_then(|v| v.as_str())
345            .map(String::from),
346        // TodoWrite tool: extract todos count for permission control
347        "TodoWrite" => input
348            .get("todos")
349            .and_then(|v| v.as_array())
350            .map(|arr| arr.len().to_string())
351            .or_else(|| Some("0".to_string())),
352        // SlashCommand tool: extract command for permission control
353        "SlashCommand" => input
354            .get("command")
355            .and_then(|v| v.as_str())
356            .map(String::from),
357        // Skill tool: extract skill for permission control
358        "Skill" => input
359            .get("skill")
360            .and_then(|v| v.as_str())
361            .map(String::from),
362        _ => None,
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::settings::{manager::Settings, permission_checker::PermissionChecker};
370    use serde_json::json;
371    use std::path::PathBuf;
372
373    fn settings_with_permissions(permissions: PermissionSettings) -> Settings {
374        Settings {
375            permissions: Some(permissions),
376            ..Default::default()
377        }
378    }
379
380    #[test]
381    fn test_parse_simple_rule() {
382        let rule = ParsedRule::parse("Read");
383        assert_eq!(rule.tool_name, "Read");
384        assert!(rule.argument.is_none());
385        assert!(!rule.is_wildcard);
386    }
387
388    #[test]
389    fn test_parse_rule_with_argument() {
390        let rule = ParsedRule::parse("Read(./.env)");
391        assert_eq!(rule.tool_name, "Read");
392        assert_eq!(rule.argument, Some("./.env".to_string()));
393        assert!(!rule.is_wildcard);
394    }
395
396    #[test]
397    fn test_parse_rule_with_wildcard() {
398        let rule = ParsedRule::parse("Bash(npm run:*)");
399        assert_eq!(rule.tool_name, "Bash");
400        assert_eq!(rule.argument, Some("npm run".to_string()));
401        assert!(rule.is_wildcard);
402    }
403
404    #[test]
405    fn test_parse_glob_pattern() {
406        let rule = ParsedRule::parse("Read(./secrets/**)");
407        assert_eq!(rule.tool_name, "Read");
408        assert_eq!(rule.argument, Some("./secrets/**".to_string()));
409        assert!(!rule.is_wildcard);
410    }
411
412    #[test]
413    fn test_matches_simple_tool() {
414        let rule = ParsedRule::parse("Read");
415        let cwd = PathBuf::from("/tmp");
416
417        assert!(rule.matches("Read", &json!({}), &cwd));
418        assert!(rule.matches("mcp__acp__Read", &json!({}), &cwd));
419        assert!(!rule.matches("Write", &json!({}), &cwd));
420    }
421
422    #[test]
423    fn test_matches_tool_group_read() {
424        let rule = ParsedRule::parse("Read");
425        let cwd = PathBuf::from("/tmp");
426
427        // Read rule should match Read, Grep, Glob, LS
428        assert!(rule.matches("Read", &json!({}), &cwd));
429        assert!(rule.matches("Grep", &json!({}), &cwd));
430        assert!(rule.matches("Glob", &json!({}), &cwd));
431        assert!(rule.matches("LS", &json!({}), &cwd));
432        assert!(!rule.matches("Write", &json!({}), &cwd));
433    }
434
435    #[test]
436    fn test_matches_tool_group_edit() {
437        let rule = ParsedRule::parse("Edit");
438        let cwd = PathBuf::from("/tmp");
439
440        // Edit rule should match Edit, Write
441        assert!(rule.matches("Edit", &json!({}), &cwd));
442        assert!(rule.matches("Write", &json!({}), &cwd));
443        assert!(!rule.matches("Read", &json!({}), &cwd));
444    }
445
446    #[test]
447    fn test_matches_bash_exact() {
448        let rule = ParsedRule::parse("Bash(npm run lint)");
449        let cwd = PathBuf::from("/tmp");
450
451        assert!(rule.matches("Bash", &json!({"command": "npm run lint"}), &cwd));
452        assert!(!rule.matches("Bash", &json!({"command": "npm run build"}), &cwd));
453        assert!(!rule.matches("Bash", &json!({"command": "npm run lint --fix"}), &cwd));
454    }
455
456    #[test]
457    fn test_matches_bash_wildcard() {
458        let rule = ParsedRule::parse("Bash(npm run:*)");
459        let cwd = PathBuf::from("/tmp");
460
461        assert!(rule.matches("Bash", &json!({"command": "npm run"}), &cwd));
462        assert!(rule.matches("Bash", &json!({"command": "npm run build"}), &cwd));
463        assert!(rule.matches("Bash", &json!({"command": "npm run lint --fix"}), &cwd));
464        assert!(!rule.matches("Bash", &json!({"command": "npm install"}), &cwd));
465    }
466
467    #[test]
468    fn test_matches_bash_wildcard_blocks_shell_operators() {
469        let rule = ParsedRule::parse("Bash(npm run:*)");
470        let cwd = PathBuf::from("/tmp");
471
472        // Should block commands with shell operators after prefix
473        assert!(!rule.matches(
474            "Bash",
475            &json!({"command": "npm run build && rm -rf /"}),
476            &cwd
477        ));
478        assert!(!rule.matches("Bash", &json!({"command": "npm run build | cat"}), &cwd));
479        assert!(!rule.matches(
480            "Bash",
481            &json!({"command": "npm run build; malicious"}),
482            &cwd
483        ));
484    }
485
486    #[test]
487    fn test_permission_check_result() {
488        let allow = PermissionCheckResult::allow("Read");
489        assert_eq!(allow.decision, PermissionDecision::Allow);
490        assert_eq!(allow.rule, Some("Read".to_string()));
491        assert_eq!(allow.source, Some("allow".to_string()));
492
493        let deny = PermissionCheckResult::deny("Bash");
494        assert_eq!(deny.decision, PermissionDecision::Deny);
495
496        let ask = PermissionCheckResult::ask();
497        assert_eq!(ask.decision, PermissionDecision::Ask);
498        assert!(ask.rule.is_none());
499    }
500
501    #[test]
502    fn test_mcp_tool_web_fetch_matching() {
503        // Test that "WebFetch" rule matches "mcp__web-fetch__webReader"
504        let rule = ParsedRule::parse("WebFetch");
505        let cwd = PathBuf::from("/tmp");
506
507        assert!(rule.matches("mcp__web-fetch__webReader", &json!({}), &cwd));
508        assert!(rule.matches("mcp__web-reader__webReader", &json!({}), &cwd));
509    }
510
511    #[test]
512    fn test_mcp_tool_web_search_matching() {
513        // Test that "WebSearch" rule matches "mcp__web-search-prime__webSearchPrime"
514        let rule = ParsedRule::parse("WebSearch");
515        let cwd = PathBuf::from("/tmp");
516
517        assert!(rule.matches("mcp__web-search-prime__webSearchPrime", &json!({}), &cwd));
518    }
519
520    #[test]
521    fn test_mcp_tool_does_not_match_unrelated_tools() {
522        // Test that "WebFetch" rule does NOT match Read or other built-in tools
523        let rule = ParsedRule::parse("WebFetch");
524        let cwd = PathBuf::from("/tmp");
525
526        assert!(!rule.matches("Read", &json!({}), &cwd));
527        assert!(!rule.matches("Bash", &json!({}), &cwd));
528        assert!(!rule.matches("Write", &json!({}), &cwd));
529    }
530
531    #[test]
532    fn test_deny_web_fetch_blocks_mcp_tool() {
533        // Test that deny: ["WebFetch"] blocks the MCP tool
534        let permissions = PermissionSettings {
535            deny: Some(vec!["WebFetch".to_string()]),
536            ..Default::default()
537        };
538        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
539
540        let result = checker.check_permission("mcp__web-fetch__webReader", &json!({}));
541        assert_eq!(result.decision, PermissionDecision::Deny);
542        assert_eq!(result.rule, Some("WebFetch".to_string()));
543    }
544
545    #[test]
546    fn test_deny_web_search_blocks_mcp_tool() {
547        // Test that deny: ["WebSearch"] blocks the MCP tool
548        let permissions = PermissionSettings {
549            deny: Some(vec!["WebSearch".to_string()]),
550            ..Default::default()
551        };
552        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
553
554        let result = checker.check_permission("mcp__web-search-prime__webSearchPrime", &json!({}));
555        assert_eq!(result.decision, PermissionDecision::Deny);
556        assert_eq!(result.rule, Some("WebSearch".to_string()));
557    }
558
559    #[test]
560    fn test_allow_web_fetch_allows_mcp_tool() {
561        // Test that allow: ["WebFetch"] allows the MCP tool
562        let permissions = PermissionSettings {
563            allow: Some(vec!["WebFetch".to_string()]),
564            ..Default::default()
565        };
566        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
567
568        let result = checker.check_permission("mcp__web-fetch__webReader", &json!({}));
569        assert_eq!(result.decision, PermissionDecision::Allow);
570        assert_eq!(result.rule, Some("WebFetch".to_string()));
571    }
572
573    #[test]
574    fn test_deny_web_fetch_blocks_builtin_tool() {
575        // Test that deny: ["WebFetch"] also blocks the built-in WebFetch tool
576        let permissions = PermissionSettings {
577            deny: Some(vec!["WebFetch".to_string()]),
578            ..Default::default()
579        };
580        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
581
582        // Built-in tools use the "mcp__acp__" prefix
583        let result = checker.check_permission("mcp__acp__WebFetch", &json!({}));
584        assert_eq!(result.decision, PermissionDecision::Deny);
585        assert_eq!(result.rule, Some("WebFetch".to_string()));
586
587        // Also works without prefix (direct match)
588        let result = checker.check_permission("WebFetch", &json!({}));
589        assert_eq!(result.decision, PermissionDecision::Deny);
590    }
591
592    #[test]
593    fn test_deny_web_search_blocks_builtin_tool() {
594        // Test that deny: ["WebSearch"] also blocks the built-in WebSearch tool
595        let permissions = PermissionSettings {
596            deny: Some(vec!["WebSearch".to_string()]),
597            ..Default::default()
598        };
599        let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
600
601        // Built-in tools use the "mcp__acp__" prefix
602        let result = checker.check_permission("mcp__acp__WebSearch", &json!({}));
603        assert_eq!(result.decision, PermissionDecision::Deny);
604        assert_eq!(result.rule, Some("WebSearch".to_string()));
605
606        // Also works without prefix (direct match)
607        let result = checker.check_permission("WebSearch", &json!({}));
608        assert_eq!(result.decision, PermissionDecision::Deny);
609    }
610}