use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ToolPermission {
Read,
Write,
Network,
Execute,
Sensitive,
}
impl std::fmt::Display for ToolPermission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ToolPermission::Read => write!(f, "read"),
ToolPermission::Write => write!(f, "write"),
ToolPermission::Network => write!(f, "network"),
ToolPermission::Execute => write!(f, "execute"),
ToolPermission::Sensitive => write!(f, "sensitive"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PermissionMode {
#[default]
Default,
Plan,
AcceptEdits,
BypassPermissions,
Auto,
Bubble,
DontAsk,
}
impl PermissionMode {
pub fn allows_write(&self) -> bool {
match self {
PermissionMode::BypassPermissions => true,
PermissionMode::AcceptEdits => true,
PermissionMode::DontAsk => true, PermissionMode::Plan => false,
_ => false,
}
}
pub fn requires_interaction(&self) -> bool {
match self {
PermissionMode::BypassPermissions => false,
PermissionMode::Auto => false,
PermissionMode::DontAsk => false, PermissionMode::AcceptEdits => false, _ => true,
}
}
pub fn uses_classifier(&self) -> bool {
matches!(self, PermissionMode::Auto)
}
}
impl std::fmt::Display for PermissionMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PermissionMode::Default => write!(f, "default"),
PermissionMode::Plan => write!(f, "plan"),
PermissionMode::AcceptEdits => write!(f, "acceptEdits"),
PermissionMode::BypassPermissions => write!(f, "bypassPermissions"),
PermissionMode::Auto => write!(f, "auto"),
PermissionMode::Bubble => write!(f, "bubble"),
PermissionMode::DontAsk => write!(f, "dontAsk"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PermissionDecision {
Allow,
Deny {
reason: String,
},
RequireApproval,
Ask {
suggestions: Vec<String>,
},
}
impl PermissionDecision {
pub fn is_allowed(&self) -> bool {
matches!(self, PermissionDecision::Allow)
}
pub fn is_denied(&self) -> bool {
matches!(self, PermissionDecision::Deny { .. })
}
pub fn requires_approval(&self) -> bool {
matches!(
self,
PermissionDecision::RequireApproval | PermissionDecision::Ask { .. }
)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
)]
#[serde(rename_all = "lowercase")]
pub enum RuleSource {
#[default]
Default = 0,
LocalSettings = 1,
ProjectSettings = 2,
UserSettings = 3,
Managed = 4,
CliArg = 5,
Session = 6,
}
impl std::fmt::Display for RuleSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RuleSource::Default => write!(f, "default"),
RuleSource::LocalSettings => write!(f, "localSettings"),
RuleSource::ProjectSettings => write!(f, "projectSettings"),
RuleSource::UserSettings => write!(f, "userSettings"),
RuleSource::Managed => write!(f, "managed"),
RuleSource::CliArg => write!(f, "cliArg"),
RuleSource::Session => write!(f, "session"),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum RuleMatcher {
Tool { name: String },
Pattern { pattern: String },
Permission { permission: ToolPermission },
All,
}
impl RuleMatcher {
pub fn matches_matcher_str(&self, matcher_str: &str) -> bool {
match self {
RuleMatcher::Tool { name } => name == matcher_str,
RuleMatcher::Pattern { pattern } => pattern == matcher_str,
RuleMatcher::Permission { .. } => false,
RuleMatcher::All => matcher_str == "*" || matcher_str == "all",
}
}
pub fn matches(&self, tool_name: &str, permissions: &[ToolPermission]) -> bool {
match self {
RuleMatcher::Tool { name } => tool_name == name,
RuleMatcher::Pattern { pattern } => {
if pattern == "*" {
return true;
}
if tool_name == pattern {
return true;
}
#[cfg(feature = "permission")]
{
if let Ok(glob) = globset::Glob::new(pattern) {
let matcher = glob.compile_matcher();
if matcher.is_match(tool_name) {
return true;
}
}
}
if pattern.ends_with("*)") {
let prefix = &pattern[..pattern.len() - 2];
if tool_name.starts_with(prefix) {
return true;
}
}
if tool_name.starts_with(pattern)
&& tool_name.len() > pattern.len()
&& tool_name.as_bytes()[pattern.len()] == b'('
{
return true;
}
false
}
RuleMatcher::Permission { permission } => permissions.contains(permission),
RuleMatcher::All => true,
}
}
}
impl std::fmt::Display for RuleMatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RuleMatcher::Tool { name } => write!(f, "tool:{}", name),
RuleMatcher::Pattern { pattern } => write!(f, "pattern:{}", pattern),
RuleMatcher::Permission { permission } => write!(f, "permission:{}", permission),
RuleMatcher::All => write!(f, "all"),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum RuleBehavior {
Allow,
Deny { reason: String },
Ask { suggestions: Vec<String> },
}
impl RuleBehavior {
pub fn to_decision(&self) -> PermissionDecision {
match self {
RuleBehavior::Allow => PermissionDecision::Allow,
RuleBehavior::Deny { reason } => PermissionDecision::Deny {
reason: reason.clone(),
},
RuleBehavior::Ask { suggestions } => PermissionDecision::Ask {
suggestions: suggestions.clone(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PermissionRule {
pub matcher: RuleMatcher,
pub behavior: RuleBehavior,
pub source: RuleSource,
#[serde(default)]
pub description: Option<String>,
}
impl PermissionRule {
pub fn allow(matcher: RuleMatcher, source: RuleSource) -> Self {
Self {
matcher,
behavior: RuleBehavior::Allow,
source,
description: None,
}
}
pub fn deny(matcher: RuleMatcher, reason: String, source: RuleSource) -> Self {
Self {
matcher,
behavior: RuleBehavior::Deny { reason },
source,
description: None,
}
}
pub fn ask(matcher: RuleMatcher, suggestions: Vec<String>, source: RuleSource) -> Self {
Self {
matcher,
behavior: RuleBehavior::Ask { suggestions },
source,
description: None,
}
}
pub fn matches(&self, tool_name: &str, permissions: &[ToolPermission]) -> bool {
self.matcher.matches(tool_name, permissions)
}
}
#[derive(Debug, Clone, Default)]
pub struct RuleRegistry {
rules: Vec<PermissionRule>,
tool_index: HashMap<String, Vec<usize>>,
}
impl RuleRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn add_rule(&mut self, rule: PermissionRule) {
let pos = self
.rules
.iter()
.position(|r| r.source < rule.source)
.unwrap_or(self.rules.len());
self.rules.insert(pos, rule);
self.rebuild_tool_index();
}
pub fn add_rules(&mut self, rules: Vec<PermissionRule>) {
for rule in rules {
self.add_rule(rule);
}
}
pub fn check(&self, tool_name: &str, permissions: &[ToolPermission]) -> Option<RuleBehavior> {
for rule in &self.rules {
if matches!(rule.behavior, RuleBehavior::Deny { .. })
&& rule.matches(tool_name, permissions)
{
return Some(rule.behavior.clone());
}
}
for rule in &self.rules {
if matches!(rule.behavior, RuleBehavior::Ask { .. })
&& rule.matches(tool_name, permissions)
{
return Some(rule.behavior.clone());
}
}
for rule in &self.rules {
if matches!(rule.behavior, RuleBehavior::Allow) && rule.matches(tool_name, permissions)
{
return Some(rule.behavior.clone());
}
}
None
}
pub fn rules_by_source(&self, source: RuleSource) -> Vec<&PermissionRule> {
self.rules.iter().filter(|r| r.source == source).collect()
}
pub fn remove_by_source(&mut self, source: RuleSource) {
self.rules.retain(|r| r.source != source);
self.rebuild_tool_index();
}
pub fn remove_by_matcher(&mut self, matcher_str: &str) -> usize {
let before = self.rules.len();
self.rules
.retain(|r| !r.matcher.matches_matcher_str(matcher_str));
let removed = before - self.rules.len();
if removed > 0 {
self.rebuild_tool_index();
}
removed
}
fn rebuild_tool_index(&mut self) {
self.tool_index.clear();
for (i, rule) in self.rules.iter().enumerate() {
if let RuleMatcher::Tool { name } = &rule.matcher {
self.tool_index.entry(name.clone()).or_default().push(i);
}
}
}
pub fn clear(&mut self) {
self.rules.clear();
self.tool_index.clear();
}
pub fn len(&self) -> usize {
self.rules.len()
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn all_rules(&self) -> &[PermissionRule] {
&self.rules
}
}
pub trait PermissionPolicy: Send + Sync {
fn check<'a>(
&'a self,
tool_name: &'a str,
permissions: &'a [ToolPermission],
) -> BoxFuture<'a, PermissionDecision>;
}
pub struct DefaultPermissionPolicy {
granted: HashSet<ToolPermission>,
approval_required: HashSet<ToolPermission>,
}
impl Default for DefaultPermissionPolicy {
fn default() -> Self {
Self::new()
}
}
impl DefaultPermissionPolicy {
pub fn new() -> Self {
let mut approval_required = HashSet::new();
approval_required.insert(ToolPermission::Execute);
approval_required.insert(ToolPermission::Sensitive);
Self {
granted: HashSet::new(),
approval_required,
}
}
pub fn grant(mut self, perm: ToolPermission) -> Self {
self.granted.insert(perm);
self.approval_required.remove(&perm);
self
}
pub fn require_approval(mut self, perm: ToolPermission) -> Self {
self.approval_required.insert(perm);
self.granted.remove(&perm);
self
}
pub fn grant_all(mut self) -> Self {
self.granted.insert(ToolPermission::Read);
self.granted.insert(ToolPermission::Write);
self.granted.insert(ToolPermission::Network);
self.granted.insert(ToolPermission::Execute);
self.granted.insert(ToolPermission::Sensitive);
self.approval_required.clear();
self
}
}
impl PermissionPolicy for DefaultPermissionPolicy {
fn check<'a>(
&'a self,
_tool_name: &'a str,
permissions: &'a [ToolPermission],
) -> BoxFuture<'a, PermissionDecision> {
Box::pin(async move {
if permissions.is_empty() {
return PermissionDecision::Allow;
}
let mut need_approval = Vec::new();
let mut denied = Vec::new();
for perm in permissions {
if self.granted.contains(perm) {
continue;
}
if self.approval_required.contains(perm) {
need_approval.push(*perm);
} else {
denied.push(*perm);
}
}
if !denied.is_empty() {
let names: Vec<String> = denied.iter().map(|p| p.to_string()).collect();
return PermissionDecision::Deny {
reason: format!("Unauthorized permissions: {}", names.join(", ")),
};
}
if !need_approval.is_empty() {
return PermissionDecision::RequireApproval;
}
PermissionDecision::Allow
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permission_mode_default() {
let mode = PermissionMode::default();
assert_eq!(mode, PermissionMode::Default);
assert!(mode.requires_interaction());
assert!(!mode.uses_classifier());
}
#[test]
fn test_permission_mode_bypass() {
let mode = PermissionMode::BypassPermissions;
assert!(mode.allows_write());
assert!(!mode.requires_interaction());
}
#[test]
fn test_permission_mode_plan() {
let mode = PermissionMode::Plan;
assert!(!mode.allows_write());
assert!(mode.requires_interaction());
}
#[test]
fn test_permission_mode_auto() {
let mode = PermissionMode::Auto;
assert!(!mode.requires_interaction());
assert!(mode.uses_classifier());
}
#[test]
fn test_rule_source_ordering() {
assert!(RuleSource::Session > RuleSource::CliArg);
assert!(RuleSource::CliArg > RuleSource::UserSettings);
assert!(RuleSource::UserSettings > RuleSource::ProjectSettings);
assert!(RuleSource::ProjectSettings > RuleSource::LocalSettings);
assert!(RuleSource::LocalSettings > RuleSource::Default);
}
#[test]
fn test_rule_matcher_tool() {
let matcher = RuleMatcher::Tool {
name: "Bash".to_string(),
};
assert!(matcher.matches("Bash", &[]));
assert!(!matcher.matches("Read", &[]));
}
#[test]
fn test_rule_matcher_pattern() {
let matcher = RuleMatcher::Pattern {
pattern: "Bash".to_string(),
};
assert!(matcher.matches("Bash", &[]));
assert!(matcher.matches("Bash(git:*)", &[]));
assert!(!matcher.matches("BashExtra", &[]));
}
#[test]
fn test_rule_matcher_wildcard() {
let matcher = RuleMatcher::Pattern {
pattern: "*".to_string(),
};
assert!(matcher.matches("Bash", &[]));
assert!(matcher.matches("Read", &[]));
assert!(matcher.matches("Write", &[]));
}
#[test]
fn test_rule_matcher_permission() {
let matcher = RuleMatcher::Permission {
permission: ToolPermission::Execute,
};
assert!(matcher.matches("shell", &[ToolPermission::Execute]));
assert!(!matcher.matches("read", &[ToolPermission::Read]));
}
#[test]
fn test_permission_rule_create() {
let rule = PermissionRule::allow(
RuleMatcher::Tool {
name: "Read".to_string(),
},
RuleSource::UserSettings,
);
assert_eq!(rule.behavior, RuleBehavior::Allow);
assert_eq!(rule.source, RuleSource::UserSettings);
}
#[test]
fn test_rule_registry_add() {
let mut registry = RuleRegistry::new();
registry.add_rule(PermissionRule::deny(
RuleMatcher::All,
"default deny".to_string(),
RuleSource::Default,
));
registry.add_rule(PermissionRule::allow(
RuleMatcher::Tool {
name: "Read".to_string(),
},
RuleSource::UserSettings,
));
assert_eq!(registry.rules[0].source, RuleSource::UserSettings);
assert_eq!(registry.rules[1].source, RuleSource::Default);
}
#[test]
fn test_rule_registry_check() {
let mut registry = RuleRegistry::new();
registry.add_rule(PermissionRule::deny(
RuleMatcher::All,
"default deny".to_string(),
RuleSource::Default,
));
registry.add_rule(PermissionRule::allow(
RuleMatcher::Tool {
name: "Read".to_string(),
},
RuleSource::UserSettings,
));
let result = registry.check("Read", &[]);
assert_eq!(
result,
Some(RuleBehavior::Deny {
reason: "default deny".to_string()
})
);
let result = registry.check("Bash", &[]);
assert!(matches!(result, Some(RuleBehavior::Deny { .. })));
}
#[test]
fn test_rule_registry_allow_without_deny() {
let mut registry = RuleRegistry::new();
registry.add_rule(PermissionRule::allow(
RuleMatcher::Tool {
name: "Read".to_string(),
},
RuleSource::UserSettings,
));
let result = registry.check("Read", &[]);
assert_eq!(result, Some(RuleBehavior::Allow));
let result = registry.check("Bash", &[]);
assert_eq!(result, None);
}
#[test]
fn test_rule_registry_deny_first_ordering() {
let mut registry = RuleRegistry::new();
registry.add_rule(PermissionRule::allow(
RuleMatcher::Pattern {
pattern: "Bash".to_string(),
},
RuleSource::UserSettings,
));
registry.add_rule(PermissionRule::deny(
RuleMatcher::Pattern {
pattern: "Bash(rm:*)".to_string(),
},
"dangerous".to_string(),
RuleSource::Default,
));
let result = registry.check("Bash(rm:rf)", &[]);
assert!(matches!(result, Some(RuleBehavior::Deny { .. })));
let result = registry.check("Bash(ls)", &[]);
assert_eq!(result, Some(RuleBehavior::Allow));
}
#[test]
fn test_rule_registry_ask_between_deny_and_allow() {
let mut registry = RuleRegistry::new();
registry.add_rule(PermissionRule::allow(
RuleMatcher::Pattern {
pattern: "Bash".to_string(),
},
RuleSource::UserSettings,
));
registry.add_rule(PermissionRule::ask(
RuleMatcher::Pattern {
pattern: "Bash(rm:*)".to_string(),
},
vec!["Confirm".to_string()],
RuleSource::Default,
));
let result = registry.check("Bash(rm:rf)", &[]);
assert!(matches!(result, Some(RuleBehavior::Ask { .. })));
let result = registry.check("Bash(git:status)", &[]);
assert_eq!(result, Some(RuleBehavior::Allow));
}
#[test]
fn test_permission_decision_is_allowed() {
assert!(PermissionDecision::Allow.is_allowed());
assert!(
!PermissionDecision::Deny {
reason: "test".to_string()
}
.is_allowed()
);
}
#[test]
fn test_permission_decision_requires_approval() {
assert!(PermissionDecision::RequireApproval.requires_approval());
assert!(
PermissionDecision::Ask {
suggestions: vec!["yes".to_string()]
}
.requires_approval()
);
assert!(!PermissionDecision::Allow.requires_approval());
}
#[tokio::test]
async fn test_empty_permissions_allowed() {
let policy = DefaultPermissionPolicy::new();
let decision = policy.check("tool", &[]).await;
assert!(matches!(decision, PermissionDecision::Allow));
}
#[tokio::test]
async fn test_granted_permission() {
let policy = DefaultPermissionPolicy::new().grant(ToolPermission::Read);
let decision = policy.check("tool", &[ToolPermission::Read]).await;
assert!(matches!(decision, PermissionDecision::Allow));
}
#[tokio::test]
async fn test_execute_requires_approval() {
let policy = DefaultPermissionPolicy::new();
let decision = policy.check("tool", &[ToolPermission::Execute]).await;
assert!(matches!(decision, PermissionDecision::RequireApproval));
}
#[tokio::test]
async fn test_ungranted_denied() {
let policy = DefaultPermissionPolicy::new();
let decision = policy.check("tool", &[ToolPermission::Write]).await;
assert!(matches!(decision, PermissionDecision::Deny { .. }));
}
#[tokio::test]
async fn test_grant_all() {
let policy = DefaultPermissionPolicy::new().grant_all();
let decision = policy
.check(
"tool",
&[
ToolPermission::Read,
ToolPermission::Write,
ToolPermission::Execute,
ToolPermission::Sensitive,
],
)
.await;
assert!(matches!(decision, PermissionDecision::Allow));
}
}