| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675 |
- 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<PermissionOverride>,
- override_reason: Option<String>,
- }
- impl PermissionContext {
- #[must_use]
- pub fn new(
- override_decision: Option<PermissionOverride>,
- override_reason: Option<String>,
- ) -> Self {
- Self {
- override_decision,
- override_reason,
- }
- }
- #[must_use]
- pub fn override_decision(&self) -> Option<PermissionOverride> {
- 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<String>,
- }
- #[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<String, PermissionMode>,
- allow_rules: Vec<PermissionRule>,
- deny_rules: Vec<PermissionRule>,
- ask_rules: Vec<PermissionRule>,
- }
- 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<String>,
- 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<String>,
- 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<usize> {
- 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<usize> {
- let chars = value.char_indices().collect::<Vec<_>>();
- 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<String> {
- let parsed = serde_json::from_str::<Value>(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<PermissionRequest>,
- 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")
- );
- }
- }
|