use serde_json::Value;
use crate::permission::config::match_glob_pattern;
fn match_tool_pattern(pattern: &str, target: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let (prefix, suffix) = (parts[0], parts[1]);
if prefix.is_empty() && suffix.is_empty() {
return true;
}
if prefix.is_empty() {
return target.ends_with(suffix);
}
if suffix.is_empty() {
return target.starts_with(prefix);
}
return target.starts_with(prefix) && target.ends_with(suffix);
}
let mut current_pos = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue; }
if i == 0 && !pattern.starts_with('*') {
if !target.starts_with(part) {
return false;
}
current_pos = part.len();
} else if i == parts.len() - 1 && !pattern.ends_with('*') {
if !target[current_pos..].ends_with(part) {
return false;
}
} else {
if let Some(pos) = target[current_pos..].find(part) {
current_pos += pos + part.len();
} else {
return false;
}
}
}
return true;
}
if pattern == target {
return true;
}
match_glob_pattern(pattern, target)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedRule {
pub tool_name: String,
pub pattern: Option<String>,
}
impl ParsedRule {
pub fn parse(rule: &str) -> Self {
let trimmed = rule.trim();
if let Some(open_paren) = trimmed.find('(') {
if trimmed.ends_with(')') {
let tool_name = trimmed[..open_paren].trim().to_string();
let pattern = trimmed[open_paren + 1..trimmed.len() - 1]
.trim()
.to_string();
if pattern.is_empty() {
return Self {
tool_name,
pattern: None,
};
}
return Self {
tool_name,
pattern: Some(pattern),
};
}
}
Self {
tool_name: trimmed.to_string(),
pattern: None,
}
}
pub fn matches_tool_call(&self, tool_name: &str, args: &Value) -> bool {
if !self.tool_name.eq_ignore_ascii_case(tool_name) {
return false;
}
let Some(pattern) = &self.pattern else {
return true;
};
let tool_name_lower = tool_name.to_ascii_lowercase();
let target = match tool_name_lower.as_str() {
"bash" => args.get("command").and_then(|v| v.as_str()),
"write" | "edit" | "read" | "apply_patch" => {
args.get("file_path").and_then(|v| v.as_str())
}
"webfetch" => args.get("url").and_then(|v| v.as_str()),
"notebookedit" => args.get("notebook_path").and_then(|v| v.as_str()),
"websearch" => args.get("query").and_then(|v| v.as_str()),
"js_repl" => args.get("code").and_then(|v| v.as_str()),
"bashoutput" | "killshell" => args.get("bash_id").and_then(|v| v.as_str()),
"session_note" | "memory" => args.get("action").and_then(|v| v.as_str()),
_ => None,
};
match target {
Some(target_str) => match_tool_pattern(pattern, target_str),
None => {
let args_str = args.to_string();
match_tool_pattern(pattern, &args_str)
}
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn test_parse_tool_only() {
let rule = ParsedRule::parse("Read");
assert_eq!(rule.tool_name, "Read");
assert_eq!(rule.pattern, None);
}
#[test]
fn test_parse_tool_with_pattern() {
let rule = ParsedRule::parse("Bash(npm run *)");
assert_eq!(rule.tool_name, "Bash");
assert_eq!(rule.pattern, Some("npm run *".to_string()));
}
#[test]
fn test_parse_write_path() {
let rule = ParsedRule::parse("Write(/src/**)");
assert_eq!(rule.tool_name, "Write");
assert_eq!(rule.pattern, Some("/src/**".to_string()));
}
#[test]
fn test_parse_nested_parens() {
let rule = ParsedRule::parse("Bash(echo $(foo))");
assert_eq!(rule.tool_name, "Bash");
assert_eq!(rule.pattern, Some("echo $(foo)".to_string()));
}
#[test]
fn test_parse_empty_parens() {
let rule = ParsedRule::parse("Bash()");
assert_eq!(rule.tool_name, "Bash");
assert_eq!(rule.pattern, None);
}
#[test]
fn test_parse_with_whitespace() {
let rule = ParsedRule::parse(" Bash( npm run * ) ");
assert_eq!(rule.tool_name, "Bash");
assert_eq!(rule.pattern, Some("npm run *".to_string()));
}
#[test]
fn test_match_bash_command() {
let rule = ParsedRule::parse("Bash(npm run *)");
assert!(rule.matches_tool_call("Bash", &json!({"command": "npm run test"})));
assert!(rule.matches_tool_call("Bash", &json!({"command": "npm run build"})));
assert!(!rule.matches_tool_call("Bash", &json!({"command": "cargo build"})));
}
#[test]
fn test_match_bash_wildcard() {
let rule = ParsedRule::parse("Bash(*)");
assert!(rule.matches_tool_call("Bash", &json!({"command": "anything here"})));
assert!(rule.matches_tool_call("Bash", &json!({"command": "npm run test"})));
}
#[test]
fn test_match_write_path() {
let rule = ParsedRule::parse("Write(/src/**)");
assert!(rule.matches_tool_call("Write", &json!({"file_path": "/src/main.rs"})));
assert!(rule.matches_tool_call("Write", &json!({"file_path": "/src/components/button.rs"})));
assert!(!rule.matches_tool_call("Write", &json!({"file_path": "/tmp/test.txt"})));
}
#[test]
fn test_match_read_exact() {
let rule = ParsedRule::parse("Read(./.env)");
assert!(rule.matches_tool_call("Read", &json!({"file_path": "./.env"})));
assert!(!rule.matches_tool_call("Read", &json!({"file_path": "./.env.local"})));
}
#[test]
fn test_match_read_glob() {
let rule = ParsedRule::parse("Read(./.env.*)");
assert!(rule.matches_tool_call("Read", &json!({"file_path": "./.env.production"})));
assert!(rule.matches_tool_call("Read", &json!({"file_path": "./.env.staging"})));
assert!(!rule.matches_tool_call("Read", &json!({"file_path": "./.env"})));
}
#[test]
fn test_match_web_fetch() {
let rule = ParsedRule::parse("WebFetch(*example.com*)");
assert!(rule.matches_tool_call("WebFetch", &json!({"url": "https://example.com/path"})));
assert!(rule.matches_tool_call("WebFetch", &json!({"url": "http://sub.example.com/api"})));
assert!(!rule.matches_tool_call("WebFetch", &json!({"url": "https://other.com"})));
}
#[test]
fn test_match_case_insensitive_tool_name() {
let rule = ParsedRule::parse("bash(npm run *)");
assert!(rule.matches_tool_call("Bash", &json!({"command": "npm run test"})));
let rule = ParsedRule::parse("BASH(npm run *)");
assert!(rule.matches_tool_call("Bash", &json!({"command": "npm run test"})));
}
#[test]
fn test_match_tool_only_matches_any_args() {
let rule = ParsedRule::parse("Bash");
assert!(rule.matches_tool_call("Bash", &json!({"command": "anything"})));
assert!(rule.matches_tool_call("Bash", &json!({"command": "rm -rf /"})));
}
#[test]
fn test_match_wrong_tool() {
let rule = ParsedRule::parse("Bash(npm run *)");
assert!(!rule.matches_tool_call("Write", &json!({"file_path": "/tmp/test"})));
}
#[test]
fn test_match_edit_path() {
let rule = ParsedRule::parse("Edit(/src/**)");
assert!(rule.matches_tool_call("Edit", &json!({"file_path": "/src/main.rs"})));
assert!(!rule.matches_tool_call("Edit", &json!({"file_path": "/tmp/test.txt"})));
}
#[test]
fn test_match_apply_patch() {
let rule = ParsedRule::parse("apply_patch(/src/**)");
assert!(rule.matches_tool_call("apply_patch", &json!({"file_path": "/src/main.rs"})));
assert!(!rule.matches_tool_call("apply_patch", &json!({"file_path": "/tmp/test.txt"})));
}
#[test]
fn test_match_notebook_edit() {
let rule = ParsedRule::parse("NotebookEdit(/notebooks/**)");
assert!(rule.matches_tool_call(
"NotebookEdit",
&json!({"notebook_path": "/notebooks/test.ipynb"})
));
assert!(
!rule.matches_tool_call("NotebookEdit", &json!({"notebook_path": "/tmp/test.ipynb"}))
);
}
#[test]
fn test_match_web_search() {
let rule = ParsedRule::parse("WebSearch(rust *)");
assert!(rule.matches_tool_call("WebSearch", &json!({"query": "rust async"})));
assert!(!rule.matches_tool_call("WebSearch", &json!({"query": "python async"})));
}
#[test]
fn test_match_js_repl() {
let rule = ParsedRule::parse("js_repl(console.*)");
assert!(rule.matches_tool_call("js_repl", &json!({"code": "console.log('hi')"})));
assert!(!rule.matches_tool_call("js_repl", &json!({"code": "1 + 1"})));
}
#[test]
fn test_match_bash_output() {
let rule = ParsedRule::parse("BashOutput(abc-*)");
assert!(rule.matches_tool_call("BashOutput", &json!({"bash_id": "abc-123"})));
assert!(!rule.matches_tool_call("BashOutput", &json!({"bash_id": "xyz-123"})));
}
#[test]
fn test_match_kill_shell() {
let rule = ParsedRule::parse("KillShell(abc-*)");
assert!(rule.matches_tool_call("KillShell", &json!({"bash_id": "abc-123"})));
assert!(!rule.matches_tool_call("KillShell", &json!({"bash_id": "xyz-123"})));
}
#[test]
fn test_match_session_note() {
let rule = ParsedRule::parse("session_note(append)");
assert!(rule.matches_tool_call("session_note", &json!({"action": "append"})));
assert!(!rule.matches_tool_call("session_note", &json!({"action": "read"})));
}
#[test]
fn test_match_memory() {
let rule = ParsedRule::parse("memory(session_*)");
assert!(rule.matches_tool_call("memory", &json!({"action": "session_append"})));
assert!(!rule.matches_tool_call("memory", &json!({"action": "write"})));
}
#[test]
fn test_match_unknown_tool_fallback() {
let rule = ParsedRule::parse("CustomTool(*hello*)");
assert!(rule.matches_tool_call("CustomTool", &json!({"any": "hello world"})));
assert!(!rule.matches_tool_call("CustomTool", &json!({"any": "goodbye"})));
}
#[test]
fn test_match_deny_overrides_allow() {
let deny_rule = ParsedRule::parse("Bash(curl *)");
let allow_rule = ParsedRule::parse("Bash(*)");
let args_curl = json!({"command": "curl https://example.com"});
let args_ls = json!({"command": "ls -la"});
assert!(deny_rule.matches_tool_call("Bash", &args_curl));
assert!(allow_rule.matches_tool_call("Bash", &args_curl));
assert!(!deny_rule.matches_tool_call("Bash", &args_ls));
assert!(allow_rule.matches_tool_call("Bash", &args_ls));
}
}