use std::collections::BTreeMap; use serde_json::Value; use crate::config::RuntimePermissionRuleConfig; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum PermissionMode { ReadOnly, WorkspaceWrite, DangerFullAccess, Prompt, Allow, } impl PermissionMode { #[must_use] pub fn as_str(self) -> &'static str { match self { Self::ReadOnly => "read-only", Self::WorkspaceWrite => "workspace-write", Self::DangerFullAccess => "danger-full-access", Self::Prompt => "prompt", Self::Allow => "allow", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionOverride { Allow, Deny, Ask, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct PermissionContext { override_decision: Option, override_reason: Option, } impl PermissionContext { #[must_use] pub fn new( override_decision: Option, override_reason: Option, ) -> Self { Self { override_decision, override_reason, } } #[must_use] pub fn override_decision(&self) -> Option { self.override_decision } #[must_use] pub fn override_reason(&self) -> Option<&str> { self.override_reason.as_deref() } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PermissionRequest { pub tool_name: String, pub input: String, pub current_mode: PermissionMode, pub required_mode: PermissionMode, pub reason: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PermissionPromptDecision { Allow, Deny { reason: String }, } pub trait PermissionPrompter { fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision; } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PermissionOutcome { Allow, Deny { reason: String }, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PermissionPolicy { active_mode: PermissionMode, tool_requirements: BTreeMap, allow_rules: Vec, deny_rules: Vec, ask_rules: Vec, } impl PermissionPolicy { #[must_use] pub fn new(active_mode: PermissionMode) -> Self { Self { active_mode, tool_requirements: BTreeMap::new(), allow_rules: Vec::new(), deny_rules: Vec::new(), ask_rules: Vec::new(), } } #[must_use] pub fn with_tool_requirement( mut self, tool_name: impl Into, required_mode: PermissionMode, ) -> Self { self.tool_requirements .insert(tool_name.into(), required_mode); self } #[must_use] pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self { self.allow_rules = config .allow() .iter() .map(|rule| PermissionRule::parse(rule)) .collect(); self.deny_rules = config .deny() .iter() .map(|rule| PermissionRule::parse(rule)) .collect(); self.ask_rules = config .ask() .iter() .map(|rule| PermissionRule::parse(rule)) .collect(); self } #[must_use] pub fn active_mode(&self) -> PermissionMode { self.active_mode } #[must_use] pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode { self.tool_requirements .get(tool_name) .copied() .unwrap_or(PermissionMode::DangerFullAccess) } #[must_use] pub fn authorize( &self, tool_name: &str, input: &str, prompter: Option<&mut dyn PermissionPrompter>, ) -> PermissionOutcome { self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter) } #[must_use] #[allow(clippy::too_many_lines)] pub fn authorize_with_context( &self, tool_name: &str, input: &str, context: &PermissionContext, prompter: Option<&mut dyn PermissionPrompter>, ) -> PermissionOutcome { if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) { return PermissionOutcome::Deny { reason: format!( "Permission to use {tool_name} has been denied by rule '{}'", rule.raw ), }; } let current_mode = self.active_mode(); let required_mode = self.required_mode_for(tool_name); let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input); let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input); match context.override_decision() { Some(PermissionOverride::Deny) => { return PermissionOutcome::Deny { reason: context.override_reason().map_or_else( || format!("tool '{tool_name}' denied by hook"), ToOwned::to_owned, ), }; } Some(PermissionOverride::Ask) => { let reason = context.override_reason().map_or_else( || format!("tool '{tool_name}' requires approval due to hook guidance"), ToOwned::to_owned, ); return Self::prompt_or_deny( tool_name, input, current_mode, required_mode, Some(reason), prompter, ); } Some(PermissionOverride::Allow) => { if let Some(rule) = ask_rule { let reason = format!( "tool '{tool_name}' requires approval due to ask rule '{}'", rule.raw ); return Self::prompt_or_deny( tool_name, input, current_mode, required_mode, Some(reason), prompter, ); } if allow_rule.is_some() || current_mode == PermissionMode::Allow || current_mode >= required_mode { return PermissionOutcome::Allow; } } None => {} } if let Some(rule) = ask_rule { let reason = format!( "tool '{tool_name}' requires approval due to ask rule '{}'", rule.raw ); return Self::prompt_or_deny( tool_name, input, current_mode, required_mode, Some(reason), prompter, ); } if allow_rule.is_some() || current_mode == PermissionMode::Allow || current_mode >= required_mode { return PermissionOutcome::Allow; } if current_mode == PermissionMode::Prompt || (current_mode == PermissionMode::WorkspaceWrite && required_mode == PermissionMode::DangerFullAccess) { let reason = Some(format!( "tool '{tool_name}' requires approval to escalate from {} to {}", current_mode.as_str(), required_mode.as_str() )); return Self::prompt_or_deny( tool_name, input, current_mode, required_mode, reason, prompter, ); } PermissionOutcome::Deny { reason: format!( "tool '{tool_name}' requires {} permission; current mode is {}", required_mode.as_str(), current_mode.as_str() ), } } fn prompt_or_deny( tool_name: &str, input: &str, current_mode: PermissionMode, required_mode: PermissionMode, reason: Option, mut prompter: Option<&mut dyn PermissionPrompter>, ) -> PermissionOutcome { let request = PermissionRequest { tool_name: tool_name.to_string(), input: input.to_string(), current_mode, required_mode, reason: reason.clone(), }; match prompter.as_mut() { Some(prompter) => match prompter.decide(&request) { PermissionPromptDecision::Allow => PermissionOutcome::Allow, PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason }, }, None => PermissionOutcome::Deny { reason: reason.unwrap_or_else(|| { format!( "tool '{tool_name}' requires approval to run while mode is {}", current_mode.as_str() ) }), }, } } fn find_matching_rule<'a>( rules: &'a [PermissionRule], tool_name: &str, input: &str, ) -> Option<&'a PermissionRule> { rules.iter().find(|rule| rule.matches(tool_name, input)) } } #[derive(Debug, Clone, PartialEq, Eq)] struct PermissionRule { raw: String, tool_name: String, matcher: PermissionRuleMatcher, } #[derive(Debug, Clone, PartialEq, Eq)] enum PermissionRuleMatcher { Any, Exact(String), Prefix(String), } impl PermissionRule { fn parse(raw: &str) -> Self { let trimmed = raw.trim(); let open = find_first_unescaped(trimmed, '('); let close = find_last_unescaped(trimmed, ')'); if let (Some(open), Some(close)) = (open, close) { if close == trimmed.len() - 1 && open < close { let tool_name = trimmed[..open].trim(); let content = &trimmed[open + 1..close]; if !tool_name.is_empty() { let matcher = parse_rule_matcher(content); return Self { raw: trimmed.to_string(), tool_name: tool_name.to_string(), matcher, }; } } } Self { raw: trimmed.to_string(), tool_name: trimmed.to_string(), matcher: PermissionRuleMatcher::Any, } } fn matches(&self, tool_name: &str, input: &str) -> bool { if self.tool_name != tool_name { return false; } match &self.matcher { PermissionRuleMatcher::Any => true, PermissionRuleMatcher::Exact(expected) => { extract_permission_subject(input).is_some_and(|candidate| candidate == *expected) } PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input) .is_some_and(|candidate| candidate.starts_with(prefix)), } } } fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher { let unescaped = unescape_rule_content(content.trim()); if unescaped.is_empty() || unescaped == "*" { PermissionRuleMatcher::Any } else if let Some(prefix) = unescaped.strip_suffix(":*") { PermissionRuleMatcher::Prefix(prefix.to_string()) } else { PermissionRuleMatcher::Exact(unescaped) } } fn unescape_rule_content(content: &str) -> String { content .replace(r"\(", "(") .replace(r"\)", ")") .replace(r"\\", r"\") } fn find_first_unescaped(value: &str, needle: char) -> Option { let mut escaped = false; for (idx, ch) in value.char_indices() { if ch == '\\' { escaped = !escaped; continue; } if ch == needle && !escaped { return Some(idx); } escaped = false; } None } fn find_last_unescaped(value: &str, needle: char) -> Option { let chars = value.char_indices().collect::>(); for (pos, (idx, ch)) in chars.iter().enumerate().rev() { if *ch != needle { continue; } let mut backslashes = 0; for (_, prev) in chars[..pos].iter().rev() { if *prev == '\\' { backslashes += 1; } else { break; } } if backslashes % 2 == 0 { return Some(*idx); } } None } fn extract_permission_subject(input: &str) -> Option { let parsed = serde_json::from_str::(input).ok(); if let Some(Value::Object(object)) = parsed { for key in [ "command", "path", "file_path", "filePath", "notebook_path", "notebookPath", "url", "pattern", "code", "message", ] { if let Some(value) = object.get(key).and_then(Value::as_str) { return Some(value.to_string()); } } } (!input.trim().is_empty()).then(|| input.to_string()) } #[cfg(test)] mod tests { use super::{ PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy, PermissionPromptDecision, PermissionPrompter, PermissionRequest, }; use crate::config::RuntimePermissionRuleConfig; struct RecordingPrompter { seen: Vec, allow: bool, } impl PermissionPrompter for RecordingPrompter { fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision { self.seen.push(request.clone()); if self.allow { PermissionPromptDecision::Allow } else { PermissionPromptDecision::Deny { reason: "not now".to_string(), } } } } #[test] fn allows_tools_when_active_mode_meets_requirement() { let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) .with_tool_requirement("read_file", PermissionMode::ReadOnly) .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite); assert_eq!( policy.authorize("read_file", "{}", None), PermissionOutcome::Allow ); assert_eq!( policy.authorize("write_file", "{}", None), PermissionOutcome::Allow ); } #[test] fn denies_read_only_escalations_without_prompt() { let policy = PermissionPolicy::new(PermissionMode::ReadOnly) .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite) .with_tool_requirement("bash", PermissionMode::DangerFullAccess); assert!(matches!( policy.authorize("write_file", "{}", None), PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission") )); assert!(matches!( policy.authorize("bash", "{}", None), PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission") )); } #[test] fn prompts_for_workspace_write_to_danger_full_access_escalation() { let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) .with_tool_requirement("bash", PermissionMode::DangerFullAccess); let mut prompter = RecordingPrompter { seen: Vec::new(), allow: true, }; let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter)); assert_eq!(outcome, PermissionOutcome::Allow); assert_eq!(prompter.seen.len(), 1); assert_eq!(prompter.seen[0].tool_name, "bash"); assert_eq!( prompter.seen[0].current_mode, PermissionMode::WorkspaceWrite ); assert_eq!( prompter.seen[0].required_mode, PermissionMode::DangerFullAccess ); } #[test] fn honors_prompt_rejection_reason() { let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) .with_tool_requirement("bash", PermissionMode::DangerFullAccess); let mut prompter = RecordingPrompter { seen: Vec::new(), allow: false, }; assert!(matches!( policy.authorize("bash", "echo hi", Some(&mut prompter)), PermissionOutcome::Deny { reason } if reason == "not now" )); } #[test] fn applies_rule_based_denials_and_allows() { let rules = RuntimePermissionRuleConfig::new( vec!["bash(git:*)".to_string()], vec!["bash(rm -rf:*)".to_string()], Vec::new(), ); let policy = PermissionPolicy::new(PermissionMode::ReadOnly) .with_tool_requirement("bash", PermissionMode::DangerFullAccess) .with_permission_rules(&rules); assert_eq!( policy.authorize("bash", r#"{"command":"git status"}"#, None), PermissionOutcome::Allow ); assert!(matches!( policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None), PermissionOutcome::Deny { reason } if reason.contains("denied by rule") )); } #[test] fn ask_rules_force_prompt_even_when_mode_allows() { let rules = RuntimePermissionRuleConfig::new( Vec::new(), Vec::new(), vec!["bash(git:*)".to_string()], ); let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess) .with_tool_requirement("bash", PermissionMode::DangerFullAccess) .with_permission_rules(&rules); let mut prompter = RecordingPrompter { seen: Vec::new(), allow: true, }; let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter)); assert_eq!(outcome, PermissionOutcome::Allow); assert_eq!(prompter.seen.len(), 1); assert!(prompter.seen[0] .reason .as_deref() .is_some_and(|reason| reason.contains("ask rule"))); } #[test] fn hook_allow_still_respects_ask_rules() { let rules = RuntimePermissionRuleConfig::new( Vec::new(), Vec::new(), vec!["bash(git:*)".to_string()], ); let policy = PermissionPolicy::new(PermissionMode::ReadOnly) .with_tool_requirement("bash", PermissionMode::DangerFullAccess) .with_permission_rules(&rules); let context = PermissionContext::new( Some(PermissionOverride::Allow), Some("hook approved".to_string()), ); let mut prompter = RecordingPrompter { seen: Vec::new(), allow: true, }; let outcome = policy.authorize_with_context( "bash", r#"{"command":"git status"}"#, &context, Some(&mut prompter), ); assert_eq!(outcome, PermissionOutcome::Allow); assert_eq!(prompter.seen.len(), 1); } #[test] fn hook_deny_short_circuits_permission_flow() { let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess) .with_tool_requirement("bash", PermissionMode::DangerFullAccess); let context = PermissionContext::new( Some(PermissionOverride::Deny), Some("blocked by hook".to_string()), ); assert_eq!( policy.authorize_with_context("bash", "{}", &context, None), PermissionOutcome::Deny { reason: "blocked by hook".to_string(), } ); } #[test] fn hook_ask_forces_prompt() { let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess) .with_tool_requirement("bash", PermissionMode::DangerFullAccess); let context = PermissionContext::new( Some(PermissionOverride::Ask), Some("hook requested confirmation".to_string()), ); let mut prompter = RecordingPrompter { seen: Vec::new(), allow: true, }; let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter)); assert_eq!(outcome, PermissionOutcome::Allow); assert_eq!(prompter.seen.len(), 1); assert_eq!( prompter.seen[0].reason.as_deref(), Some("hook requested confirmation") ); } }