Skip to main content

a3s_code_core/permissions/
rule.rs

1use serde::{Deserialize, Serialize};
2
3/// A permission rule with pattern matching support
4///
5/// Format: `ToolName(pattern)` or `ToolName` (matches all)
6///
7/// Examples:
8/// - `Bash(cargo:*)` - matches all cargo commands
9/// - `Bash(npm run test:*)` - matches npm run test with any args
10/// - `Read(src/**/*.rs)` - matches Rust files in src/
11/// - `Grep(*)` - matches all grep invocations
12/// - `mcp__pencil` - matches all pencil MCP tools
13///
14/// Deserialization supports both plain strings and `{rule: "..."}` objects:
15/// ```yaml
16/// allow:
17///   - read                   # plain string
18///   - rule: "Bash(cargo:*)"  # struct form
19/// ```
20#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
21pub struct PermissionRule {
22    /// The original rule string
23    pub rule: String,
24    /// Parsed tool name
25    #[serde(skip)]
26    pub(crate) tool_name: Option<String>,
27    /// Parsed argument pattern (None means match all)
28    #[serde(skip)]
29    pub(crate) arg_pattern: Option<String>,
30}
31
32impl<'de> Deserialize<'de> for PermissionRule {
33    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
34    where
35        D: serde::Deserializer<'de>,
36    {
37        /// Helper enum to accept both `"read"` and `{rule: "read"}` in YAML/JSON.
38        #[derive(Deserialize)]
39        #[serde(untagged)]
40        enum RuleRepr {
41            Plain(String),
42            Struct { rule: String },
43        }
44
45        let rule_str = match RuleRepr::deserialize(deserializer)? {
46            RuleRepr::Plain(s) => s,
47            RuleRepr::Struct { rule } => rule,
48        };
49        // `new()` calls `parse_rule()` to populate tool_name and arg_pattern.
50        Ok(PermissionRule::new(&rule_str))
51    }
52}
53
54impl PermissionRule {
55    /// Create a new permission rule from a pattern string
56    pub fn new(rule: &str) -> Self {
57        let (tool_name, arg_pattern) = Self::parse_rule(rule);
58        Self {
59            rule: rule.to_string(),
60            tool_name,
61            arg_pattern,
62        }
63    }
64
65    /// Parse rule string into tool name and argument pattern
66    fn parse_rule(rule: &str) -> (Option<String>, Option<String>) {
67        // Handle format: ToolName(pattern) or ToolName
68        if let Some(paren_start) = rule.find('(') {
69            if rule.ends_with(')') {
70                let tool_name = rule[..paren_start].to_string();
71                let pattern = rule[paren_start + 1..rule.len() - 1].to_string();
72                return (Some(tool_name), Some(pattern));
73            }
74        }
75        // No parentheses - tool name only, matches all args
76        (Some(rule.to_string()), None)
77    }
78
79    /// Check if this rule matches a tool invocation
80    pub fn matches(&self, tool_name: &str, args: &serde_json::Value) -> bool {
81        if !self.matches_tool(tool_name) {
82            return false;
83        }
84
85        // If no argument pattern, match all
86        let pattern = match &self.arg_pattern {
87            Some(p) => p,
88            None => return true,
89        };
90
91        // Match against argument pattern
92        self.matches_args(pattern, tool_name, args)
93    }
94
95    /// Whether this rule can match a tool, independent of invocation args.
96    pub(super) fn matches_tool(&self, tool_name: &str) -> bool {
97        self.tool_name
98            .as_deref()
99            .is_some_and(|rule_tool| self.matches_tool_name(rule_tool, tool_name))
100    }
101
102    /// Whether a matching tool name is denied for every possible argument.
103    pub(super) fn matches_all_args(&self) -> bool {
104        self.arg_pattern
105            .as_deref()
106            .is_none_or(|pattern| pattern == "*")
107    }
108
109    /// Check if tool names match (case-insensitive, wildcard-aware)
110    fn matches_tool_name(&self, rule_tool: &str, actual_tool: &str) -> bool {
111        // If the rule contains wildcards, use glob matching on the tool name directly.
112        // e.g. "mcp__longvt__*" must use glob, not starts_with, because starts_with
113        // treats '*' as a literal character and will never match.
114        if rule_tool.contains('*') || rule_tool.contains('?') {
115            return self.glob_match(rule_tool, actual_tool);
116        }
117
118        // Handle MCP tools: mcp__server matches mcp__server__tool
119        if rule_tool.starts_with("mcp__") && actual_tool.starts_with("mcp__") {
120            // mcp__pencil matches mcp__pencil__batch_design
121            if actual_tool.starts_with(rule_tool) {
122                return true;
123            }
124        }
125        rule_tool.eq_ignore_ascii_case(actual_tool)
126    }
127
128    /// Match argument pattern against tool arguments
129    fn matches_args(&self, pattern: &str, tool_name: &str, args: &serde_json::Value) -> bool {
130        // Handle wildcard pattern "*" - matches everything
131        if pattern == "*" {
132            return true;
133        }
134
135        // Build argument string based on tool type
136        let arg_string = self.build_arg_string(tool_name, args);
137
138        // Perform glob-style matching
139        self.glob_match(pattern, &arg_string)
140    }
141
142    /// Build a string representation of arguments for matching
143    fn build_arg_string(&self, tool_name: &str, args: &serde_json::Value) -> String {
144        match tool_name.to_lowercase().as_str() {
145            "bash" => {
146                // For Bash, use the command field
147                args.get("command")
148                    .and_then(|v| v.as_str())
149                    .unwrap_or("")
150                    .to_string()
151            }
152            "read" | "write" | "edit" => {
153                // For file operations, use the file_path field
154                args.get("file_path")
155                    .and_then(|v| v.as_str())
156                    .unwrap_or("")
157                    .to_string()
158            }
159            "glob" => {
160                // For glob, use the pattern field
161                args.get("pattern")
162                    .and_then(|v| v.as_str())
163                    .unwrap_or("")
164                    .to_string()
165            }
166            "grep" => {
167                // For grep, combine pattern and path
168                let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
169                let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
170                format!("{} {}", pattern, path)
171            }
172            "ls" => {
173                // For ls, use the path field
174                args.get("path")
175                    .and_then(|v| v.as_str())
176                    .unwrap_or("")
177                    .to_string()
178            }
179            _ => {
180                // For other tools, serialize the entire args
181                serde_json::to_string(args).unwrap_or_default()
182            }
183        }
184    }
185
186    /// Perform glob-style pattern matching
187    ///
188    /// Supports:
189    /// - `*` matches any sequence of characters (except /)
190    /// - `**` matches any sequence including /
191    /// - `:*` at the end matches any suffix (including empty)
192    fn glob_match(&self, pattern: &str, text: &str) -> bool {
193        // Handle special `:*` suffix (matches any args after the prefix)
194        if let Some(prefix) = pattern.strip_suffix(":*") {
195            return text.starts_with(prefix);
196        }
197
198        // Normalize Windows backslashes to forward slashes for consistent matching
199        let text = text.replace('\\', "/");
200
201        // Convert glob pattern to regex pattern
202        let regex_pattern = Self::glob_to_regex(pattern);
203        if let Ok(re) = regex::Regex::new(&regex_pattern) {
204            re.is_match(&text)
205        } else {
206            // Fallback to simple prefix match if regex fails
207            text.starts_with(pattern)
208        }
209    }
210
211    /// Convert glob pattern to regex pattern
212    fn glob_to_regex(pattern: &str) -> String {
213        let mut regex = String::from("^");
214        let chars: Vec<char> = pattern.chars().collect();
215        let mut i = 0;
216
217        while i < chars.len() {
218            let c = chars[i];
219            match c {
220                '*' => {
221                    // Check for ** (matches anything including /)
222                    if i + 1 < chars.len() && chars[i + 1] == '*' {
223                        // ** matches any path including /
224                        // Skip optional following /
225                        if i + 2 < chars.len() && chars[i + 2] == '/' {
226                            regex.push_str(".*");
227                            i += 3;
228                        } else {
229                            regex.push_str(".*");
230                            i += 2;
231                        }
232                    } else {
233                        // * matches anything except path separators
234                        regex.push_str("[^/\\\\]*");
235                        i += 1;
236                    }
237                }
238                '?' => {
239                    // ? matches any single character except path separators
240                    regex.push_str("[^/\\\\]");
241                    i += 1;
242                }
243                '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
244                    // Escape regex special characters
245                    regex.push('\\');
246                    regex.push(c);
247                    i += 1;
248                }
249                _ => {
250                    regex.push(c);
251                    i += 1;
252                }
253            }
254        }
255
256        regex.push('$');
257        regex
258    }
259}