use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::hook::HookPermission;
use crate::types::ToolCallId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionBehavior {
Allow,
Deny,
Ask,
}
impl PermissionBehavior {
pub fn is_allow(&self) -> bool {
matches!(self, Self::Allow)
}
pub fn is_deny(&self) -> bool {
matches!(self, Self::Deny)
}
pub fn is_ask(&self) -> bool {
matches!(self, Self::Ask)
}
pub fn strictness(&self) -> u8 {
match self {
Self::Allow => 0,
Self::Ask => 1,
Self::Deny => 2,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionMode {
#[default]
Default,
Plan,
AcceptEdits,
Bypass,
NonInteractive,
}
impl PermissionMode {
pub fn is_bypass(&self) -> bool {
matches!(self, Self::Bypass)
}
pub fn is_non_interactive(&self) -> bool {
matches!(self, Self::NonInteractive)
}
pub fn is_plan(&self) -> bool {
matches!(self, Self::Plan)
}
pub fn transform_ask(&self) -> PermissionBehavior {
match self {
Self::Bypass => PermissionBehavior::Allow,
Self::NonInteractive => PermissionBehavior::Deny,
_ => PermissionBehavior::Ask,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleSource {
Policy,
User,
Project,
Session,
Default,
}
impl RuleSource {
pub fn priority(&self) -> u8 {
match self {
Self::Policy => 100,
Self::User => 80,
Self::Project => 60,
Self::Session => 40,
Self::Default => 0,
}
}
pub fn is_immutable(&self) -> bool {
matches!(self, Self::Policy)
}
}
impl PartialOrd for RuleSource {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for RuleSource {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.priority().cmp(&other.priority())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionRule {
pub source: RuleSource,
pub behavior: PermissionBehavior,
pub permission: String,
pub pattern: String,
}
impl PermissionRule {
pub fn new(
source: RuleSource,
behavior: PermissionBehavior,
permission: impl Into<String>,
pattern: impl Into<String>,
) -> Self {
Self {
source,
behavior,
permission: permission.into(),
pattern: pattern.into(),
}
}
pub fn allow(source: RuleSource, permission: impl Into<String>, pattern: impl Into<String>) -> Self {
Self::new(source, PermissionBehavior::Allow, permission, pattern)
}
pub fn deny(source: RuleSource, permission: impl Into<String>, pattern: impl Into<String>) -> Self {
Self::new(source, PermissionBehavior::Deny, permission, pattern)
}
pub fn ask(source: RuleSource, permission: impl Into<String>, pattern: impl Into<String>) -> Self {
Self::new(source, PermissionBehavior::Ask, permission, pattern)
}
pub fn matches(&self, permission_key: &str, content: &str) -> bool {
wildcard_match(permission_key, &self.permission)
&& wildcard_match(content, &self.pattern)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Ruleset {
rules: Vec<PermissionRule>,
}
impl Ruleset {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add(&mut self, rule: PermissionRule) {
self.rules.push(rule);
}
pub fn extend(&mut self, rules: impl IntoIterator<Item = PermissionRule>) {
self.rules.extend(rules);
}
pub fn remove_source(&mut self, source: RuleSource) {
self.rules.retain(|r| r.source != source);
}
pub fn len(&self) -> usize {
self.rules.len()
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn rules(&self) -> &[PermissionRule] {
&self.rules
}
pub fn evaluate(&self, permission_key: &str, content: &str) -> Option<PermissionBehavior> {
for source in &[
RuleSource::Policy,
RuleSource::User,
RuleSource::Project,
RuleSource::Session,
RuleSource::Default,
] {
let last_match = self
.rules
.iter()
.filter(|r| r.source == *source)
.filter(|r| r.matches(permission_key, content))
.last();
if let Some(rule) = last_match {
return Some(rule.behavior);
}
}
None
}
pub fn has_deny_for(&self, permission_key: &str) -> bool {
self.rules.iter().any(|r| {
r.behavior.is_deny() && wildcard_match(permission_key, &r.permission)
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRequest {
pub permission: String,
pub patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub call_id: Option<ToolCallId>,
#[serde(default)]
pub metadata: serde_json::Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub always_allow_patterns: Vec<String>,
}
impl PermissionRequest {
pub fn new(permission: impl Into<String>, pattern: impl Into<String>) -> Self {
Self {
permission: permission.into(),
patterns: vec![pattern.into()],
tool_name: None,
call_id: None,
metadata: serde_json::Value::Null,
always_allow_patterns: Vec::new(),
}
}
pub fn with_patterns(
permission: impl Into<String>,
patterns: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
permission: permission.into(),
patterns: patterns.into_iter().map(Into::into).collect(),
tool_name: None,
call_id: None,
metadata: serde_json::Value::Null,
always_allow_patterns: Vec::new(),
}
}
pub fn with_tool_name(mut self, name: impl Into<String>) -> Self {
self.tool_name = Some(name.into());
self
}
pub fn with_call_id(mut self, id: ToolCallId) -> Self {
self.call_id = Some(id);
self
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = metadata;
self
}
pub fn with_always_allow(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.always_allow_patterns = patterns.into_iter().map(Into::into).collect();
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "behavior", rename_all = "snake_case")]
pub enum PermissionDecision {
Allow {
reason: PermissionReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
updated_input: Option<serde_json::Value>,
},
Deny {
reason: PermissionReason,
message: String,
},
Ask {
message: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
suggestions: Vec<PermissionUpdate>,
},
}
impl PermissionDecision {
pub fn allow(reason: PermissionReason) -> Self {
Self::Allow {
reason,
updated_input: None,
}
}
pub fn allow_with_input(reason: PermissionReason, updated_input: serde_json::Value) -> Self {
Self::Allow {
reason,
updated_input: Some(updated_input),
}
}
pub fn deny(reason: PermissionReason, message: impl Into<String>) -> Self {
Self::Deny {
reason,
message: message.into(),
}
}
pub fn ask(message: impl Into<String>) -> Self {
Self::Ask {
message: message.into(),
suggestions: Vec::new(),
}
}
pub fn ask_with_suggestions(
message: impl Into<String>,
suggestions: Vec<PermissionUpdate>,
) -> Self {
Self::Ask {
message: message.into(),
suggestions,
}
}
pub fn is_allow(&self) -> bool {
matches!(self, Self::Allow { .. })
}
pub fn is_deny(&self) -> bool {
matches!(self, Self::Deny { .. })
}
pub fn is_ask(&self) -> bool {
matches!(self, Self::Ask { .. })
}
pub fn behavior(&self) -> PermissionBehavior {
match self {
Self::Allow { .. } => PermissionBehavior::Allow,
Self::Deny { .. } => PermissionBehavior::Deny,
Self::Ask { .. } => PermissionBehavior::Ask,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PermissionReason {
Rule { source: RuleSource },
Mode,
ToolCheck,
Hook,
SessionCache,
SafetyCheck,
UserDecision,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PermissionResult {
Allow,
Deny { message: String },
Ask { message: String },
Passthrough,
}
impl PermissionResult {
pub fn deny(message: impl Into<String>) -> Self {
Self::Deny {
message: message.into(),
}
}
pub fn ask(message: impl Into<String>) -> Self {
Self::Ask {
message: message.into(),
}
}
pub fn is_allow(&self) -> bool {
matches!(self, Self::Allow)
}
pub fn is_deny(&self) -> bool {
matches!(self, Self::Deny { .. })
}
pub fn is_ask(&self) -> bool {
matches!(self, Self::Ask { .. })
}
pub fn is_passthrough(&self) -> bool {
matches!(self, Self::Passthrough)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PermissionReply {
AllowOnce,
AllowAlways,
DenyOnce,
DenyAlways,
DenyWithFeedback { feedback: String },
}
impl PermissionReply {
pub fn is_allow(&self) -> bool {
matches!(self, Self::AllowOnce | Self::AllowAlways)
}
pub fn is_deny(&self) -> bool {
matches!(self, Self::DenyOnce | Self::DenyAlways | Self::DenyWithFeedback { .. })
}
pub fn is_always(&self) -> bool {
matches!(self, Self::AllowAlways | Self::DenyAlways)
}
pub fn has_feedback(&self) -> bool {
matches!(self, Self::DenyWithFeedback { .. })
}
pub fn feedback(&self) -> Option<&str> {
match self {
Self::DenyWithFeedback { feedback } => Some(feedback.as_str()),
_ => None,
}
}
pub fn behavior(&self) -> PermissionBehavior {
if self.is_allow() {
PermissionBehavior::Allow
} else {
PermissionBehavior::Deny
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum PermissionUpdate {
AddRule {
destination: RuleSource,
rule: PermissionRule,
},
RemoveRules {
destination: RuleSource,
permission: String,
pattern: String,
},
SetMode {
mode: PermissionMode,
},
}
impl PermissionUpdate {
pub fn add_rule(destination: RuleSource, rule: PermissionRule) -> Self {
Self::AddRule { destination, rule }
}
pub fn remove_rules(
destination: RuleSource,
permission: impl Into<String>,
pattern: impl Into<String>,
) -> Self {
Self::RemoveRules {
destination,
permission: permission.into(),
pattern: pattern.into(),
}
}
pub fn set_mode(mode: PermissionMode) -> Self {
Self::SetMode { mode }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionPermissionCache {
rules: Vec<CacheEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CacheEntry {
permission: String,
pattern: String,
behavior: PermissionBehavior,
}
impl SessionPermissionCache {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn allow_always(&mut self, permission: impl Into<String>, pattern: impl Into<String>) {
self.rules.push(CacheEntry {
permission: permission.into(),
pattern: pattern.into(),
behavior: PermissionBehavior::Allow,
});
}
pub fn deny_always(&mut self, permission: impl Into<String>, pattern: impl Into<String>) {
self.rules.push(CacheEntry {
permission: permission.into(),
pattern: pattern.into(),
behavior: PermissionBehavior::Deny,
});
}
pub fn check(&self, permission_key: &str, content: &str) -> Option<PermissionBehavior> {
self.rules
.iter()
.filter(|e| {
wildcard_match(permission_key, &e.permission)
&& wildcard_match(content, &e.pattern)
})
.last()
.map(|e| e.behavior)
}
pub fn len(&self) -> usize {
self.rules.len()
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn clear(&mut self) {
self.rules.clear();
}
pub fn to_ruleset(&self) -> Ruleset {
let mut ruleset = Ruleset::new();
for entry in &self.rules {
ruleset.add(PermissionRule::new(
RuleSource::Session,
entry.behavior,
&entry.permission,
&entry.pattern,
));
}
ruleset
}
}
#[derive(Debug, Clone)]
pub struct DenialTracker {
consecutive_denials: u32,
threshold: u32,
total_denials: u32,
}
impl DenialTracker {
pub fn new(threshold: u32) -> Self {
Self {
consecutive_denials: 0,
threshold,
total_denials: 0,
}
}
pub fn record_denial(&mut self) {
self.consecutive_denials += 1;
self.total_denials += 1;
}
pub fn record_allow(&mut self) {
self.consecutive_denials = 0;
}
pub fn is_tripped(&self) -> bool {
self.consecutive_denials >= self.threshold
}
pub fn consecutive_count(&self) -> u32 {
self.consecutive_denials
}
pub fn total_count(&self) -> u32 {
self.total_denials
}
pub fn reset(&mut self) {
self.consecutive_denials = 0;
}
}
impl From<HookPermission> for PermissionBehavior {
fn from(hook_perm: HookPermission) -> Self {
match hook_perm {
HookPermission::Allow => Self::Allow,
HookPermission::Deny { .. } => Self::Deny,
HookPermission::Ask { .. } => Self::Ask,
}
}
}
impl From<&HookPermission> for PermissionBehavior {
fn from(hook_perm: &HookPermission) -> Self {
match hook_perm {
HookPermission::Allow => Self::Allow,
HookPermission::Deny { .. } => Self::Deny,
HookPermission::Ask { .. } => Self::Ask,
}
}
}
impl From<PermissionBehavior> for HookPermission {
fn from(behavior: PermissionBehavior) -> Self {
match behavior {
PermissionBehavior::Allow => Self::Allow,
PermissionBehavior::Deny => Self::Deny { reason: None },
PermissionBehavior::Ask => Self::Ask { message: None },
}
}
}
#[derive(Debug, Clone)]
pub struct PermissionCheckInput {
pub request: PermissionRequest,
pub hook_decision: Option<HookPermission>,
pub tool_check: Option<PermissionResult>,
pub mode: PermissionMode,
}
impl PermissionCheckInput {
pub fn new(request: PermissionRequest, mode: PermissionMode) -> Self {
Self {
request,
hook_decision: None,
tool_check: None,
mode,
}
}
pub fn with_hook_decision(mut self, decision: HookPermission) -> Self {
self.hook_decision = Some(decision);
self
}
pub fn with_tool_check(mut self, result: PermissionResult) -> Self {
self.tool_check = Some(result);
self
}
}
#[async_trait]
pub trait PermissionEngine: Send + Sync {
async fn check(&self, input: PermissionCheckInput) -> PermissionDecision;
async fn prompt_user(&self, decision: &PermissionDecision) -> PermissionReply;
async fn apply_reply(
&self,
request: &PermissionRequest,
reply: &PermissionReply,
);
}
pub fn evaluate_permission(
ruleset: &Ruleset,
cache: &SessionPermissionCache,
input: &PermissionCheckInput,
) -> PermissionDecision {
let permission_key = &input.request.permission;
let mut overall_behavior: Option<PermissionBehavior> = None;
for pattern in &input.request.patterns {
let decision = evaluate_single(ruleset, cache, input, permission_key, pattern);
match decision {
PermissionBehavior::Deny => {
let reason = determine_deny_reason(ruleset, input, permission_key, pattern);
return PermissionDecision::deny(
reason,
format!("Permission denied: {}({})", permission_key, pattern),
);
}
PermissionBehavior::Ask => {
if overall_behavior != Some(PermissionBehavior::Deny) {
overall_behavior = Some(PermissionBehavior::Ask);
}
}
PermissionBehavior::Allow => {
if overall_behavior.is_none() {
overall_behavior = Some(PermissionBehavior::Allow);
}
}
}
}
if input.request.patterns.is_empty() {
let decision = evaluate_single(ruleset, cache, input, permission_key, "*");
overall_behavior = Some(decision);
if decision.is_deny() {
let reason = determine_deny_reason(ruleset, input, permission_key, "*");
return PermissionDecision::deny(reason, format!("Permission denied: {}", permission_key));
}
}
match overall_behavior.unwrap_or(PermissionBehavior::Ask) {
PermissionBehavior::Allow => {
PermissionDecision::allow(PermissionReason::Rule { source: RuleSource::Session })
}
PermissionBehavior::Ask => {
PermissionDecision::ask(format!(
"Allow {} to execute {}?",
input.request.tool_name.as_deref().unwrap_or(permission_key),
input.request.patterns.first().map(|s| s.as_str()).unwrap_or("*"),
))
}
PermissionBehavior::Deny => {
unreachable!("Deny should have been returned early in the loop")
}
}
}
fn evaluate_single(
ruleset: &Ruleset,
cache: &SessionPermissionCache,
input: &PermissionCheckInput,
permission_key: &str,
pattern: &str,
) -> PermissionBehavior {
let policy_rules: Vec<_> = ruleset
.rules()
.iter()
.filter(|r| r.source == RuleSource::Policy && r.behavior.is_deny())
.filter(|r| r.matches(permission_key, pattern))
.collect();
if !policy_rules.is_empty() {
return PermissionBehavior::Deny;
}
if let Some(ref tool_result) = input.tool_check {
match tool_result {
PermissionResult::Deny { .. } => return PermissionBehavior::Deny,
PermissionResult::Ask { .. } => { }
_ => {}
}
}
if let Some(ref hook_decision) = input.hook_decision {
if hook_decision.is_deny() {
return PermissionBehavior::Deny;
}
}
if let Some(rule_behavior) = ruleset.evaluate(permission_key, pattern) {
match rule_behavior {
PermissionBehavior::Deny => return PermissionBehavior::Deny,
PermissionBehavior::Allow => {
if let Some(ref hook_decision) = input.hook_decision {
if hook_decision.is_ask() {
return PermissionBehavior::Ask;
}
}
if let Some(PermissionResult::Ask { .. }) = input.tool_check {
return PermissionBehavior::Ask;
}
return PermissionBehavior::Allow;
}
PermissionBehavior::Ask => { }
}
}
if let Some(cached) = cache.check(permission_key, pattern) {
match cached {
PermissionBehavior::Allow => return PermissionBehavior::Allow,
PermissionBehavior::Deny => return PermissionBehavior::Deny,
_ => {}
}
}
if let Some(ref hook_decision) = input.hook_decision {
if hook_decision.is_allow() {
return PermissionBehavior::Allow;
}
}
match input.mode {
PermissionMode::Bypass => return PermissionBehavior::Allow,
PermissionMode::NonInteractive => return PermissionBehavior::Deny,
PermissionMode::Plan => return PermissionBehavior::Deny,
_ => {}
}
PermissionBehavior::Ask
}
fn determine_deny_reason(
ruleset: &Ruleset,
input: &PermissionCheckInput,
permission_key: &str,
pattern: &str,
) -> PermissionReason {
let is_policy = ruleset
.rules()
.iter()
.any(|r| r.source == RuleSource::Policy && r.behavior.is_deny() && r.matches(permission_key, pattern));
if is_policy {
return PermissionReason::Rule { source: RuleSource::Policy };
}
if let Some(ref hook) = input.hook_decision {
if hook.is_deny() {
return PermissionReason::Hook;
}
}
if let Some(PermissionResult::Deny { .. }) = &input.tool_check {
return PermissionReason::ToolCheck;
}
for source in &[RuleSource::User, RuleSource::Project, RuleSource::Session, RuleSource::Default] {
let has_deny = ruleset
.rules()
.iter()
.any(|r| r.source == *source && r.behavior.is_deny() && r.matches(permission_key, pattern));
if has_deny {
return PermissionReason::Rule { source: *source };
}
}
if input.mode.is_non_interactive() || input.mode.is_plan() {
return PermissionReason::Mode;
}
PermissionReason::Rule { source: RuleSource::Default }
}
pub fn wildcard_match(value: &str, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') {
return value == pattern;
}
let parts: Vec<&str> = pattern.split('*').collect();
if !parts[0].is_empty() && !value.starts_with(parts[0]) {
return false;
}
let last = parts[parts.len() - 1];
if !last.is_empty() && !value.ends_with(last) {
return false;
}
let mut pos = parts[0].len();
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
match value[pos..].find(part) {
Some(idx) => pos += idx + part.len(),
None => return false,
}
}
if !last.is_empty() {
let tail_start = value.len() - last.len();
if pos > tail_start {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_behavior_variants() {
assert!(PermissionBehavior::Allow.is_allow());
assert!(PermissionBehavior::Deny.is_deny());
assert!(PermissionBehavior::Ask.is_ask());
}
#[test]
fn test_behavior_strictness_order() {
assert!(PermissionBehavior::Deny.strictness() > PermissionBehavior::Ask.strictness());
assert!(PermissionBehavior::Ask.strictness() > PermissionBehavior::Allow.strictness());
}
#[test]
fn test_behavior_serde_roundtrip() {
for b in [
PermissionBehavior::Allow,
PermissionBehavior::Deny,
PermissionBehavior::Ask,
] {
let json_str = serde_json::to_string(&b).unwrap();
let restored: PermissionBehavior = serde_json::from_str(&json_str).unwrap();
assert_eq!(b, restored);
}
}
#[test]
fn test_mode_default() {
assert_eq!(PermissionMode::default(), PermissionMode::Default);
}
#[test]
fn test_mode_transform_ask() {
assert_eq!(PermissionMode::Default.transform_ask(), PermissionBehavior::Ask);
assert_eq!(PermissionMode::Bypass.transform_ask(), PermissionBehavior::Allow);
assert_eq!(PermissionMode::NonInteractive.transform_ask(), PermissionBehavior::Deny);
assert_eq!(PermissionMode::Plan.transform_ask(), PermissionBehavior::Ask);
assert_eq!(PermissionMode::AcceptEdits.transform_ask(), PermissionBehavior::Ask);
}
#[test]
fn test_mode_serde_roundtrip() {
for mode in [
PermissionMode::Default,
PermissionMode::Plan,
PermissionMode::AcceptEdits,
PermissionMode::Bypass,
PermissionMode::NonInteractive,
] {
let json_str = serde_json::to_string(&mode).unwrap();
let restored: PermissionMode = serde_json::from_str(&json_str).unwrap();
assert_eq!(mode, restored);
}
}
#[test]
fn test_rule_source_priority_order() {
assert!(RuleSource::Policy.priority() > RuleSource::User.priority());
assert!(RuleSource::User.priority() > RuleSource::Project.priority());
assert!(RuleSource::Project.priority() > RuleSource::Session.priority());
assert!(RuleSource::Session.priority() > RuleSource::Default.priority());
}
#[test]
fn test_rule_source_ord() {
let mut sources = vec![
RuleSource::Session,
RuleSource::Policy,
RuleSource::Default,
RuleSource::User,
RuleSource::Project,
];
sources.sort();
assert_eq!(
sources,
vec![
RuleSource::Default,
RuleSource::Session,
RuleSource::Project,
RuleSource::User,
RuleSource::Policy,
]
);
}
#[test]
fn test_rule_source_immutable() {
assert!(RuleSource::Policy.is_immutable());
assert!(!RuleSource::User.is_immutable());
assert!(!RuleSource::Session.is_immutable());
}
#[test]
fn test_rule_new() {
let rule = PermissionRule::new(
RuleSource::User,
PermissionBehavior::Allow,
"read",
"*",
);
assert_eq!(rule.source, RuleSource::User);
assert_eq!(rule.behavior, PermissionBehavior::Allow);
assert_eq!(rule.permission, "read");
assert_eq!(rule.pattern, "*");
}
#[test]
fn test_rule_shortcuts() {
let allow = PermissionRule::allow(RuleSource::User, "read", "*");
assert!(allow.behavior.is_allow());
let deny = PermissionRule::deny(RuleSource::Policy, "bash", "rm *");
assert!(deny.behavior.is_deny());
let ask = PermissionRule::ask(RuleSource::Project, "edit", "*.env");
assert!(ask.behavior.is_ask());
}
#[test]
fn test_rule_matches_exact() {
let rule = PermissionRule::deny(RuleSource::Policy, "bash", "rm -rf /");
assert!(rule.matches("bash", "rm -rf /"));
assert!(!rule.matches("bash", "ls -la"));
assert!(!rule.matches("edit", "rm -rf /"));
}
#[test]
fn test_rule_matches_wildcard() {
let rule = PermissionRule::deny(RuleSource::Policy, "bash", "rm *");
assert!(rule.matches("bash", "rm -rf /"));
assert!(rule.matches("bash", "rm file.txt"));
assert!(!rule.matches("bash", "ls -la"));
}
#[test]
fn test_rule_matches_permission_wildcard() {
let rule = PermissionRule::allow(RuleSource::User, "*", "*");
assert!(rule.matches("bash", "anything"));
assert!(rule.matches("edit", "anything"));
}
#[test]
fn test_rule_serde_roundtrip() {
let rule = PermissionRule::deny(RuleSource::Policy, "bash", "rm *");
let json_str = serde_json::to_string(&rule).unwrap();
let restored: PermissionRule = serde_json::from_str(&json_str).unwrap();
assert_eq!(rule, restored);
}
#[test]
fn test_ruleset_empty() {
let ruleset = Ruleset::new();
assert!(ruleset.is_empty());
assert_eq!(ruleset.evaluate("bash", "ls"), None);
}
#[test]
fn test_ruleset_single_rule() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::allow(RuleSource::User, "read", "*"));
assert_eq!(ruleset.evaluate("read", "file.txt"), Some(PermissionBehavior::Allow));
assert_eq!(ruleset.evaluate("bash", "ls"), None);
}
#[test]
fn test_ruleset_last_match_wins_same_source() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::allow(RuleSource::User, "bash", "*"));
ruleset.add(PermissionRule::deny(RuleSource::User, "bash", "rm *"));
assert_eq!(ruleset.evaluate("bash", "rm -rf /"), Some(PermissionBehavior::Deny));
assert_eq!(ruleset.evaluate("bash", "ls"), Some(PermissionBehavior::Allow));
}
#[test]
fn test_ruleset_higher_source_wins() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::allow(RuleSource::Session, "bash", "*"));
ruleset.add(PermissionRule::deny(RuleSource::Policy, "bash", "rm *"));
assert_eq!(ruleset.evaluate("bash", "rm -rf /"), Some(PermissionBehavior::Deny));
assert_eq!(ruleset.evaluate("bash", "ls"), Some(PermissionBehavior::Allow));
}
#[test]
fn test_ruleset_policy_deny_cannot_be_overridden() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::deny(RuleSource::Policy, "bash", "rm *"));
ruleset.add(PermissionRule::allow(RuleSource::User, "bash", "*"));
ruleset.add(PermissionRule::allow(RuleSource::Session, "bash", "rm *"));
assert_eq!(ruleset.evaluate("bash", "rm file"), Some(PermissionBehavior::Deny));
}
#[test]
fn test_ruleset_remove_source() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::allow(RuleSource::User, "read", "*"));
ruleset.add(PermissionRule::deny(RuleSource::Session, "bash", "*"));
assert_eq!(ruleset.len(), 2);
ruleset.remove_source(RuleSource::Session);
assert_eq!(ruleset.len(), 1);
assert_eq!(ruleset.evaluate("bash", "ls"), None);
}
#[test]
fn test_ruleset_has_deny_for() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::deny(RuleSource::Policy, "bash", "rm *"));
ruleset.add(PermissionRule::allow(RuleSource::User, "read", "*"));
assert!(ruleset.has_deny_for("bash"));
assert!(!ruleset.has_deny_for("read"));
assert!(!ruleset.has_deny_for("edit"));
}
#[test]
fn test_permission_request_builder() {
let req = PermissionRequest::new("bash", "rm -rf /tmp")
.with_tool_name("bash")
.with_call_id(ToolCallId::new("call_1"))
.with_metadata(json!({"cwd": "/home/user"}))
.with_always_allow(["rm *"]);
assert_eq!(req.permission, "bash");
assert_eq!(req.patterns, vec!["rm -rf /tmp"]);
assert_eq!(req.tool_name, Some("bash".into()));
assert_eq!(req.call_id.unwrap().as_str(), "call_1");
assert_eq!(req.always_allow_patterns, vec!["rm *"]);
}
#[test]
fn test_permission_request_multi_pattern() {
let req = PermissionRequest::with_patterns("edit", ["src/main.rs", "src/lib.rs"]);
assert_eq!(req.patterns.len(), 2);
}
#[test]
fn test_decision_allow() {
let d = PermissionDecision::allow(PermissionReason::Rule {
source: RuleSource::User,
});
assert!(d.is_allow());
assert_eq!(d.behavior(), PermissionBehavior::Allow);
}
#[test]
fn test_decision_deny() {
let d = PermissionDecision::deny(
PermissionReason::Rule {
source: RuleSource::Policy,
},
"Blocked by policy",
);
assert!(d.is_deny());
if let PermissionDecision::Deny { message, .. } = &d {
assert_eq!(message, "Blocked by policy");
}
}
#[test]
fn test_decision_ask() {
let d = PermissionDecision::ask("Allow bash to run 'rm -rf /tmp'?");
assert!(d.is_ask());
}
#[test]
fn test_decision_ask_with_suggestions() {
let d = PermissionDecision::ask_with_suggestions(
"Allow?",
vec![PermissionUpdate::add_rule(
RuleSource::Session,
PermissionRule::allow(RuleSource::Session, "bash", "rm *"),
)],
);
if let PermissionDecision::Ask { suggestions, .. } = &d {
assert_eq!(suggestions.len(), 1);
}
}
#[test]
fn test_permission_result_variants() {
assert!(PermissionResult::Allow.is_allow());
assert!(PermissionResult::deny("no").is_deny());
assert!(PermissionResult::ask("confirm?").is_ask());
assert!(PermissionResult::Passthrough.is_passthrough());
}
#[test]
fn test_reply_is_allow() {
assert!(PermissionReply::AllowOnce.is_allow());
assert!(PermissionReply::AllowAlways.is_allow());
assert!(!PermissionReply::DenyOnce.is_allow());
}
#[test]
fn test_reply_is_deny() {
assert!(PermissionReply::DenyOnce.is_deny());
assert!(PermissionReply::DenyAlways.is_deny());
assert!(PermissionReply::DenyWithFeedback {
feedback: "use a safer command".into()
}
.is_deny());
assert!(!PermissionReply::AllowOnce.is_deny());
}
#[test]
fn test_reply_is_always() {
assert!(PermissionReply::AllowAlways.is_always());
assert!(PermissionReply::DenyAlways.is_always());
assert!(!PermissionReply::AllowOnce.is_always());
assert!(!PermissionReply::DenyOnce.is_always());
}
#[test]
fn test_reply_feedback() {
let reply = PermissionReply::DenyWithFeedback {
feedback: "try ls instead".into(),
};
assert!(reply.has_feedback());
assert_eq!(reply.feedback(), Some("try ls instead"));
assert!(!PermissionReply::DenyOnce.has_feedback());
assert_eq!(PermissionReply::DenyOnce.feedback(), None);
}
#[test]
fn test_reply_serde_roundtrip() {
for reply in [
PermissionReply::AllowOnce,
PermissionReply::AllowAlways,
PermissionReply::DenyOnce,
PermissionReply::DenyAlways,
PermissionReply::DenyWithFeedback {
feedback: "feedback".into(),
},
] {
let json_str = serde_json::to_string(&reply).unwrap();
let restored: PermissionReply = serde_json::from_str(&json_str).unwrap();
assert_eq!(reply, restored);
}
}
#[test]
fn test_cache_empty() {
let cache = SessionPermissionCache::new();
assert!(cache.is_empty());
assert_eq!(cache.check("bash", "ls"), None);
}
#[test]
fn test_cache_allow_always() {
let mut cache = SessionPermissionCache::new();
cache.allow_always("bash", "git *");
assert_eq!(cache.check("bash", "git pull"), Some(PermissionBehavior::Allow));
assert_eq!(cache.check("bash", "git push"), Some(PermissionBehavior::Allow));
assert_eq!(cache.check("bash", "rm -rf /"), None);
}
#[test]
fn test_cache_deny_always() {
let mut cache = SessionPermissionCache::new();
cache.deny_always("bash", "rm *");
assert_eq!(cache.check("bash", "rm file.txt"), Some(PermissionBehavior::Deny));
assert_eq!(cache.check("bash", "ls"), None);
}
#[test]
fn test_cache_last_match_wins() {
let mut cache = SessionPermissionCache::new();
cache.allow_always("bash", "*");
cache.deny_always("bash", "rm *");
assert_eq!(cache.check("bash", "rm file"), Some(PermissionBehavior::Deny));
assert_eq!(cache.check("bash", "ls"), Some(PermissionBehavior::Allow));
}
#[test]
fn test_cache_clear() {
let mut cache = SessionPermissionCache::new();
cache.allow_always("bash", "*");
assert!(!cache.is_empty());
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.check("bash", "ls"), None);
}
#[test]
fn test_cache_to_ruleset() {
let mut cache = SessionPermissionCache::new();
cache.allow_always("bash", "git *");
cache.deny_always("bash", "rm *");
let ruleset = cache.to_ruleset();
assert_eq!(ruleset.len(), 2);
assert_eq!(ruleset.evaluate("bash", "git pull"), Some(PermissionBehavior::Allow));
assert_eq!(ruleset.evaluate("bash", "rm file"), Some(PermissionBehavior::Deny));
}
#[test]
fn test_denial_tracker_basic() {
let tracker = DenialTracker::new(3);
assert!(!tracker.is_tripped());
assert_eq!(tracker.consecutive_count(), 0);
assert_eq!(tracker.total_count(), 0);
}
#[test]
fn test_denial_tracker_trips_at_threshold() {
let mut tracker = DenialTracker::new(3);
tracker.record_denial();
tracker.record_denial();
assert!(!tracker.is_tripped());
tracker.record_denial();
assert!(tracker.is_tripped());
assert_eq!(tracker.consecutive_count(), 3);
assert_eq!(tracker.total_count(), 3);
}
#[test]
fn test_denial_tracker_allow_resets() {
let mut tracker = DenialTracker::new(3);
tracker.record_denial();
tracker.record_denial();
tracker.record_allow();
assert!(!tracker.is_tripped());
assert_eq!(tracker.consecutive_count(), 0);
assert_eq!(tracker.total_count(), 2); }
#[test]
fn test_denial_tracker_reset() {
let mut tracker = DenialTracker::new(2);
tracker.record_denial();
tracker.record_denial();
assert!(tracker.is_tripped());
tracker.reset();
assert!(!tracker.is_tripped());
}
#[test]
fn test_wildcard_exact() {
assert!(wildcard_match("hello", "hello"));
assert!(!wildcard_match("hello", "world"));
}
#[test]
fn test_wildcard_star_all() {
assert!(wildcard_match("anything", "*"));
assert!(wildcard_match("", "*"));
}
#[test]
fn test_wildcard_prefix() {
assert!(wildcard_match("hello world", "hello *"));
assert!(wildcard_match("hello", "hello*"));
assert!(!wildcard_match("world hello", "hello *"));
}
#[test]
fn test_wildcard_suffix() {
assert!(wildcard_match("file.rs", "*.rs"));
assert!(wildcard_match("test.rs", "*.rs"));
assert!(!wildcard_match("file.ts", "*.rs"));
}
#[test]
fn test_wildcard_middle() {
assert!(wildcard_match("/etc/shadow", "/etc/*"));
assert!(wildcard_match("/home/user/file.txt", "/home/*/file.txt"));
assert!(!wildcard_match("/tmp/file.txt", "/home/*/file.txt"));
}
#[test]
fn test_wildcard_multiple_stars() {
assert!(wildcard_match("/home/user/project/src/main.rs", "/home/*/project/*.rs"));
assert!(!wildcard_match("/home/user/other/src/main.rs", "/home/*/project/*.rs"));
}
#[test]
fn test_wildcard_empty_pattern_segment() {
assert!(wildcard_match("anything", "**"));
}
#[test]
fn test_permission_update_add_rule() {
let update = PermissionUpdate::add_rule(
RuleSource::Session,
PermissionRule::allow(RuleSource::Session, "bash", "git *"),
);
assert!(matches!(update, PermissionUpdate::AddRule { .. }));
}
#[test]
fn test_permission_update_serde_roundtrip() {
let update = PermissionUpdate::set_mode(PermissionMode::Bypass);
let json_str = serde_json::to_string(&update).unwrap();
let restored: PermissionUpdate = serde_json::from_str(&json_str).unwrap();
assert_eq!(update, restored);
}
#[test]
fn test_pipeline_policy_deny_beats_everything() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::deny(RuleSource::Policy, "bash", "rm *"));
let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("bash", "rm -rf /"),
PermissionMode::Bypass,
)
.with_hook_decision(HookPermission::Allow);
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_deny());
if let PermissionDecision::Deny { reason, .. } = &decision {
assert!(matches!(reason, PermissionReason::Rule { source: RuleSource::Policy }));
}
}
#[test]
fn test_pipeline_hook_deny_beats_rule_allow() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::allow(RuleSource::User, "bash", "*"));
let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("bash", "curl evil.com"),
PermissionMode::Default,
)
.with_hook_decision(HookPermission::Deny {
reason: Some("Suspicious URL detected".into()),
});
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_deny());
if let PermissionDecision::Deny { reason, .. } = &decision {
assert!(matches!(reason, PermissionReason::Hook));
}
}
#[test]
fn test_pipeline_hook_allow_cannot_bypass_rule_deny() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::deny(RuleSource::User, "bash", "rm *"));
let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("bash", "rm file.txt"),
PermissionMode::Default,
)
.with_hook_decision(HookPermission::Allow);
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_deny());
}
#[test]
fn test_pipeline_tool_deny_is_immediate() {
let ruleset = Ruleset::new(); let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("edit", "/etc/shadow"),
PermissionMode::Bypass, )
.with_tool_check(PermissionResult::deny("Cannot edit system files"));
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_deny());
if let PermissionDecision::Deny { reason, .. } = &decision {
assert!(matches!(reason, PermissionReason::ToolCheck));
}
}
#[test]
fn test_pipeline_hook_ask_overrides_rule_allow() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::allow(RuleSource::User, "bash", "*"));
let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("bash", "git push --force"),
PermissionMode::Default,
)
.with_hook_decision(HookPermission::Ask {
message: Some("Force push detected, confirm?".into()),
});
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_ask());
}
#[test]
fn test_pipeline_cache_allows_after_user_always() {
let ruleset = Ruleset::new(); let mut cache = SessionPermissionCache::new();
cache.allow_always("bash", "git *");
let input = PermissionCheckInput::new(
PermissionRequest::new("bash", "git pull"),
PermissionMode::Default,
);
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_allow());
}
#[test]
fn test_pipeline_bypass_mode_auto_allows() {
let ruleset = Ruleset::new();
let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("bash", "anything"),
PermissionMode::Bypass,
);
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_allow());
}
#[test]
fn test_pipeline_non_interactive_auto_denies() {
let ruleset = Ruleset::new();
let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("bash", "anything"),
PermissionMode::NonInteractive,
);
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_deny());
if let PermissionDecision::Deny { reason, .. } = &decision {
assert!(matches!(reason, PermissionReason::Mode));
}
}
#[test]
fn test_pipeline_no_rules_no_hook_defaults_to_ask() {
let ruleset = Ruleset::new();
let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("bash", "ls -la"),
PermissionMode::Default,
);
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_ask());
}
#[test]
fn test_pipeline_hook_allow_works_when_no_rule_conflicts() {
let ruleset = Ruleset::new(); let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("read", "file.txt"),
PermissionMode::Default,
)
.with_hook_decision(HookPermission::Allow);
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_allow());
}
#[test]
fn test_pipeline_tool_ask_overrides_rule_allow() {
let mut ruleset = Ruleset::new();
ruleset.add(PermissionRule::allow(RuleSource::User, "edit", "*"));
let cache = SessionPermissionCache::new();
let input = PermissionCheckInput::new(
PermissionRequest::new("edit", ".git/config"),
PermissionMode::Default,
)
.with_tool_check(PermissionResult::ask("Editing .git/ files requires confirmation"));
let decision = evaluate_permission(&ruleset, &cache, &input);
assert!(decision.is_ask());
}
#[test]
fn test_hook_permission_to_behavior() {
assert_eq!(
PermissionBehavior::from(HookPermission::Allow),
PermissionBehavior::Allow
);
assert_eq!(
PermissionBehavior::from(HookPermission::Deny { reason: None }),
PermissionBehavior::Deny
);
assert_eq!(
PermissionBehavior::from(HookPermission::Ask { message: None }),
PermissionBehavior::Ask
);
}
#[test]
fn test_behavior_to_hook_permission() {
let perm: HookPermission = PermissionBehavior::Allow.into();
assert!(perm.is_allow());
let perm: HookPermission = PermissionBehavior::Deny.into();
assert!(perm.is_deny());
}
}