use std::path::{Path, PathBuf};
use super::manager::Settings;
use super::rule::{ParsedRule, PermissionCheckResult};
use crate::command_safety::extract_command_basename;
#[derive(Debug)]
pub struct PermissionChecker {
settings: Settings,
cwd: PathBuf,
allow_rules: Vec<(String, ParsedRule)>,
deny_rules: Vec<(String, ParsedRule)>,
ask_rules: Vec<(String, ParsedRule)>,
}
impl PermissionChecker {
pub fn new(settings: Settings, cwd: impl AsRef<Path>) -> Self {
let cwd = cwd.as_ref().to_path_buf();
let allow_rules = Self::parse_rules(
settings.permissions.as_ref().and_then(|p| p.allow.as_ref()),
&cwd,
);
let deny_rules = Self::parse_rules(
settings.permissions.as_ref().and_then(|p| p.deny.as_ref()),
&cwd,
);
let ask_rules = Self::parse_rules(
settings.permissions.as_ref().and_then(|p| p.ask.as_ref()),
&cwd,
);
Self {
settings,
cwd,
allow_rules,
deny_rules,
ask_rules,
}
}
fn parse_rules(rules: Option<&Vec<String>>, cwd: &Path) -> Vec<(String, ParsedRule)> {
rules
.map(|rules| {
rules
.iter()
.map(|rule| (rule.clone(), ParsedRule::parse_with_glob(rule, cwd)))
.collect()
})
.unwrap_or_default()
}
pub fn check_permission(
&self,
tool_name: &str,
tool_input: &serde_json::Value,
) -> PermissionCheckResult {
for (rule_str, parsed) in &self.deny_rules {
if parsed.matches(tool_name, tool_input, &self.cwd) {
tracing::debug!("Tool {} denied by rule: {}", tool_name, rule_str);
return PermissionCheckResult::deny(rule_str);
}
}
for (rule_str, parsed) in &self.allow_rules {
if parsed.matches(tool_name, tool_input, &self.cwd) {
tracing::debug!("Tool {} allowed by rule: {}", tool_name, rule_str);
return PermissionCheckResult::allow(rule_str);
}
}
for (rule_str, parsed) in &self.ask_rules {
if parsed.matches(tool_name, tool_input, &self.cwd) {
tracing::debug!(
"Tool {} requires permission (ask rule): {}",
tool_name,
rule_str
);
return PermissionCheckResult::ask_with_rule(rule_str);
}
}
tracing::debug!("Tool {} has no matching rule, defaulting to ask", tool_name);
PermissionCheckResult::ask()
}
pub fn settings(&self) -> &Settings {
&self.settings
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn has_rules(&self) -> bool {
!self.allow_rules.is_empty() || !self.deny_rules.is_empty() || !self.ask_rules.is_empty()
}
pub fn add_allow_rule(&mut self, rule: &str) {
let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
self.allow_rules.push((rule.to_string(), parsed));
}
pub fn add_allow_rule_for_tool_call(
&mut self,
tool_name: &str,
tool_input: &serde_json::Value,
) {
let stripped = tool_name.strip_prefix("mcp__acp__").unwrap_or(tool_name);
let rule = match stripped {
"Bash" => {
if let Some(cmd) = tool_input.get("command").and_then(|v| v.as_str()) {
let cmd_name = Self::extract_command_name(cmd);
if cmd_name.is_empty() {
stripped.to_string()
} else {
format!("Bash({}:*)", cmd_name) }
} else {
stripped.to_string()
}
}
"Read" | "Grep" | "Glob" | "LS" => {
Self::generate_file_rule("Read", tool_input, &self.cwd)
}
"Edit" | "Write" => {
Self::generate_file_rule(stripped, tool_input, &self.cwd)
}
_ => stripped.to_string(),
};
tracing::info!(
tool_name = %tool_name,
generated_rule = %rule,
"Adding allow rule for Always Allow"
);
let parsed = ParsedRule::parse_with_glob(&rule, &self.cwd);
self.allow_rules.push((rule, parsed));
}
fn extract_command_name(cmd: &str) -> String {
extract_command_basename(cmd).to_string()
}
fn generate_file_rule(tool_name: &str, tool_input: &serde_json::Value, cwd: &Path) -> String {
if let Some(path) = tool_input.get("file_path").and_then(|v| v.as_str()) {
let path = Path::new(path);
if let Some(dir) = path.parent() {
let dir_str = if let Ok(relative) = dir.strip_prefix(cwd) {
format!("./{}", relative.display())
} else {
dir.to_string_lossy().to_string()
};
if dir_str.is_empty() || dir_str == "." {
format!("{}(./*)", tool_name)
} else {
format!("{}({}/**)", tool_name, dir_str)
}
} else {
tool_name.to_string()
}
} else {
tool_name.to_string()
}
}
pub fn add_deny_rule(&mut self, rule: &str) {
let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
self.deny_rules.push((rule.to_string(), parsed));
}
pub fn default_mode(&self) -> Option<&str> {
self.settings
.permissions
.as_ref()
.and_then(|p| p.default_mode.as_deref())
}
pub fn additional_directories(&self) -> Option<&Vec<String>> {
self.settings
.permissions
.as_ref()
.and_then(|p| p.additional_directories.as_ref())
}
}
impl Default for PermissionChecker {
fn default() -> Self {
Self::new(Settings::default(), PathBuf::from("."))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::{PermissionDecision, PermissionSettings};
use serde_json::json;
fn settings_with_permissions(permissions: PermissionSettings) -> Settings {
Settings {
permissions: Some(permissions),
..Default::default()
}
}
#[test]
fn test_empty_rules_default_to_ask() {
let checker = PermissionChecker::default();
let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
assert_eq!(result.decision, PermissionDecision::Ask);
assert!(result.rule.is_none());
}
#[test]
fn test_allow_rule() {
let permissions = PermissionSettings {
allow: Some(vec!["Read".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
assert_eq!(result.decision, PermissionDecision::Allow);
assert_eq!(result.rule, Some("Read".to_string()));
}
#[test]
fn test_deny_rule() {
let permissions = PermissionSettings {
deny: Some(vec!["Bash".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("Bash", &json!({"command": "rm -rf /"}));
assert_eq!(result.decision, PermissionDecision::Deny);
}
#[test]
fn test_deny_takes_priority_over_allow() {
let permissions = PermissionSettings {
allow: Some(vec!["Bash".to_string()]),
deny: Some(vec!["Bash".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("Bash", &json!({"command": "ls"}));
assert_eq!(result.decision, PermissionDecision::Deny);
}
#[test]
fn test_allow_takes_priority_over_ask() {
let permissions = PermissionSettings {
allow: Some(vec!["Read".to_string()]),
ask: Some(vec!["Read".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
let result = checker.check_permission("Read", &json!({}));
assert_eq!(result.decision, PermissionDecision::Allow);
}
#[test]
fn test_bash_wildcard_rule() {
let permissions = PermissionSettings {
allow: Some(vec!["Bash(npm run:*)".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
assert_eq!(
checker
.check_permission("Bash", &json!({"command": "npm run build"}))
.decision,
PermissionDecision::Allow
);
assert_eq!(
checker
.check_permission("Bash", &json!({"command": "npm install"}))
.decision,
PermissionDecision::Ask
);
assert_eq!(
checker
.check_permission("Bash", &json!({"command": "npm run build && rm -rf /"}))
.decision,
PermissionDecision::Ask
);
}
#[test]
fn test_read_group_matching() {
let permissions = PermissionSettings {
allow: Some(vec!["Read".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
assert_eq!(
checker.check_permission("Read", &json!({})).decision,
PermissionDecision::Allow
);
assert_eq!(
checker.check_permission("Grep", &json!({})).decision,
PermissionDecision::Allow
);
assert_eq!(
checker.check_permission("Glob", &json!({})).decision,
PermissionDecision::Allow
);
assert_eq!(
checker.check_permission("LS", &json!({})).decision,
PermissionDecision::Allow
);
assert_eq!(
checker.check_permission("Write", &json!({})).decision,
PermissionDecision::Ask
);
}
#[test]
fn test_add_runtime_rule() {
let mut checker = PermissionChecker::default();
assert_eq!(
checker.check_permission("Read", &json!({})).decision,
PermissionDecision::Ask
);
checker.add_allow_rule("Read");
assert_eq!(
checker.check_permission("Read", &json!({})).decision,
PermissionDecision::Allow
);
}
#[test]
fn test_acp_prefix_stripped() {
let permissions = PermissionSettings {
allow: Some(vec!["Read".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
assert_eq!(
checker.check_permission("Read", &json!({})).decision,
PermissionDecision::Allow
);
assert_eq!(
checker
.check_permission("mcp__acp__Read", &json!({}))
.decision,
PermissionDecision::Allow
);
}
#[test]
fn test_has_rules() {
let checker = PermissionChecker::default();
assert!(!checker.has_rules());
let permissions = PermissionSettings {
allow: Some(vec!["Read".to_string()]),
..Default::default()
};
let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
assert!(checker.has_rules());
}
#[test]
fn test_add_allow_rule_for_bash_command() {
let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
checker
.add_allow_rule_for_tool_call("Bash", &json!({"command": "find /path1 -name '*.rs'"}));
assert_eq!(
checker
.check_permission("Bash", &json!({"command": "find /different/path -type f"}))
.decision,
PermissionDecision::Allow
);
assert_eq!(
checker
.check_permission("Bash", &json!({"command": "find . -name '*.txt' -delete"}))
.decision,
PermissionDecision::Allow
);
assert_eq!(
checker
.check_permission("Bash", &json!({"command": "ls -la /tmp"}))
.decision,
PermissionDecision::Ask
);
assert_eq!(
checker
.check_permission("Bash", &json!({"command": "rm -rf /"}))
.decision,
PermissionDecision::Ask
);
}
#[test]
fn test_add_allow_rule_for_file_operation() {
let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
checker.add_allow_rule_for_tool_call(
"Read",
&json!({"file_path": "/tmp/project/src/main.rs"}),
);
assert_eq!(
checker
.check_permission("Read", &json!({"file_path": "/tmp/project/src/lib.rs"}))
.decision,
PermissionDecision::Allow
);
assert_eq!(
checker
.check_permission(
"Read",
&json!({"file_path": "/tmp/project/src/utils/helper.rs"})
)
.decision,
PermissionDecision::Allow
);
assert_eq!(
checker
.check_permission("Read", &json!({"file_path": "/etc/passwd"}))
.decision,
PermissionDecision::Ask
);
}
#[test]
fn test_add_allow_rule_for_mcp_prefixed_tool() {
let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
checker
.add_allow_rule_for_tool_call("mcp__acp__Bash", &json!({"command": "npm run build"}));
assert_eq!(
checker
.check_permission("Bash", &json!({"command": "npm run test"}))
.decision,
PermissionDecision::Allow
);
assert_eq!(
checker
.check_permission("mcp__acp__Bash", &json!({"command": "npm run lint"}))
.decision,
PermissionDecision::Allow
);
}
#[test]
fn test_extract_command_name() {
assert_eq!(
PermissionChecker::extract_command_name("cargo build --release"),
"cargo"
);
assert_eq!(
PermissionChecker::extract_command_name("find /path -name '*.rs'"),
"find"
);
assert_eq!(PermissionChecker::extract_command_name("ls -la /tmp"), "ls");
assert_eq!(PermissionChecker::extract_command_name("npm"), "npm");
assert_eq!(PermissionChecker::extract_command_name(""), "");
assert_eq!(
PermissionChecker::extract_command_name("/usr/bin/find . -name '*.rs'"),
"find"
);
assert_eq!(
PermissionChecker::extract_command_name("/usr/local/bin/cargo build"),
"cargo"
);
}
}