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