use std::path::Path;
use globset::{Glob, GlobMatcher};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::mcp::ExternalMcpManager;
use crate::mcp::tools::bash::contains_shell_operator;
static RULE_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"^(\w+)(?:\((.+)\))?$").expect("Invalid hardcoded regex pattern")
});
const ACP_TOOL_PREFIX: &str = "mcp__acp__";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionDecision {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone)]
pub struct PermissionCheckResult {
pub decision: PermissionDecision,
pub rule: Option<String>,
pub source: Option<String>,
}
impl PermissionCheckResult {
pub fn allow(rule: impl Into<String>) -> Self {
Self {
decision: PermissionDecision::Allow,
rule: Some(rule.into()),
source: Some("allow".to_string()),
}
}
pub fn deny(rule: impl Into<String>) -> Self {
Self {
decision: PermissionDecision::Deny,
rule: Some(rule.into()),
source: Some("deny".to_string()),
}
}
pub fn ask_with_rule(rule: impl Into<String>) -> Self {
Self {
decision: PermissionDecision::Ask,
rule: Some(rule.into()),
source: Some("ask".to_string()),
}
}
pub fn ask() -> Self {
Self {
decision: PermissionDecision::Ask,
rule: None,
source: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionSettings {
#[serde(default)]
pub allow: Option<Vec<String>>,
#[serde(default)]
pub deny: Option<Vec<String>>,
#[serde(default)]
pub ask: Option<Vec<String>>,
#[serde(default)]
pub additional_directories: Option<Vec<String>>,
#[serde(default)]
pub default_mode: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedRule {
pub tool_name: String,
pub argument: Option<String>,
pub is_wildcard: bool,
glob_matcher: Option<GlobMatcher>,
}
impl ParsedRule {
pub fn parse(rule: &str) -> Self {
if let Some(caps) = RULE_REGEX.captures(rule) {
let tool_name = caps.get(1).map_or("", |m| m.as_str()).to_string();
let argument = caps.get(2).map(|m| m.as_str().to_string());
let is_wildcard = argument
.as_ref()
.map(|a| a.ends_with(":*"))
.unwrap_or(false);
let argument = if is_wildcard {
argument.map(|a| a.trim_end_matches(":*").to_string())
} else {
argument
};
Self {
tool_name,
argument,
is_wildcard,
glob_matcher: None,
}
} else {
Self {
tool_name: rule.to_string(),
argument: None,
is_wildcard: false,
glob_matcher: None,
}
}
}
pub fn parse_with_glob(rule: &str, cwd: &Path) -> Self {
let mut parsed = Self::parse(rule);
if let Some(ref arg) = parsed.argument {
if is_file_tool(&parsed.tool_name) && !parsed.is_wildcard {
let normalized = normalize_path(arg, cwd);
if let Ok(glob) = Glob::new(&normalized) {
parsed.glob_matcher = Some(glob.compile_matcher());
}
}
}
parsed
}
pub fn matches(&self, tool_name: &str, tool_input: &serde_json::Value, cwd: &Path) -> bool {
let stripped_name = tool_name.strip_prefix(ACP_TOOL_PREFIX).unwrap_or(tool_name);
if !self.matches_tool_name(stripped_name) {
return false;
}
let Some(ref pattern) = self.argument else {
return true;
};
let actual_arg = extract_tool_argument(stripped_name, tool_input);
let Some(actual_arg) = actual_arg else {
return false;
};
if is_bash_tool(stripped_name) {
self.matches_bash_command(pattern, &actual_arg)
} else if is_file_tool(stripped_name) {
self.matches_file_path(pattern, &actual_arg, cwd)
} else {
pattern == &actual_arg
}
}
fn matches_tool_name(&self, tool_name: &str) -> bool {
if self.tool_name == tool_name {
return true;
}
if let Some(friendly_name) = ExternalMcpManager::get_friendly_tool_name(tool_name) {
if self.tool_name == friendly_name {
return true;
}
}
match self.tool_name.as_str() {
"Read" => matches!(tool_name, "Read" | "Grep" | "Glob" | "LS"),
"Edit" => matches!(tool_name, "Edit" | "Write"),
"Task" => matches!(tool_name, "Task" | "TaskOutput"),
"Web" => matches!(tool_name, "WebSearch" | "WebFetch"),
_ => false,
}
}
fn matches_bash_command(&self, pattern: &str, command: &str) -> bool {
if self.is_wildcard {
if let Some(remainder) = command.strip_prefix(pattern) {
if contains_shell_operator(remainder) {
return false;
}
return true;
}
false
} else {
pattern == command
}
}
fn matches_file_path(&self, pattern: &str, file_path: &str, cwd: &Path) -> bool {
if let Some(ref matcher) = self.glob_matcher {
let normalized_path = normalize_path(file_path, cwd);
return matcher.is_match(&normalized_path);
}
let normalized_pattern = normalize_path(pattern, cwd);
let normalized_path = normalize_path(file_path, cwd);
if let Ok(glob) = Glob::new(&normalized_pattern) {
let matcher = glob.compile_matcher();
return matcher.is_match(&normalized_path);
}
normalized_pattern == normalized_path
}
}
fn normalize_path(path: &str, cwd: &Path) -> String {
let path = if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(rest).to_string_lossy().to_string()
} else {
path.to_string()
}
} else if let Some(rest) = path.strip_prefix("./") {
cwd.join(rest).to_string_lossy().to_string()
} else if !Path::new(path).is_absolute() {
cwd.join(path).to_string_lossy().to_string()
} else {
path.to_string()
};
Path::new(&path)
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or(path)
}
fn is_bash_tool(tool_name: &str) -> bool {
matches!(tool_name, "Bash" | "BashOutput" | "KillShell")
}
fn is_file_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"Read" | "Write" | "Edit" | "Grep" | "Glob" | "LS" | "NotebookRead" | "NotebookEdit"
)
}
fn extract_tool_argument(tool_name: &str, input: &serde_json::Value) -> Option<String> {
match tool_name {
"Bash" | "BashOutput" | "KillShell" => input
.get("command")
.and_then(|v| v.as_str())
.map(String::from),
"Read" | "Write" | "Edit" | "NotebookRead" | "NotebookEdit" => input
.get("file_path")
.or_else(|| input.get("path"))
.and_then(|v| v.as_str())
.map(String::from),
"Grep" | "Glob" | "LS" => input
.get("path")
.or_else(|| input.get("pattern"))
.and_then(|v| v.as_str())
.map(String::from),
"Task" => input
.get("subagent_type")
.or_else(|| input.get("description"))
.and_then(|v| v.as_str())
.map(String::from),
"TaskOutput" => input
.get("task_id")
.and_then(|v| v.as_str())
.map(String::from),
"TodoWrite" => input
.get("todos")
.and_then(|v| v.as_array())
.map(|arr| arr.len().to_string())
.or_else(|| Some("0".to_string())),
"SlashCommand" => input
.get("command")
.and_then(|v| v.as_str())
.map(String::from),
"Skill" => input
.get("skill")
.and_then(|v| v.as_str())
.map(String::from),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::{manager::Settings, permission_checker::PermissionChecker};
use serde_json::json;
use std::path::PathBuf;
fn settings_with_permissions(permissions: PermissionSettings) -> Settings {
Settings {
permissions: Some(permissions),
..Default::default()
}
}
#[test]
fn test_parse_simple_rule() {
let rule = ParsedRule::parse("Read");
assert_eq!(rule.tool_name, "Read");
assert!(rule.argument.is_none());
assert!(!rule.is_wildcard);
}
#[test]
fn test_parse_rule_with_argument() {
let rule = ParsedRule::parse("Read(./.env)");
assert_eq!(rule.tool_name, "Read");
assert_eq!(rule.argument, Some("./.env".to_string()));
assert!(!rule.is_wildcard);
}
#[test]
fn test_parse_rule_with_wildcard() {
let rule = ParsedRule::parse("Bash(npm run:*)");
assert_eq!(rule.tool_name, "Bash");
assert_eq!(rule.argument, Some("npm run".to_string()));
assert!(rule.is_wildcard);
}
#[test]
fn test_parse_glob_pattern() {
let rule = ParsedRule::parse("Read(./secrets/**)");
assert_eq!(rule.tool_name, "Read");
assert_eq!(rule.argument, Some("./secrets/**".to_string()));
assert!(!rule.is_wildcard);
}
#[test]
fn test_matches_simple_tool() {
let rule = ParsedRule::parse("Read");
let cwd = PathBuf::from("/tmp");
assert!(rule.matches("Read", &json!({}), &cwd));
assert!(rule.matches("mcp__acp__Read", &json!({}), &cwd));
assert!(!rule.matches("Write", &json!({}), &cwd));
}
#[test]
fn test_matches_tool_group_read() {
let rule = ParsedRule::parse("Read");
let cwd = PathBuf::from("/tmp");
assert!(rule.matches("Read", &json!({}), &cwd));
assert!(rule.matches("Grep", &json!({}), &cwd));
assert!(rule.matches("Glob", &json!({}), &cwd));
assert!(rule.matches("LS", &json!({}), &cwd));
assert!(!rule.matches("Write", &json!({}), &cwd));
}
#[test]
fn test_matches_tool_group_edit() {
let rule = ParsedRule::parse("Edit");
let cwd = PathBuf::from("/tmp");
assert!(rule.matches("Edit", &json!({}), &cwd));
assert!(rule.matches("Write", &json!({}), &cwd));
assert!(!rule.matches("Read", &json!({}), &cwd));
}
#[test]
fn test_matches_bash_exact() {
let rule = ParsedRule::parse("Bash(npm run lint)");
let cwd = PathBuf::from("/tmp");
assert!(rule.matches("Bash", &json!({"command": "npm run lint"}), &cwd));
assert!(!rule.matches("Bash", &json!({"command": "npm run build"}), &cwd));
assert!(!rule.matches("Bash", &json!({"command": "npm run lint --fix"}), &cwd));
}
#[test]
fn test_matches_bash_wildcard() {
let rule = ParsedRule::parse("Bash(npm run:*)");
let cwd = PathBuf::from("/tmp");
assert!(rule.matches("Bash", &json!({"command": "npm run"}), &cwd));
assert!(rule.matches("Bash", &json!({"command": "npm run build"}), &cwd));
assert!(rule.matches("Bash", &json!({"command": "npm run lint --fix"}), &cwd));
assert!(!rule.matches("Bash", &json!({"command": "npm install"}), &cwd));
}
#[test]
fn test_matches_bash_wildcard_blocks_shell_operators() {
let rule = ParsedRule::parse("Bash(npm run:*)");
let cwd = PathBuf::from("/tmp");
assert!(!rule.matches(
"Bash",
&json!({"command": "npm run build && rm -rf /"}),
&cwd
));
assert!(!rule.matches("Bash", &json!({"command": "npm run build | cat"}), &cwd));
assert!(!rule.matches(
"Bash",
&json!({"command": "npm run build; malicious"}),
&cwd
));
}
#[test]
fn test_permission_check_result() {
let allow = PermissionCheckResult::allow("Read");
assert_eq!(allow.decision, PermissionDecision::Allow);
assert_eq!(allow.rule, Some("Read".to_string()));
assert_eq!(allow.source, Some("allow".to_string()));
let deny = PermissionCheckResult::deny("Bash");
assert_eq!(deny.decision, PermissionDecision::Deny);
let ask = PermissionCheckResult::ask();
assert_eq!(ask.decision, PermissionDecision::Ask);
assert!(ask.rule.is_none());
}
#[test]
fn test_mcp_tool_web_fetch_matching() {
let rule = ParsedRule::parse("WebFetch");
let cwd = PathBuf::from("/tmp");
assert!(rule.matches("mcp__web-fetch__webReader", &json!({}), &cwd));
assert!(rule.matches("mcp__web-reader__webReader", &json!({}), &cwd));
}
#[test]
fn test_mcp_tool_web_search_matching() {
let rule = ParsedRule::parse("WebSearch");
let cwd = PathBuf::from("/tmp");
assert!(rule.matches("mcp__web-search-prime__webSearchPrime", &json!({}), &cwd));
}
#[test]
fn test_mcp_tool_does_not_match_unrelated_tools() {
let rule = ParsedRule::parse("WebFetch");
let cwd = PathBuf::from("/tmp");
assert!(!rule.matches("Read", &json!({}), &cwd));
assert!(!rule.matches("Bash", &json!({}), &cwd));
assert!(!rule.matches("Write", &json!({}), &cwd));
}
#[test]
fn test_deny_web_fetch_blocks_mcp_tool() {
let permissions = PermissionSettings {
deny: Some(vec!["WebFetch".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("mcp__web-fetch__webReader", &json!({}));
assert_eq!(result.decision, PermissionDecision::Deny);
assert_eq!(result.rule, Some("WebFetch".to_string()));
}
#[test]
fn test_deny_web_search_blocks_mcp_tool() {
let permissions = PermissionSettings {
deny: Some(vec!["WebSearch".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("mcp__web-search-prime__webSearchPrime", &json!({}));
assert_eq!(result.decision, PermissionDecision::Deny);
assert_eq!(result.rule, Some("WebSearch".to_string()));
}
#[test]
fn test_allow_web_fetch_allows_mcp_tool() {
let permissions = PermissionSettings {
allow: Some(vec!["WebFetch".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("mcp__web-fetch__webReader", &json!({}));
assert_eq!(result.decision, PermissionDecision::Allow);
assert_eq!(result.rule, Some("WebFetch".to_string()));
}
#[test]
fn test_deny_web_fetch_blocks_builtin_tool() {
let permissions = PermissionSettings {
deny: Some(vec!["WebFetch".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("mcp__acp__WebFetch", &json!({}));
assert_eq!(result.decision, PermissionDecision::Deny);
assert_eq!(result.rule, Some("WebFetch".to_string()));
let result = checker.check_permission("WebFetch", &json!({}));
assert_eq!(result.decision, PermissionDecision::Deny);
}
#[test]
fn test_deny_web_search_blocks_builtin_tool() {
let permissions = PermissionSettings {
deny: Some(vec!["WebSearch".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("mcp__acp__WebSearch", &json!({}));
assert_eq!(result.decision, PermissionDecision::Deny);
assert_eq!(result.rule, Some("WebSearch".to_string()));
let result = checker.check_permission("WebSearch", &json!({}));
assert_eq!(result.decision, PermissionDecision::Deny);
}
}