use std::collections::{HashMap, HashSet};
use glob::Pattern;
pub(crate) use zeph_config::tools::{
AutonomyLevel, PermissionAction, PermissionRule, PermissionsConfig,
};
pub(crate) use zeph_common::tool_classification::{READONLY_TOOLS, is_readonly_tool};
#[derive(Debug, Clone, Default)]
pub struct PermissionPolicy {
rules: HashMap<String, Vec<PermissionRule>>,
autonomy_level: AutonomyLevel,
}
impl PermissionPolicy {
#[must_use]
pub fn new(rules: HashMap<String, Vec<PermissionRule>>) -> Self {
Self {
rules,
autonomy_level: AutonomyLevel::default(),
}
}
#[must_use]
pub fn with_autonomy(mut self, level: AutonomyLevel) -> Self {
self.autonomy_level = level;
self
}
#[must_use]
pub fn check(&self, tool_id: &str, input: &str) -> PermissionAction {
match self.autonomy_level {
AutonomyLevel::ReadOnly => {
if READONLY_TOOLS.contains(&tool_id) {
PermissionAction::Allow
} else {
PermissionAction::Deny
}
}
AutonomyLevel::Full => PermissionAction::Allow,
AutonomyLevel::Supervised => {
let Some(rules) = self.rules.get(tool_id) else {
return PermissionAction::Ask;
};
let normalized = input.to_lowercase();
for rule in rules {
if let Ok(pat) = Pattern::new(&rule.pattern.to_lowercase())
&& pat.matches(&normalized)
{
return rule.action;
}
}
PermissionAction::Ask
}
_ => PermissionAction::Deny,
}
}
#[must_use]
pub fn from_legacy(blocked: &[String], confirm: &[String]) -> Self {
let mut rules = Vec::with_capacity(blocked.len() + confirm.len());
for cmd in blocked {
rules.push(PermissionRule {
pattern: format!("*{cmd}*"),
action: PermissionAction::Deny,
});
}
for pat in confirm {
rules.push(PermissionRule {
pattern: format!("*{pat}*"),
action: PermissionAction::Ask,
});
}
rules.push(PermissionRule {
pattern: "*".to_owned(),
action: PermissionAction::Allow,
});
let mut map = HashMap::new();
map.insert("bash".to_owned(), rules);
Self {
rules: map,
autonomy_level: AutonomyLevel::default(),
}
}
#[must_use]
pub fn is_fully_denied(&self, tool_id: &str) -> bool {
self.rules.get(tool_id).is_some_and(|rules| {
!rules.is_empty() && rules.iter().all(|r| r.action == PermissionAction::Deny)
})
}
#[must_use]
pub fn rules(&self) -> &HashMap<String, Vec<PermissionRule>> {
&self.rules
}
#[must_use]
pub fn autonomy_level(&self) -> AutonomyLevel {
self.autonomy_level
}
#[must_use]
pub fn effective_tool_allowlist(
&self,
universe: impl IntoIterator<Item = String>,
) -> Option<HashSet<String>> {
if self.autonomy_level != AutonomyLevel::Supervised {
return None;
}
let mut normalized_rules: HashMap<String, &Vec<PermissionRule>> = HashMap::new();
for (tool_id, rules) in &self.rules {
normalized_rules.insert(normalize_tool_id(tool_id), rules);
}
let universe: HashSet<String> = universe
.into_iter()
.map(|t| normalize_tool_id(&t))
.collect();
let kept: HashSet<String> = universe
.iter()
.filter(|tool| {
!normalized_rules
.get(tool.as_str())
.is_some_and(|rules| is_wholesale_denied(rules))
})
.cloned()
.collect();
if kept == universe { None } else { Some(kept) }
}
}
fn normalize_tool_id(s: &str) -> String {
let base = s.split('(').next().unwrap_or(s);
base.trim().to_lowercase()
}
fn is_wholesale_denied(rules: &[PermissionRule]) -> bool {
for rule in rules {
match rule.action {
PermissionAction::Deny if is_catch_all(&rule.pattern) => return true,
PermissionAction::Deny => {}
_ => return false,
}
}
false
}
fn is_catch_all(pattern: &str) -> bool {
matches!(pattern.trim(), "" | "*" | "**")
}
impl From<PermissionsConfig> for PermissionPolicy {
fn from(config: PermissionsConfig) -> Self {
Self {
rules: config.tools,
autonomy_level: AutonomyLevel::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn policy_with_rules(tool_id: &str, rules: Vec<(&str, PermissionAction)>) -> PermissionPolicy {
let rules = rules
.into_iter()
.map(|(pattern, action)| PermissionRule {
pattern: pattern.to_owned(),
action,
})
.collect();
let mut map = HashMap::new();
map.insert(tool_id.to_owned(), rules);
PermissionPolicy::new(map)
}
#[test]
fn allow_rule_matches_glob() {
let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
}
#[test]
fn deny_rule_blocks() {
let policy = policy_with_rules("bash", vec![("*rm -rf*", PermissionAction::Deny)]);
assert_eq!(policy.check("bash", "rm -rf /tmp"), PermissionAction::Deny);
}
#[test]
fn ask_rule_returns_ask() {
let policy = policy_with_rules("bash", vec![("*git push*", PermissionAction::Ask)]);
assert_eq!(
policy.check("bash", "git push origin main"),
PermissionAction::Ask
);
}
#[test]
fn first_matching_rule_wins() {
let policy = policy_with_rules(
"bash",
vec![
("*safe*", PermissionAction::Allow),
("*", PermissionAction::Deny),
],
);
assert_eq!(
policy.check("bash", "safe command"),
PermissionAction::Allow
);
assert_eq!(
policy.check("bash", "dangerous command"),
PermissionAction::Deny
);
}
#[test]
fn no_rules_returns_default_ask() {
let policy = PermissionPolicy::default();
assert_eq!(policy.check("bash", "anything"), PermissionAction::Ask);
}
#[test]
fn wildcard_pattern() {
let policy = policy_with_rules("bash", vec![("*", PermissionAction::Allow)]);
assert_eq!(policy.check("bash", "any command"), PermissionAction::Allow);
}
#[test]
fn case_sensitive_tool_id() {
let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
assert_eq!(policy.check("BASH", "cmd"), PermissionAction::Ask);
assert_eq!(policy.check("bash", "cmd"), PermissionAction::Deny);
}
#[test]
fn no_matching_rule_falls_through_to_ask() {
let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Ask);
}
#[test]
fn from_legacy_creates_deny_and_ask_rules() {
let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
assert_eq!(
policy.check("bash", "find . -name foo"),
PermissionAction::Allow
);
assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Allow);
}
#[test]
fn is_fully_denied_all_deny() {
let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
assert!(policy.is_fully_denied("bash"));
}
#[test]
fn is_fully_denied_mixed() {
let policy = policy_with_rules(
"bash",
vec![
("echo *", PermissionAction::Allow),
("*", PermissionAction::Deny),
],
);
assert!(!policy.is_fully_denied("bash"));
}
#[test]
fn is_fully_denied_no_rules() {
let policy = PermissionPolicy::default();
assert!(!policy.is_fully_denied("bash"));
}
#[test]
fn case_insensitive_input_matching() {
let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)]);
assert_eq!(policy.check("bash", "SUDO apt"), PermissionAction::Deny);
assert_eq!(policy.check("bash", "Sudo apt"), PermissionAction::Deny);
assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
}
#[test]
fn permissions_config_deserialize() {
let toml_str = r#"
[[bash]]
pattern = "*sudo*"
action = "deny"
[[bash]]
pattern = "*"
action = "ask"
"#;
let config: PermissionsConfig = toml::from_str(toml_str).unwrap();
let policy = PermissionPolicy::from(config);
assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
}
#[test]
fn autonomy_level_deserialize() {
use serde::Deserialize;
#[derive(Deserialize)]
struct Wrapper {
level: AutonomyLevel,
}
let w: Wrapper = toml::from_str(r#"level = "readonly""#).unwrap();
assert_eq!(w.level, AutonomyLevel::ReadOnly);
let w: Wrapper = toml::from_str(r#"level = "supervised""#).unwrap();
assert_eq!(w.level, AutonomyLevel::Supervised);
let w: Wrapper = toml::from_str(r#"level = "full""#).unwrap();
assert_eq!(w.level, AutonomyLevel::Full);
}
#[test]
fn autonomy_level_default_is_supervised() {
assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
}
#[test]
fn is_readonly_tool_matches_allowlist() {
for tool in READONLY_TOOLS {
assert!(is_readonly_tool(tool), "{tool} should be a readonly tool");
}
assert!(!is_readonly_tool("bash"));
assert!(!is_readonly_tool("diagnostics"));
assert!(!is_readonly_tool("write"));
}
#[test]
fn readonly_allows_readonly_tools() {
let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
for tool in &[
"read",
"find_path",
"grep",
"list_directory",
"web_scrape",
"fetch",
] {
assert_eq!(
policy.check(tool, "any input"),
PermissionAction::Allow,
"expected Allow for read-only tool {tool}"
);
}
}
#[test]
fn readonly_denies_write_tools() {
let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Deny);
assert_eq!(
policy.check("file_write", "foo.txt"),
PermissionAction::Deny
);
}
#[test]
fn full_allows_everything() {
let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Full);
assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Allow);
assert_eq!(
policy.check("file_write", "foo.txt"),
PermissionAction::Allow
);
}
#[test]
fn supervised_uses_rules() {
let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)])
.with_autonomy(AutonomyLevel::Supervised);
assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
}
#[test]
fn from_legacy_preserves_supervised_behavior() {
let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
}
fn universe(tools: &[&str]) -> Vec<String> {
tools.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn effective_allowlist_empty_rules_returns_none() {
let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Supervised);
assert_eq!(
policy.effective_tool_allowlist(universe(&["bash", "read"])),
None,
"no rules at all -> no narrowing needed"
);
}
#[test]
fn effective_allowlist_star_catch_all_deny_removes_tool() {
let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
.with_autonomy(AutonomyLevel::Supervised);
let result = policy
.effective_tool_allowlist(universe(&["bash", "read"]))
.expect("bash must be wholesale-denied");
assert!(!result.contains("bash"));
assert!(result.contains("read"));
}
#[test]
fn effective_allowlist_double_star_and_empty_pattern_are_catch_all() {
for pattern in ["**", ""] {
let policy = policy_with_rules("bash", vec![(pattern, PermissionAction::Deny)])
.with_autonomy(AutonomyLevel::Supervised);
let result = policy
.effective_tool_allowlist(universe(&["bash", "read"]))
.unwrap_or_else(|| panic!("pattern {pattern:?} must be treated as catch-all"));
assert!(
!result.contains("bash"),
"pattern {pattern:?} must deny bash"
);
}
}
#[test]
fn effective_allowlist_narrower_deny_only_keeps_tool() {
let policy = policy_with_rules("bash", vec![("*rm -rf*", PermissionAction::Deny)])
.with_autonomy(AutonomyLevel::Supervised);
assert_eq!(
policy.effective_tool_allowlist(universe(&["bash", "read"])),
None,
"narrower deny alone must not wholesale-deny bash"
);
}
#[test]
fn effective_allowlist_allow_before_catch_all_deny_keeps_tool() {
let policy = policy_with_rules(
"bash",
vec![
("echo *", PermissionAction::Allow),
("*", PermissionAction::Deny),
],
)
.with_autonomy(AutonomyLevel::Supervised);
assert_eq!(
policy.effective_tool_allowlist(universe(&["bash", "read"])),
None,
"an earlier Allow rule means the tool is not wholesale-denied"
);
}
#[test]
fn effective_allowlist_ask_before_catch_all_deny_keeps_tool() {
let policy = policy_with_rules(
"bash",
vec![
("*sudo*", PermissionAction::Ask),
("*", PermissionAction::Deny),
],
)
.with_autonomy(AutonomyLevel::Supervised);
assert_eq!(
policy.effective_tool_allowlist(universe(&["bash", "read"])),
None,
"an earlier Ask rule means the tool is not wholesale-denied"
);
}
#[test]
fn effective_allowlist_full_autonomy_returns_none() {
let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
.with_autonomy(AutonomyLevel::Full);
assert_eq!(
policy.effective_tool_allowlist(universe(&["bash", "read"])),
None,
"Full autonomy ignores rules entirely"
);
}
#[test]
fn effective_allowlist_readonly_autonomy_returns_none() {
let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
.with_autonomy(AutonomyLevel::ReadOnly);
assert_eq!(
policy.effective_tool_allowlist(universe(&["bash", "read"])),
None
);
}
#[test]
fn effective_allowlist_no_wholesale_deny_returns_none_not_full_universe() {
let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)])
.with_autonomy(AutonomyLevel::Supervised);
assert_eq!(
policy.effective_tool_allowlist(universe(&["bash", "read", "write"])),
None
);
}
#[test]
fn effective_allowlist_normalizes_mixed_case_parenthesized_rule_key() {
let mut map = HashMap::new();
map.insert(
"Bash(cargo *)".to_owned(),
vec![PermissionRule {
pattern: "*".to_owned(),
action: PermissionAction::Deny,
}],
);
let policy = PermissionPolicy::new(map).with_autonomy(AutonomyLevel::Supervised);
let result = policy
.effective_tool_allowlist(universe(&["bash", "read"]))
.expect("mixed-case parenthesized rule key must still match normalized 'bash'");
assert!(!result.contains("bash"));
assert!(result.contains("read"));
}
#[test]
fn effective_allowlist_multiple_tools_mixed_deny() {
let mut map = HashMap::new();
map.insert(
"bash".to_owned(),
vec![PermissionRule {
pattern: "*".to_owned(),
action: PermissionAction::Deny,
}],
);
map.insert(
"fetch".to_owned(),
vec![PermissionRule {
pattern: "*".to_owned(),
action: PermissionAction::Deny,
}],
);
let policy = PermissionPolicy::new(map).with_autonomy(AutonomyLevel::Supervised);
let result = policy
.effective_tool_allowlist(universe(&["bash", "fetch", "read"]))
.expect("at least one wholesale-denied tool must narrow the set");
assert_eq!(result.len(), 1);
assert!(result.contains("read"));
}
}