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 in order: protected paths, explicit rules, default mode.
54    /// The first match wins.
55    pub fn check(&self, tool_name: &str, input: &serde_json::Value) -> PermissionDecision {
56        // Block writes to protected directories regardless of rules.
57        if is_write_tool(tool_name)
58            && let Some(reason) = check_protected_path(input)
59        {
60            return PermissionDecision::Deny(reason);
61        }
62
63        // Check explicit rules.
64        for rule in &self.rules {
65            if !matches_tool(&rule.tool, tool_name) {
66                continue;
67            }
68
69            if let Some(ref pattern) = rule.pattern
70                && !matches_input_pattern(pattern, input)
71            {
72                continue;
73            }
74
75            return mode_to_decision(rule.action, tool_name);
76        }
77
78        // Fall back to default mode.
79        mode_to_decision(self.default_mode, tool_name)
80    }
81
82    /// Check for read-only operations (always allowed).
83    pub fn check_read(&self, tool_name: &str, input: &serde_json::Value) -> PermissionDecision {
84        // Read operations use a relaxed check — only explicit deny rules block.
85        for rule in &self.rules {
86            if !matches_tool(&rule.tool, tool_name) {
87                continue;
88            }
89            if let Some(ref pattern) = rule.pattern
90                && !matches_input_pattern(pattern, input)
91            {
92                continue;
93            }
94            if matches!(rule.action, PermissionMode::Deny) {
95                return PermissionDecision::Deny(format!("Denied by rule for {tool_name}"));
96            }
97        }
98        PermissionDecision::Allow
99    }
100}
101
102fn matches_tool(rule_tool: &str, tool_name: &str) -> bool {
103    rule_tool == "*" || rule_tool.eq_ignore_ascii_case(tool_name)
104}
105
106fn matches_input_pattern(pattern: &str, input: &serde_json::Value) -> bool {
107    // Match against common input fields: command, file_path, pattern.
108    let input_str = input
109        .get("command")
110        .or_else(|| input.get("file_path"))
111        .or_else(|| input.get("pattern"))
112        .and_then(|v| v.as_str())
113        .unwrap_or("");
114
115    glob_match(pattern, input_str)
116}
117
118/// Simple glob matching (supports `*` and `?`).
119fn glob_match(pattern: &str, text: &str) -> bool {
120    let pattern_chars: Vec<char> = pattern.chars().collect();
121    let text_chars: Vec<char> = text.chars().collect();
122    glob_match_inner(&pattern_chars, &text_chars)
123}
124
125fn glob_match_inner(pattern: &[char], text: &[char]) -> bool {
126    match (pattern.first(), text.first()) {
127        (None, None) => true,
128        (Some('*'), _) => {
129            // '*' matches zero or more characters.
130            glob_match_inner(&pattern[1..], text)
131                || (!text.is_empty() && glob_match_inner(pattern, &text[1..]))
132        }
133        (Some('?'), Some(_)) => glob_match_inner(&pattern[1..], &text[1..]),
134        (Some(p), Some(t)) if p == t => glob_match_inner(&pattern[1..], &text[1..]),
135        _ => false,
136    }
137}
138
139/// Directories that should never be written to by the agent.
140const PROTECTED_DIRS: &[&str] = &[
141    ".git/",
142    ".git\\",
143    ".husky/",
144    ".husky\\",
145    "node_modules/",
146    "node_modules\\",
147];
148
149/// Returns true for tools that modify the filesystem.
150fn is_write_tool(tool_name: &str) -> bool {
151    matches!(
152        tool_name,
153        "FileWrite" | "FileEdit" | "MultiEdit" | "NotebookEdit"
154    )
155}
156
157/// Check if the input targets a protected path. Returns the denial reason if so.
158fn check_protected_path(input: &serde_json::Value) -> Option<String> {
159    let path = input
160        .get("file_path")
161        .and_then(|v| v.as_str())
162        .unwrap_or("");
163
164    for dir in PROTECTED_DIRS {
165        if path.contains(dir) {
166            let dir_name = dir.trim_end_matches(['/', '\\']);
167            return Some(format!(
168                "Write to {dir_name}/ is blocked. This is a protected directory."
169            ));
170        }
171    }
172    None
173}
174
175fn mode_to_decision(mode: PermissionMode, tool_name: &str) -> PermissionDecision {
176    match mode {
177        PermissionMode::Allow | PermissionMode::AcceptEdits => PermissionDecision::Allow,
178        PermissionMode::Deny => {
179            PermissionDecision::Deny(format!("Default mode denies {tool_name}"))
180        }
181        PermissionMode::Ask => PermissionDecision::Ask(format!("Allow {tool_name} to execute?")),
182        PermissionMode::Plan => {
183            PermissionDecision::Deny("Plan mode: only read-only operations allowed".into())
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_glob_match() {
194        assert!(glob_match("git *", "git status"));
195        assert!(glob_match("git *", "git push --force"));
196        assert!(!glob_match("git *", "rm -rf /"));
197        assert!(glob_match("*.rs", "main.rs"));
198        assert!(glob_match("*", "anything"));
199        assert!(glob_match("??", "ab"));
200        assert!(!glob_match("??", "abc"));
201    }
202
203    #[test]
204    fn test_allow_all() {
205        let checker = PermissionChecker::allow_all();
206        assert!(matches!(
207            checker.check("Bash", &serde_json::json!({"command": "ls"})),
208            PermissionDecision::Allow
209        ));
210    }
211
212    #[test]
213    fn test_protected_dirs_block_writes() {
214        let checker = PermissionChecker::allow_all();
215
216        // Writing to .git/ should be denied even with allow_all.
217        assert!(matches!(
218            checker.check(
219                "FileWrite",
220                &serde_json::json!({"file_path": ".git/config"})
221            ),
222            PermissionDecision::Deny(_)
223        ));
224
225        // Writing to node_modules/ should be denied.
226        assert!(matches!(
227            checker.check(
228                "FileEdit",
229                &serde_json::json!({"file_path": "node_modules/foo/index.js"})
230            ),
231            PermissionDecision::Deny(_)
232        ));
233
234        // Writing to .husky/ should be denied.
235        assert!(matches!(
236            checker.check(
237                "FileWrite",
238                &serde_json::json!({"file_path": ".husky/pre-commit"})
239            ),
240            PermissionDecision::Deny(_)
241        ));
242
243        // Reading .git/ should still be allowed.
244        assert!(matches!(
245            checker.check("FileRead", &serde_json::json!({"file_path": ".git/config"})),
246            PermissionDecision::Allow
247        ));
248
249        // Writing to normal paths should still work.
250        assert!(matches!(
251            checker.check(
252                "FileWrite",
253                &serde_json::json!({"file_path": "src/main.rs"})
254            ),
255            PermissionDecision::Allow
256        ));
257    }
258
259    #[test]
260    fn test_protected_dirs_helper() {
261        assert!(check_protected_path(&serde_json::json!({"file_path": ".git/HEAD"})).is_some());
262        assert!(
263            check_protected_path(&serde_json::json!({"file_path": "node_modules/pkg/lib.js"}))
264                .is_some()
265        );
266        assert!(check_protected_path(&serde_json::json!({"file_path": "src/lib.rs"})).is_none());
267        assert!(check_protected_path(&serde_json::json!({"command": "ls"})).is_none());
268    }
269
270    #[test]
271    fn test_rule_matching() {
272        let checker = PermissionChecker::from_config(&PermissionsConfig {
273            default_mode: PermissionMode::Ask,
274            rules: vec![
275                PermissionRule {
276                    tool: "Bash".into(),
277                    pattern: Some("git *".into()),
278                    action: PermissionMode::Allow,
279                },
280                PermissionRule {
281                    tool: "Bash".into(),
282                    pattern: Some("rm *".into()),
283                    action: PermissionMode::Deny,
284                },
285            ],
286        });
287
288        assert!(matches!(
289            checker.check("Bash", &serde_json::json!({"command": "git status"})),
290            PermissionDecision::Allow
291        ));
292        assert!(matches!(
293            checker.check("Bash", &serde_json::json!({"command": "rm -rf /"})),
294            PermissionDecision::Deny(_)
295        ));
296        assert!(matches!(
297            checker.check("Bash", &serde_json::json!({"command": "ls"})),
298            PermissionDecision::Ask(_)
299        ));
300    }
301}