Skip to main content

agent_code_lib/permissions/
mod.rs

1//! Permission system.
2//!
3//! Controls which tool operations are allowed. Checks are run
4//! before every tool execution. The system supports three modes:
5//!
6//! - `Allow` — execute without asking
7//! - `Deny` — block with a reason
8//! - `Ask` — prompt the user interactively
9//!
10//! Rules can be configured per-tool and per-pattern (e.g., allow
11//! `Bash` for `git *` commands, deny `FileWrite` outside the project).
12
13pub mod tracking;
14
15use crate::config::{PermissionMode, PermissionRule, PermissionsConfig};
16
17/// Decision from a permission check.
18#[derive(Debug, Clone)]
19pub enum PermissionDecision {
20    /// Tool execution is allowed.
21    Allow,
22    /// Tool execution is denied with a reason.
23    Deny(String),
24    /// User should be prompted with this message.
25    Ask(String),
26}
27
28/// Checks permissions for tool operations based on configured rules.
29pub struct PermissionChecker {
30    default_mode: PermissionMode,
31    rules: Vec<PermissionRule>,
32}
33
34impl PermissionChecker {
35    /// Create from configuration.
36    pub fn from_config(config: &PermissionsConfig) -> Self {
37        Self {
38            default_mode: config.default_mode,
39            rules: config.rules.clone(),
40        }
41    }
42
43    /// Create a checker that allows everything (for testing or bypass mode).
44    pub fn allow_all() -> Self {
45        Self {
46            default_mode: PermissionMode::Allow,
47            rules: Vec::new(),
48        }
49    }
50
51    /// Check whether a tool operation is permitted.
52    ///
53    /// Evaluates rules in order. The first matching rule wins.
54    /// If no rule matches, the default mode is applied.
55    pub fn check(&self, tool_name: &str, input: &serde_json::Value) -> PermissionDecision {
56        // Check explicit rules first.
57        for rule in &self.rules {
58            if !matches_tool(&rule.tool, tool_name) {
59                continue;
60            }
61
62            if let Some(ref pattern) = rule.pattern
63                && !matches_input_pattern(pattern, input)
64            {
65                continue;
66            }
67
68            return mode_to_decision(rule.action, tool_name);
69        }
70
71        // Fall back to default mode.
72        mode_to_decision(self.default_mode, tool_name)
73    }
74
75    /// Check for read-only operations (always allowed).
76    pub fn check_read(&self, tool_name: &str, input: &serde_json::Value) -> PermissionDecision {
77        // Read operations use a relaxed check — only explicit deny rules block.
78        for rule in &self.rules {
79            if !matches_tool(&rule.tool, tool_name) {
80                continue;
81            }
82            if let Some(ref pattern) = rule.pattern
83                && !matches_input_pattern(pattern, input)
84            {
85                continue;
86            }
87            if matches!(rule.action, PermissionMode::Deny) {
88                return PermissionDecision::Deny(format!("Denied by rule for {tool_name}"));
89            }
90        }
91        PermissionDecision::Allow
92    }
93}
94
95fn matches_tool(rule_tool: &str, tool_name: &str) -> bool {
96    rule_tool == "*" || rule_tool.eq_ignore_ascii_case(tool_name)
97}
98
99fn matches_input_pattern(pattern: &str, input: &serde_json::Value) -> bool {
100    // Match against common input fields: command, file_path, pattern.
101    let input_str = input
102        .get("command")
103        .or_else(|| input.get("file_path"))
104        .or_else(|| input.get("pattern"))
105        .and_then(|v| v.as_str())
106        .unwrap_or("");
107
108    glob_match(pattern, input_str)
109}
110
111/// Simple glob matching (supports `*` and `?`).
112fn glob_match(pattern: &str, text: &str) -> bool {
113    let pattern_chars: Vec<char> = pattern.chars().collect();
114    let text_chars: Vec<char> = text.chars().collect();
115    glob_match_inner(&pattern_chars, &text_chars)
116}
117
118fn glob_match_inner(pattern: &[char], text: &[char]) -> bool {
119    match (pattern.first(), text.first()) {
120        (None, None) => true,
121        (Some('*'), _) => {
122            // '*' matches zero or more characters.
123            glob_match_inner(&pattern[1..], text)
124                || (!text.is_empty() && glob_match_inner(pattern, &text[1..]))
125        }
126        (Some('?'), Some(_)) => glob_match_inner(&pattern[1..], &text[1..]),
127        (Some(p), Some(t)) if p == t => glob_match_inner(&pattern[1..], &text[1..]),
128        _ => false,
129    }
130}
131
132fn mode_to_decision(mode: PermissionMode, tool_name: &str) -> PermissionDecision {
133    match mode {
134        PermissionMode::Allow | PermissionMode::AcceptEdits => PermissionDecision::Allow,
135        PermissionMode::Deny => {
136            PermissionDecision::Deny(format!("Default mode denies {tool_name}"))
137        }
138        PermissionMode::Ask => PermissionDecision::Ask(format!("Allow {tool_name} to execute?")),
139        PermissionMode::Plan => {
140            PermissionDecision::Deny("Plan mode: only read-only operations allowed".into())
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_glob_match() {
151        assert!(glob_match("git *", "git status"));
152        assert!(glob_match("git *", "git push --force"));
153        assert!(!glob_match("git *", "rm -rf /"));
154        assert!(glob_match("*.rs", "main.rs"));
155        assert!(glob_match("*", "anything"));
156        assert!(glob_match("??", "ab"));
157        assert!(!glob_match("??", "abc"));
158    }
159
160    #[test]
161    fn test_allow_all() {
162        let checker = PermissionChecker::allow_all();
163        assert!(matches!(
164            checker.check("Bash", &serde_json::json!({"command": "ls"})),
165            PermissionDecision::Allow
166        ));
167    }
168
169    #[test]
170    fn test_rule_matching() {
171        let checker = PermissionChecker::from_config(&PermissionsConfig {
172            default_mode: PermissionMode::Ask,
173            rules: vec![
174                PermissionRule {
175                    tool: "Bash".into(),
176                    pattern: Some("git *".into()),
177                    action: PermissionMode::Allow,
178                },
179                PermissionRule {
180                    tool: "Bash".into(),
181                    pattern: Some("rm *".into()),
182                    action: PermissionMode::Deny,
183                },
184            ],
185        });
186
187        assert!(matches!(
188            checker.check("Bash", &serde_json::json!({"command": "git status"})),
189            PermissionDecision::Allow
190        ));
191        assert!(matches!(
192            checker.check("Bash", &serde_json::json!({"command": "rm -rf /"})),
193            PermissionDecision::Deny(_)
194        ));
195        assert!(matches!(
196            checker.check("Bash", &serde_json::json!({"command": "ls"})),
197            PermissionDecision::Ask(_)
198        ));
199    }
200}