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(®ex_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}