Skip to main content

ai_agent/
permission.rs

1//! Permission management for agent tool access control.
2//!
3//! This module provides a permission system similar to claude code's permissions,
4//! with support for permission modes, rules, and decisions.
5
6use serde::{Deserialize, Serialize};
7
8/// Permission behavior - what to do when a tool is used
9#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
10#[serde(rename_all = "lowercase")]
11pub enum PermissionBehavior {
12    /// Always allow the tool
13    Allow,
14    /// Always deny the tool
15    Deny,
16    /// Ask the user for permission
17    #[default]
18    Ask,
19}
20
21impl PermissionBehavior {
22    /// Get string representation
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            PermissionBehavior::Allow => "allow",
26            PermissionBehavior::Deny => "deny",
27            PermissionBehavior::Ask => "ask",
28        }
29    }
30}
31
32/// Permission mode - controls how permissions are handled globally
33#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
34#[serde(rename_all = "lowercase")]
35pub enum PermissionMode {
36    /// Default mode - ask for permission
37    #[default]
38    Default,
39    /// Accept edits without asking
40    AcceptEdits,
41    /// Bypass all permission checks
42    Bypass,
43    /// Deny all without asking
44    DontAsk,
45    /// Plan mode - for planning operations
46    Plan,
47    /// Auto mode - automatically decide based on context
48    Auto,
49    /// Bubble mode - prompt-free for most operations, escalate on certain patterns
50    Bubble,
51}
52
53/// Source of a permission rule
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum PermissionRuleSource {
57    /// User-level settings (~/.ai/)
58    UserSettings,
59    /// Project-level settings (./.ai/)
60    ProjectSettings,
61    /// Local settings (./.ai.local/)
62    LocalSettings,
63    /// From CLI arguments
64    CliArg,
65    /// From command/session
66    Session,
67    /// From policy
68    Policy,
69    /// From flag settings
70    FlagSettings,
71}
72
73/// A permission rule - specifies behavior for a tool
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct PermissionRule {
76    /// Source of this rule
77    pub source: PermissionRuleSource,
78    /// Behavior (allow/deny/ask)
79    pub behavior: PermissionBehavior,
80    /// The tool name this rule applies to
81    pub tool_name: String,
82    /// Optional content pattern to match
83    pub rule_content: Option<String>,
84}
85
86impl PermissionRule {
87    /// Create a new permission rule
88    pub fn new(tool_name: &str, behavior: PermissionBehavior) -> Self {
89        Self {
90            source: PermissionRuleSource::UserSettings,
91            behavior,
92            tool_name: tool_name.to_string(),
93            rule_content: None,
94        }
95    }
96
97    /// Create a rule with content pattern
98    pub fn with_content(tool_name: &str, behavior: PermissionBehavior, content: &str) -> Self {
99        Self {
100            source: PermissionRuleSource::UserSettings,
101            behavior,
102            tool_name: tool_name.to_string(),
103            rule_content: Some(content.to_string()),
104        }
105    }
106
107    /// Create an allow rule
108    pub fn allow(tool_name: &str) -> Self {
109        Self::new(tool_name, PermissionBehavior::Allow)
110    }
111
112    /// Create a deny rule
113    pub fn deny(tool_name: &str) -> Self {
114        Self::new(tool_name, PermissionBehavior::Deny)
115    }
116
117    /// Create an ask rule
118    pub fn ask(tool_name: &str) -> Self {
119        Self::new(tool_name, PermissionBehavior::Ask)
120    }
121}
122
123/// Permission metadata for a tool request
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct PermissionMetadata {
126    /// Tool name
127    pub tool_name: String,
128    /// Tool description
129    pub description: Option<String>,
130    /// The input/arguments to the tool
131    pub input: Option<serde_json::Value>,
132    /// Current working directory
133    pub cwd: Option<String>,
134}
135
136impl PermissionMetadata {
137    /// Create new metadata
138    pub fn new(tool_name: &str) -> Self {
139        Self {
140            tool_name: tool_name.to_string(),
141            description: None,
142            input: None,
143            cwd: None,
144        }
145    }
146
147    /// Set description
148    pub fn with_description(mut self, description: &str) -> Self {
149        self.description = Some(description.to_string());
150        self
151    }
152
153    /// Set input
154    pub fn with_input(mut self, input: serde_json::Value) -> Self {
155        self.input = Some(input);
156        self
157    }
158
159    /// Set cwd
160    pub fn with_cwd(mut self, cwd: &str) -> Self {
161        self.cwd = Some(cwd.to_string());
162        self
163    }
164}
165
166/// Reason for a permission decision
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168#[serde(tag = "type", rename_all = "snake_case")]
169pub enum PermissionDecisionReason {
170    /// Matched a permission rule
171    Rule { rule: PermissionRule },
172    /// Determined by permission mode
173    Mode { mode: PermissionMode },
174    /// From a hook
175    Hook {
176        hook_name: String,
177        reason: Option<String>,
178    },
179    /// Sandbox override
180    SandboxOverride { reason: String },
181    /// Safety check failed
182    SafetyCheck { reason: String },
183    /// Other reason
184    Other { reason: String },
185}
186
187/// Result when permission is allowed
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct PermissionAllowDecision {
190    pub behavior: PermissionBehavior,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub updated_input: Option<serde_json::Value>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub user_modified: Option<bool>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub decision_reason: Option<PermissionDecisionReason>,
197}
198
199impl PermissionAllowDecision {
200    /// Create an allow decision
201    pub fn new() -> Self {
202        Self {
203            behavior: PermissionBehavior::Allow,
204            updated_input: None,
205            user_modified: None,
206            decision_reason: None,
207        }
208    }
209
210    /// Create with reason
211    pub fn with_reason(mut self, reason: PermissionDecisionReason) -> Self {
212        self.decision_reason = Some(reason);
213        self
214    }
215}
216
217impl Default for PermissionAllowDecision {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223/// Result when permission should be asked
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct PermissionAskDecision {
226    pub behavior: PermissionBehavior,
227    pub message: String,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub updated_input: Option<serde_json::Value>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub decision_reason: Option<PermissionDecisionReason>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub blocked_path: Option<String>,
234}
235
236impl PermissionAskDecision {
237    /// Create an ask decision with message
238    pub fn new(message: &str) -> Self {
239        Self {
240            behavior: PermissionBehavior::Ask,
241            message: message.to_string(),
242            updated_input: None,
243            decision_reason: None,
244            blocked_path: None,
245        }
246    }
247
248    /// Create with reason
249    pub fn with_reason(mut self, reason: PermissionDecisionReason) -> Self {
250        self.decision_reason = Some(reason);
251        self
252    }
253
254    /// Create with blocked path
255    pub fn with_blocked_path(mut self, path: &str) -> Self {
256        self.blocked_path = Some(path.to_string());
257        self
258    }
259}
260
261/// Result when permission is denied
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct PermissionDenyDecision {
264    pub behavior: PermissionBehavior,
265    pub message: String,
266    pub decision_reason: PermissionDecisionReason,
267}
268
269impl PermissionDenyDecision {
270    /// Create a deny decision with message
271    pub fn new(message: &str, reason: PermissionDecisionReason) -> Self {
272        Self {
273            behavior: PermissionBehavior::Deny,
274            message: message.to_string(),
275            decision_reason: reason,
276        }
277    }
278}
279
280/// A permission decision - allow, ask, or deny
281#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(tag = "behavior", rename_all = "lowercase")]
283pub enum PermissionDecision {
284    Allow(PermissionAllowDecision),
285    Ask(PermissionAskDecision),
286    Deny(PermissionDenyDecision),
287}
288
289impl PermissionDecision {
290    /// Check if allowed
291    pub fn is_allowed(&self) -> bool {
292        matches!(self, PermissionDecision::Allow(_))
293    }
294
295    /// Check if denied
296    pub fn is_denied(&self) -> bool {
297        matches!(self, PermissionDecision::Deny(_))
298    }
299
300    /// Check if asking
301    pub fn is_ask(&self) -> bool {
302        matches!(self, PermissionDecision::Ask(_))
303    }
304
305    /// Get the message if present
306    pub fn message(&self) -> Option<&str> {
307        match self {
308            PermissionDecision::Allow(_) => None,
309            PermissionDecision::Ask(d) => Some(&d.message),
310            PermissionDecision::Deny(d) => Some(&d.message),
311        }
312    }
313}
314
315/// Permission result with additional passthrough option
316#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(tag = "behavior", rename_all = "lowercase")]
318pub enum PermissionResult {
319    Allow(PermissionAllowDecision),
320    Ask(PermissionAskDecision),
321    Deny(PermissionDenyDecision),
322    /// Passthrough - allow but log/notify
323    Passthrough {
324        message: String,
325        #[serde(skip_serializing_if = "Option::is_none")]
326        decision_reason: Option<PermissionDecisionReason>,
327    },
328}
329
330impl PermissionResult {
331    /// Convert to decision
332    pub fn to_decision(self) -> Option<PermissionDecision> {
333        match self {
334            PermissionResult::Allow(d) => Some(PermissionDecision::Allow(d)),
335            PermissionResult::Ask(d) => Some(PermissionDecision::Ask(d)),
336            PermissionResult::Deny(d) => Some(PermissionDecision::Deny(d)),
337            PermissionResult::Passthrough { .. } => None,
338        }
339    }
340
341    /// Check if allowed (including passthrough)
342    pub fn is_allowed(&self) -> bool {
343        matches!(
344            self,
345            PermissionResult::Allow(_) | PermissionResult::Passthrough { .. }
346        )
347    }
348
349    /// Check if denied
350    pub fn is_denied(&self) -> bool {
351        matches!(self, PermissionResult::Deny(_))
352    }
353
354    /// Check if asking
355    pub fn is_ask(&self) -> bool {
356        matches!(self, PermissionResult::Ask(_))
357    }
358
359    /// Get the message
360    pub fn message(&self) -> Option<&str> {
361        match self {
362            PermissionResult::Allow(_) => None,
363            PermissionResult::Ask(d) => Some(&d.message),
364            PermissionResult::Deny(d) => Some(&d.message),
365            PermissionResult::Passthrough { message, .. } => Some(message),
366        }
367    }
368}
369
370/// Permission context for checking tool access
371pub struct PermissionContext {
372    /// Current permission mode
373    pub mode: PermissionMode,
374    /// Always allow rules
375    pub allow_rules: Vec<PermissionRule>,
376    /// Always deny rules
377    pub deny_rules: Vec<PermissionRule>,
378    /// Always ask rules
379    pub ask_rules: Vec<PermissionRule>,
380    /// Denial tracking state
381    pub denial_tracking: std::sync::RwLock<crate::utils::permissions::denial_tracking::DenialTrackingState>,
382}
383
384impl std::fmt::Debug for PermissionContext {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        f.debug_struct("PermissionContext")
387            .field("mode", &self.mode)
388            .field("allow_rules", &self.allow_rules)
389            .field("deny_rules", &self.deny_rules)
390            .field("ask_rules", &self.ask_rules)
391            .finish_non_exhaustive()
392    }
393}
394
395impl Clone for PermissionContext {
396    fn clone(&self) -> Self {
397        let dt = self.denial_tracking.read().map(|dt| *dt).unwrap_or_default();
398        Self {
399            mode: self.mode,
400            allow_rules: self.allow_rules.clone(),
401            deny_rules: self.deny_rules.clone(),
402            ask_rules: self.ask_rules.clone(),
403            denial_tracking: std::sync::RwLock::new(dt),
404        }
405    }
406}
407
408impl Default for PermissionContext {
409    fn default() -> Self {
410        Self {
411            mode: PermissionMode::default(),
412            allow_rules: Vec::new(),
413            deny_rules: Vec::new(),
414            ask_rules: Vec::new(),
415            denial_tracking: std::sync::RwLock::new(
416                crate::utils::permissions::denial_tracking::DenialTrackingState::default(),
417            ),
418        }
419    }
420}
421
422/// Checks if a tool name matches a PermissionRule's tool_name using 4-step matching.
423///
424/// Step 1: Exact match (`Bash` matches `Bash`)
425/// Step 2: MCP server-prefix match (`mcp__fs__` blocks all tools starting with `mcp__fs__`)
426/// Step 3: MCP tool-prefix match (`mcp__fs_` blocks all tools starting with `mcp__fs_`)
427/// Step 4: Wildcard (`*` matches everything)
428fn tool_name_matches_rule(tool_name: &str, rule: &PermissionRule) -> bool {
429    let rule_tool = &rule.tool_name;
430
431    // Step 1: Exact match
432    if rule_tool == tool_name {
433        return true;
434    }
435    // Step 2: MCP server-prefix match (rule ends with "__")
436    if rule_tool.ends_with("__") && tool_name.starts_with(rule_tool.as_str()) {
437        return true;
438    }
439    // Step 3: MCP tool-prefix match (rule ends with "_")
440    if rule_tool.ends_with('_') && tool_name.starts_with(rule_tool.as_str()) {
441        return true;
442    }
443    // Step 4: Wildcard
444    if rule_tool == "*" {
445        return true;
446    }
447    false
448}
449
450impl PermissionContext {
451    /// Create a new permission context
452    pub fn new() -> Self {
453        Self::default()
454    }
455
456    /// Set permission mode
457    pub fn with_mode(mut self, mode: PermissionMode) -> Self {
458        self.mode = mode;
459        self
460    }
461
462    /// Add an allow rule
463    pub fn with_allow_rule(mut self, rule: PermissionRule) -> Self {
464        self.allow_rules.push(rule);
465        self
466    }
467
468    /// Add a deny rule
469    pub fn with_deny_rule(mut self, rule: PermissionRule) -> Self {
470        self.deny_rules.push(rule);
471        self
472    }
473
474    /// Add an ask rule
475    pub fn with_ask_rule(mut self, rule: PermissionRule) -> Self {
476        self.ask_rules.push(rule);
477        self
478    }
479
480    /// Set denial tracking state
481    pub fn with_denial_tracking(
482        mut self,
483        state: crate::utils::permissions::denial_tracking::DenialTrackingState,
484    ) -> Self {
485        let guard = self.denial_tracking.get_mut().unwrap();
486        *guard = state;
487        self
488    }
489
490    /// Check if a deny rule matches a tool (4-step: exact, server-prefix, tool-prefix, wildcard).
491    /// Content-pattern deny rules do not match at the tool-name level.
492    fn deny_rule_matches(&self, tool_name: &str, rule: &PermissionRule) -> bool {
493        if rule.rule_content.is_some() {
494            return false;
495        }
496        tool_name_matches_rule(tool_name, rule)
497    }
498
499    /// Check if an allow rule matches a tool AND (optionally) its input content.
500    fn allow_rule_matches(
501        &self,
502        tool_name: &str,
503        input: Option<&serde_json::Value>,
504        rule: &PermissionRule,
505    ) -> bool {
506        if !tool_name_matches_rule(tool_name, rule) {
507            return false;
508        }
509        // If rule has content pattern, input must also match
510        if let Some(content) = &rule.rule_content {
511            if let Some(input) = input {
512                let input_str = input.to_string();
513                return input_str.contains(content);
514            }
515            return false;
516        }
517        true
518    }
519
520    /// Check if a tool is allowed
521    pub fn check_tool(
522        &self,
523        tool_name: &str,
524        input: Option<&serde_json::Value>,
525    ) -> PermissionResult {
526        // Check deny rules first (4-step matching)
527        for rule in &self.deny_rules {
528            if self.deny_rule_matches(tool_name, rule) {
529                return PermissionResult::Deny(PermissionDenyDecision::new(
530                    &format!("Tool '{}' is denied by rule", tool_name),
531                    PermissionDecisionReason::Rule { rule: rule.clone() },
532                ));
533            }
534        }
535
536        // Check allow rules (4-step matching + content)
537        for rule in &self.allow_rules {
538            if self.allow_rule_matches(tool_name, input, rule) {
539                return PermissionResult::Allow(
540                    PermissionAllowDecision::new()
541                        .with_reason(PermissionDecisionReason::Rule { rule: rule.clone() }),
542                );
543            }
544        }
545
546        // Check ask rules (4-step matching)
547        for rule in &self.ask_rules {
548            if self.deny_rule_matches(tool_name, rule) {
549                return PermissionResult::Ask(
550                    PermissionAskDecision::new(&format!(
551                        "Tool '{}' requires permission",
552                        tool_name
553                    ))
554                    .with_reason(PermissionDecisionReason::Rule { rule: rule.clone() }),
555                );
556            }
557        }
558
559        // Check permission mode
560        match self.mode {
561            PermissionMode::Bypass => {
562                return PermissionResult::Allow(PermissionAllowDecision::new().with_reason(
563                    PermissionDecisionReason::Mode {
564                        mode: PermissionMode::Bypass,
565                    },
566                ));
567            }
568            PermissionMode::DontAsk => {
569                return PermissionResult::Deny(PermissionDenyDecision::new(
570                    "Permission mode is 'dontAsk'",
571                    PermissionDecisionReason::Mode {
572                        mode: PermissionMode::DontAsk,
573                    },
574                ));
575            }
576            PermissionMode::AcceptEdits => {
577                // Allow edit tools
578                if tool_name == "Write" || tool_name == "Edit" || tool_name == "Bash" {
579                    return PermissionResult::Allow(PermissionAllowDecision::new().with_reason(
580                        PermissionDecisionReason::Mode {
581                            mode: PermissionMode::AcceptEdits,
582                        },
583                    ));
584                }
585            }
586            PermissionMode::Bubble => {
587                // Bubble mode: allow most tools without prompting, but check for dangerous patterns
588                // Allow read-only tools and safe tools automatically
589                let safe_tools = ["Read", "Glob", "Grep", "Search", "WebFetch", "WebSearch"];
590                if safe_tools.iter().any(|&t| t == tool_name) {
591                    return PermissionResult::Allow(PermissionAllowDecision::new().with_reason(
592                        PermissionDecisionReason::Mode {
593                            mode: PermissionMode::Bubble,
594                        },
595                    ));
596                }
597                // Check input for dangerous patterns before allowing write/edit/bash
598                if let Some(input_val) = input {
599                    let input_str = input_val.to_string();
600                    // Block potentially dangerous patterns
601                    let dangerous_patterns = [
602                        "rm -rf",
603                        "rm /",
604                        "del /",
605                        "format",
606                        "dd if=",
607                        "> /dev/sd",
608                        "chmod 777",
609                        "chown -R",
610                    ];
611                    for pattern in dangerous_patterns {
612                        if input_str.contains(pattern) {
613                            // Dangerous pattern detected - ask for permission
614                            return PermissionResult::Ask(
615                                PermissionAskDecision::new(&format!(
616                                    "Tool '{}' contains potentially dangerous pattern: {}",
617                                    tool_name, pattern
618                                ))
619                                .with_reason(
620                                    PermissionDecisionReason::Mode {
621                                        mode: PermissionMode::Bubble,
622                                    },
623                                ),
624                            );
625                        }
626                    }
627                }
628                // Allow write/edit/bash if no dangerous patterns
629                if tool_name == "Write"
630                    || tool_name == "Edit"
631                    || tool_name == "Bash"
632                    || tool_name == "FileEdit"
633                    || tool_name == "Write"
634                {
635                    return PermissionResult::Allow(PermissionAllowDecision::new().with_reason(
636                        PermissionDecisionReason::Mode {
637                            mode: PermissionMode::Bubble,
638                        },
639                    ));
640                }
641            }
642            PermissionMode::Auto => {
643                if crate::utils::permissions::classifier_decision::is_auto_mode_allowlisted_tool(
644                    tool_name,
645                ) {
646                    if let Ok(mut dt) = self.denial_tracking.write() {
647                        *dt = crate::utils::permissions::denial_tracking::record_success(*dt);
648                    }
649                    return PermissionResult::Allow(
650                        PermissionAllowDecision::new()
651                            .with_reason(PermissionDecisionReason::Mode {
652                                mode: PermissionMode::Auto,
653                            }),
654                    );
655                }
656                // Record denial
657                if let Ok(mut dt) = self.denial_tracking.write() {
658                    *dt = crate::utils::permissions::denial_tracking::record_denial(*dt);
659                }
660                let should_fallback = if let Ok(dt) = self.denial_tracking.read() {
661                    crate::utils::permissions::denial_tracking::should_fallback_to_prompting(*dt)
662                } else {
663                    false
664                };
665                let mut msg = format!("Tool '{}' requires auto-classification", tool_name);
666                if should_fallback {
667                    msg = format!(
668                        "{}. Auto mode has failed repeatedly — consider switching to a different permission mode.",
669                        msg
670                    );
671                }
672                return PermissionResult::Ask(
673                    PermissionAskDecision::new(&msg).with_reason(PermissionDecisionReason::Mode {
674                        mode: PermissionMode::Auto,
675                    }),
676                );
677            }
678            _ => {}
679        }
680
681        // Default: ask
682        PermissionResult::Ask(
683            PermissionAskDecision::new(&format!("Permission required to use {}", tool_name))
684                .with_reason(PermissionDecisionReason::Mode { mode: self.mode }),
685        )
686    }
687}
688
689/// Callback type for permission checks
690pub type PermissionCallback =
691    Box<dyn Fn(PermissionMetadata, PermissionResult) -> PermissionResult + Send + Sync>;
692
693/// Permission handler with callback support
694pub struct PermissionHandler {
695    context: PermissionContext,
696    callback: Option<PermissionCallback>,
697}
698
699impl PermissionHandler {
700    /// Create a new permission handler
701    pub fn new(context: PermissionContext) -> Self {
702        Self {
703            context,
704            callback: None,
705        }
706    }
707
708    /// Create with a callback
709    pub fn with_callback(context: PermissionContext, callback: PermissionCallback) -> Self {
710        Self {
711            context,
712            callback: Some(callback),
713        }
714    }
715
716    /// Check permission for a tool
717    pub fn check(&self, metadata: PermissionMetadata) -> PermissionResult {
718        let result = self
719            .context
720            .check_tool(&metadata.tool_name, metadata.input.as_ref());
721
722        // If there's a callback, let it override the decision
723        if let Some(callback) = &self.callback {
724            return callback(metadata, result);
725        }
726
727        result
728    }
729
730    /// Check if tool is allowed
731    pub fn is_allowed(&self, metadata: &PermissionMetadata) -> bool {
732        self.check(metadata.clone()).is_allowed()
733    }
734}
735
736impl PermissionHandler {
737    /// Create a default permission handler
738    pub fn default() -> Self {
739        Self::new(PermissionContext::default())
740    }
741}