#[derive(Debug, Clone, Default)]
pub struct PermissionRules {
pub allow: Vec<String>,
pub deny: Vec<String>,
}
pub enum PermissionOutcome {
Allow,
Deny(String),
}
impl PermissionRules {
pub fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionOutcome {
let args_str = extract_args_string(args);
for pattern in &self.deny {
if pattern_matches(pattern, tool_name, &args_str) {
return PermissionOutcome::Deny(format!("denied by rule: {pattern}"));
}
}
if !self.allow.is_empty() {
let allowed = self
.allow
.iter()
.any(|p| pattern_matches(p, tool_name, &args_str));
if !allowed {
return PermissionOutcome::Deny(format!("tool '{tool_name}' not in allow list"));
}
}
PermissionOutcome::Allow
}
}
fn extract_args_string(args: &serde_json::Value) -> String {
let mut out = String::new();
flatten_strings(args, &mut out);
if out.len() > MAX_ARGS_LEN {
out.truncate(crate::utf8::floor_char_boundary(&out, MAX_ARGS_LEN));
}
out
}
const MAX_ARGS_LEN: usize = 64 * 1024;
fn flatten_strings(v: &serde_json::Value, out: &mut String) {
if out.len() >= MAX_ARGS_LEN {
return;
}
match v {
serde_json::Value::String(s) => {
if !out.is_empty() {
out.push(' ');
}
out.push_str(s);
}
serde_json::Value::Array(arr) => {
for item in arr {
if out.len() >= MAX_ARGS_LEN {
break;
}
flatten_strings(item, out);
}
}
serde_json::Value::Object(map) => {
for value in map.values() {
if out.len() >= MAX_ARGS_LEN {
break;
}
flatten_strings(value, out);
}
}
_ => {}
}
}
fn pattern_matches(pattern: &str, tool_name: &str, args_str: &str) -> bool {
if let Some(paren_idx) = pattern.find('(') {
if pattern.ends_with(')') {
let pat_tool = &pattern[..paren_idx];
let glob = &pattern[paren_idx + 1..pattern.len() - 1];
if !pat_tool.eq_ignore_ascii_case(tool_name) {
return false;
}
return glob_match(glob, args_str);
}
}
pattern.eq_ignore_ascii_case(tool_name)
}
fn glob_match(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
let plen = p.len();
let tlen = t.len();
let mut dp = vec![vec![false; tlen + 1]; plen + 1];
dp[0][0] = true;
for i in 1..=plen {
if p[i - 1] == '*' {
dp[i][0] = dp[i - 1][0];
}
}
for i in 1..=plen {
for j in 1..=tlen {
if p[i - 1] == '*' {
dp[i][j] = dp[i][j - 1] || dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j - 1] && p[i - 1].eq_ignore_ascii_case(&t[j - 1]);
}
}
}
dp[plen][tlen]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_glob_exact_match() {
assert!(glob_match("git status", "git status"));
}
#[test]
fn test_glob_star_prefix() {
assert!(glob_match("git *", "git status"));
assert!(glob_match("git *", "git commit -m \"fix\""));
assert!(!glob_match("git *", "rm -rf /"));
}
#[test]
fn test_glob_star_anywhere() {
assert!(glob_match("*rm*", "sudo rm -rf /"));
assert!(glob_match("*rm*", "rm -rf"));
assert!(!glob_match("*rm*", "git status"));
}
#[test]
fn test_glob_case_insensitive() {
assert!(glob_match("GIT *", "git status"));
assert!(glob_match("git *", "GIT STATUS"));
}
#[test]
fn test_glob_empty_pattern_matches_empty_only() {
assert!(glob_match("", ""));
assert!(!glob_match("", "anything"));
}
#[test]
fn test_glob_star_only_matches_anything() {
assert!(glob_match("*", ""));
assert!(glob_match("*", "anything at all"));
}
#[test]
fn test_pattern_tool_name_only() {
assert!(pattern_matches("Bash", "bash", "some args"));
assert!(pattern_matches("Write", "write", ""));
assert!(!pattern_matches("Read", "write", ""));
}
#[test]
fn test_pattern_with_glob() {
assert!(pattern_matches("Bash(git *)", "bash", "git status"));
assert!(!pattern_matches("Bash(git *)", "bash", "rm -rf /"));
assert!(!pattern_matches("Bash(git *)", "read", "git status"));
}
#[test]
fn test_empty_rules_allows_everything() {
let rules = PermissionRules::default();
assert!(matches!(
rules.check("bash", &json!({"command": "rm -rf /"})),
PermissionOutcome::Allow
));
}
#[test]
fn test_deny_by_tool_name() {
let rules = PermissionRules {
allow: vec![],
deny: vec!["Write".to_string()],
};
assert!(matches!(
rules.check("write", &json!({"path": "/etc/passwd"})),
PermissionOutcome::Deny(_)
));
assert!(matches!(
rules.check("read", &json!({"path": "/etc/passwd"})),
PermissionOutcome::Allow
));
}
#[test]
fn test_deny_by_glob_pattern() {
let rules = PermissionRules {
allow: vec![],
deny: vec!["Bash(rm *)".to_string()],
};
assert!(matches!(
rules.check("bash", &json!({"command": "rm -rf /"})),
PermissionOutcome::Deny(_)
));
assert!(matches!(
rules.check("bash", &json!({"command": "git status"})),
PermissionOutcome::Allow
));
}
#[test]
fn test_allow_list_restricts_unlisted_tools() {
let rules = PermissionRules {
allow: vec!["Read".to_string(), "Bash(git *)".to_string()],
deny: vec![],
};
assert!(matches!(
rules.check("read", &json!({"path": "src/lib.rs"})),
PermissionOutcome::Allow
));
assert!(matches!(
rules.check("bash", &json!({"command": "git log"})),
PermissionOutcome::Allow
));
assert!(matches!(
rules.check("write", &json!({"path": "out.txt"})),
PermissionOutcome::Deny(_)
));
assert!(matches!(
rules.check("bash", &json!({"command": "curl https://example.com"})),
PermissionOutcome::Deny(_)
));
}
#[test]
fn test_deny_takes_precedence_over_allow() {
let rules = PermissionRules {
allow: vec!["Bash".to_string()],
deny: vec!["Bash(rm *)".to_string()],
};
assert!(matches!(
rules.check("bash", &json!({"command": "rm file.txt"})),
PermissionOutcome::Deny(_)
));
assert!(matches!(
rules.check("bash", &json!({"command": "echo hello"})),
PermissionOutcome::Allow
));
}
#[test]
fn test_deny_matches_nested_object_arg() {
let rules = PermissionRules {
allow: vec![],
deny: vec!["Bash(*rm -rf*)".to_string()],
};
let nested = json!({
"options": { "command": "rm -rf /" }
});
assert!(matches!(
rules.check("bash", &nested),
PermissionOutcome::Deny(_)
));
}
#[test]
fn test_deny_matches_array_arg() {
let rules = PermissionRules {
allow: vec![],
deny: vec!["Bash(*sudo*)".to_string()],
};
let arr = json!({ "argv": ["bash", "-c", "sudo poweroff"] });
assert!(matches!(
rules.check("bash", &arr),
PermissionOutcome::Deny(_)
));
}
#[test]
fn test_extract_args_string_caps_at_max() {
let huge = "x".repeat(80 * 1024);
let v = json!({ "command": huge });
let s = extract_args_string(&v);
assert!(s.len() <= MAX_ARGS_LEN);
}
#[test]
fn test_deny_reason_contains_pattern() {
let rules = PermissionRules {
allow: vec![],
deny: vec!["Bash(sudo *)".to_string()],
};
if let PermissionOutcome::Deny(reason) =
rules.check("bash", &json!({"command": "sudo apt-get install vim"}))
{
assert!(reason.contains("Bash(sudo *)"));
} else {
panic!("expected Deny");
}
}
}