Skip to main content

a3s_code_core/permissions/
rule.rs

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