use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PermissionRule {
pub rule: String,
#[serde(skip)]
pub(crate) tool_name: Option<String>,
#[serde(skip)]
pub(crate) arg_pattern: Option<String>,
}
impl<'de> Deserialize<'de> for PermissionRule {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum RuleRepr {
Plain(String),
Struct { rule: String },
}
let rule_str = match RuleRepr::deserialize(deserializer)? {
RuleRepr::Plain(s) => s,
RuleRepr::Struct { rule } => rule,
};
Ok(PermissionRule::new(&rule_str))
}
}
impl PermissionRule {
pub fn new(rule: &str) -> Self {
let (tool_name, arg_pattern) = Self::parse_rule(rule);
Self {
rule: rule.to_string(),
tool_name,
arg_pattern,
}
}
fn parse_rule(rule: &str) -> (Option<String>, Option<String>) {
if let Some(paren_start) = rule.find('(') {
if rule.ends_with(')') {
let tool_name = rule[..paren_start].to_string();
let pattern = rule[paren_start + 1..rule.len() - 1].to_string();
return (Some(tool_name), Some(pattern));
}
}
(Some(rule.to_string()), None)
}
pub fn matches(&self, tool_name: &str, args: &serde_json::Value) -> bool {
let rule_tool = match &self.tool_name {
Some(t) => t,
None => return false,
};
if !self.matches_tool_name(rule_tool, tool_name) {
return false;
}
let pattern = match &self.arg_pattern {
Some(p) => p,
None => return true,
};
self.matches_args(pattern, tool_name, args)
}
fn matches_tool_name(&self, rule_tool: &str, actual_tool: &str) -> bool {
if rule_tool.contains('*') || rule_tool.contains('?') {
return self.glob_match(rule_tool, actual_tool);
}
if rule_tool.starts_with("mcp__") && actual_tool.starts_with("mcp__") {
if actual_tool.starts_with(rule_tool) {
return true;
}
}
rule_tool.eq_ignore_ascii_case(actual_tool)
}
fn matches_args(&self, pattern: &str, tool_name: &str, args: &serde_json::Value) -> bool {
if pattern == "*" {
return true;
}
let arg_string = self.build_arg_string(tool_name, args);
self.glob_match(pattern, &arg_string)
}
fn build_arg_string(&self, tool_name: &str, args: &serde_json::Value) -> String {
match tool_name.to_lowercase().as_str() {
"bash" => {
args.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
"read" | "write" | "edit" => {
args.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
"glob" => {
args.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
"grep" => {
let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
format!("{} {}", pattern, path)
}
"ls" => {
args.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
_ => {
serde_json::to_string(args).unwrap_or_default()
}
}
}
fn glob_match(&self, pattern: &str, text: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix(":*") {
return text.starts_with(prefix);
}
let text = text.replace('\\', "/");
let regex_pattern = Self::glob_to_regex(pattern);
if let Ok(re) = regex::Regex::new(®ex_pattern) {
re.is_match(&text)
} else {
text.starts_with(pattern)
}
}
fn glob_to_regex(pattern: &str) -> String {
let mut regex = String::from("^");
let chars: Vec<char> = pattern.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match c {
'*' => {
if i + 1 < chars.len() && chars[i + 1] == '*' {
if i + 2 < chars.len() && chars[i + 2] == '/' {
regex.push_str(".*");
i += 3;
} else {
regex.push_str(".*");
i += 2;
}
} else {
regex.push_str("[^/\\\\]*");
i += 1;
}
}
'?' => {
regex.push_str("[^/\\\\]");
i += 1;
}
'.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
regex.push('\\');
regex.push(c);
i += 1;
}
_ => {
regex.push(c);
i += 1;
}
}
}
regex.push('$');
regex
}
}