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
302    #[test]
303    fn test_deny_mode_blocks_all_tools() {
304        let checker = PermissionChecker::from_config(&PermissionsConfig {
305            default_mode: PermissionMode::Deny,
306            rules: vec![],
307        });
308        assert!(matches!(
309            checker.check("Bash", &serde_json::json!({"command": "ls"})),
310            PermissionDecision::Deny(_)
311        ));
312        assert!(matches!(
313            checker.check(
314                "FileWrite",
315                &serde_json::json!({"file_path": "src/main.rs"})
316            ),
317            PermissionDecision::Deny(_)
318        ));
319    }
320
321    #[test]
322    fn test_plan_mode_blocks_all_tools() {
323        let checker = PermissionChecker::from_config(&PermissionsConfig {
324            default_mode: PermissionMode::Plan,
325            rules: vec![],
326        });
327        let decision = checker.check("Bash", &serde_json::json!({"command": "ls"}));
328        assert!(matches!(decision, PermissionDecision::Deny(_)));
329        if let PermissionDecision::Deny(msg) = decision {
330            assert!(msg.contains("Plan mode"));
331        }
332    }
333
334    #[test]
335    fn test_accept_edits_mode_allows_writes() {
336        let checker = PermissionChecker::from_config(&PermissionsConfig {
337            default_mode: PermissionMode::AcceptEdits,
338            rules: vec![],
339        });
340        // Write to a non-protected path should be allowed.
341        assert!(matches!(
342            checker.check("FileWrite", &serde_json::json!({"file_path": "src/lib.rs"})),
343            PermissionDecision::Allow
344        ));
345    }
346
347    #[test]
348    fn test_wildcard_tool_rule_matches_any_tool() {
349        let checker = PermissionChecker::from_config(&PermissionsConfig {
350            default_mode: PermissionMode::Deny,
351            rules: vec![PermissionRule {
352                tool: "*".into(),
353                pattern: None,
354                action: PermissionMode::Allow,
355            }],
356        });
357        assert!(matches!(
358            checker.check("Bash", &serde_json::json!({"command": "ls"})),
359            PermissionDecision::Allow
360        ));
361        assert!(matches!(
362            checker.check("FileRead", &serde_json::json!({"file_path": "foo.rs"})),
363            PermissionDecision::Allow
364        ));
365    }
366
367    #[test]
368    fn test_check_read_allows_reads_with_deny_default() {
369        let checker = PermissionChecker::from_config(&PermissionsConfig {
370            default_mode: PermissionMode::Deny,
371            rules: vec![],
372        });
373        // check_read should allow even when default mode is Deny (no explicit deny rule).
374        assert!(matches!(
375            checker.check_read("FileRead", &serde_json::json!({"file_path": "src/lib.rs"})),
376            PermissionDecision::Allow
377        ));
378    }
379
380    #[test]
381    fn test_check_read_blocks_with_explicit_deny_rule() {
382        let checker = PermissionChecker::from_config(&PermissionsConfig {
383            default_mode: PermissionMode::Allow,
384            rules: vec![PermissionRule {
385                tool: "FileRead".into(),
386                pattern: Some("*.secret".into()),
387                action: PermissionMode::Deny,
388            }],
389        });
390        assert!(matches!(
391            checker.check_read("FileRead", &serde_json::json!({"file_path": "keys.secret"})),
392            PermissionDecision::Deny(_)
393        ));
394        // Non-matching pattern should still allow.
395        assert!(matches!(
396            checker.check_read("FileRead", &serde_json::json!({"file_path": "src/lib.rs"})),
397            PermissionDecision::Allow
398        ));
399    }
400
401    #[test]
402    fn test_matches_input_pattern_with_file_path() {
403        let input = serde_json::json!({"file_path": "src/main.rs"});
404        assert!(matches_input_pattern("src/*", &input));
405        assert!(!matches_input_pattern("test/*", &input));
406    }
407
408    #[test]
409    fn test_matches_input_pattern_with_pattern_field() {
410        let input = serde_json::json!({"pattern": "TODO"});
411        assert!(matches_input_pattern("TODO", &input));
412        assert!(!matches_input_pattern("FIXME", &input));
413    }
414
415    #[test]
416    fn test_is_write_tool_classification() {
417        assert!(is_write_tool("FileWrite"));
418        assert!(is_write_tool("FileEdit"));
419        assert!(is_write_tool("MultiEdit"));
420        assert!(is_write_tool("NotebookEdit"));
421        assert!(!is_write_tool("FileRead"));
422        assert!(!is_write_tool("Bash"));
423        assert!(!is_write_tool("Grep"));
424    }
425
426    #[test]
427    fn test_protected_path_windows_backslash() {
428        assert!(
429            check_protected_path(&serde_json::json!({"file_path": "repo\\.git\\config"})).is_some()
430        );
431    }
432
433    #[test]
434    fn test_protected_path_nested_git_objects() {
435        assert!(
436            check_protected_path(&serde_json::json!({"file_path": "some/path/.git/objects/foo"}))
437                .is_some()
438        );
439    }
440}