1use 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
14static RULE_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
18 Regex::new(r"^(\w+)(?:\((.+)\))?$").expect("Invalid hardcoded regex pattern")
20});
21
22const ACP_TOOL_PREFIX: &str = "mcp__acp__";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PermissionDecision {
28 Allow,
30 Deny,
32 Ask,
34}
35
36#[derive(Debug, Clone)]
38pub struct PermissionCheckResult {
39 pub decision: PermissionDecision,
41 pub rule: Option<String>,
43 pub source: Option<String>,
45}
46
47impl PermissionCheckResult {
48 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 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 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 pub fn ask() -> Self {
77 Self {
78 decision: PermissionDecision::Ask,
79 rule: None,
80 source: None,
81 }
82 }
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct PermissionSettings {
89 #[serde(default)]
91 pub allow: Option<Vec<String>>,
92
93 #[serde(default)]
95 pub deny: Option<Vec<String>>,
96
97 #[serde(default)]
99 pub ask: Option<Vec<String>>,
100
101 #[serde(default)]
103 pub additional_directories: Option<Vec<String>>,
104
105 #[serde(default)]
107 pub default_mode: Option<String>,
108}
109
110#[derive(Debug, Clone)]
112pub struct ParsedRule {
113 pub tool_name: String,
115 pub argument: Option<String>,
117 pub is_wildcard: bool,
119 glob_matcher: Option<GlobMatcher>,
121}
122
123impl ParsedRule {
124 pub fn parse(rule: &str) -> Self {
126 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 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 Self {
153 tool_name: rule.to_string(),
154 argument: None,
155 is_wildcard: false,
156 glob_matcher: None,
157 }
158 }
159 }
160
161 pub fn parse_with_glob(rule: &str, cwd: &Path) -> Self {
163 let mut parsed = Self::parse(rule);
164
165 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 pub fn matches(&self, tool_name: &str, tool_input: &serde_json::Value, cwd: &Path) -> bool {
180 let stripped_name = tool_name.strip_prefix(ACP_TOOL_PREFIX).unwrap_or(tool_name);
182
183 if !self.matches_tool_name(stripped_name) {
185 return false;
186 }
187
188 let Some(ref pattern) = self.argument else {
190 return true;
191 };
192
193 let actual_arg = extract_tool_argument(stripped_name, tool_input);
195 let Some(actual_arg) = actual_arg else {
196 return false;
197 };
198
199 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 pattern == &actual_arg
207 }
208 }
209
210 fn matches_tool_name(&self, tool_name: &str) -> bool {
212 if self.tool_name == tool_name {
214 return true;
215 }
216
217 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 match self.tool_name.as_str() {
227 "Read" => matches!(tool_name, "Read" | "Grep" | "Glob" | "LS"),
229 "Edit" => matches!(tool_name, "Edit" | "Write"),
231 "Task" => matches!(tool_name, "Task" | "TaskOutput"),
233 "Web" => matches!(tool_name, "WebSearch" | "WebFetch"),
235 _ => false,
236 }
237 }
238
239 fn matches_bash_command(&self, pattern: &str, command: &str) -> bool {
241 if self.is_wildcard {
242 if let Some(remainder) = command.strip_prefix(pattern) {
244 if contains_shell_operator(remainder) {
246 return false;
247 }
248 return true;
249 }
250 false
251 } else {
252 pattern == command
254 }
255 }
256
257 fn matches_file_path(&self, pattern: &str, file_path: &str, cwd: &Path) -> bool {
259 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 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 normalized_pattern == normalized_path
276 }
277}
278
279fn 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 Path::new(&path)
297 .canonicalize()
298 .map(|p| p.to_string_lossy().to_string())
299 .unwrap_or(path)
300}
301
302fn is_bash_tool(tool_name: &str) -> bool {
304 matches!(tool_name, "Bash" | "BashOutput" | "KillShell")
305}
306
307fn is_file_tool(tool_name: &str) -> bool {
309 matches!(
310 tool_name,
311 "Read" | "Write" | "Edit" | "Grep" | "Glob" | "LS" | "NotebookRead" | "NotebookEdit"
312 )
313}
314
315fn extract_tool_argument(tool_name: &str, input: &serde_json::Value) -> Option<String> {
317 match tool_name {
318 "Bash" | "BashOutput" | "KillShell" => input
320 .get("command")
321 .and_then(|v| v.as_str())
322 .map(String::from),
323 "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 "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" => input
337 .get("subagent_type")
338 .or_else(|| input.get("description"))
339 .and_then(|v| v.as_str())
340 .map(String::from),
341 "TaskOutput" => input
343 .get("task_id")
344 .and_then(|v| v.as_str())
345 .map(String::from),
346 "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" => input
354 .get("command")
355 .and_then(|v| v.as_str())
356 .map(String::from),
357 "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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let result = checker.check_permission("WebSearch", &json!({}));
608 assert_eq!(result.decision, PermissionDecision::Deny);
609 }
610}