agent_code_lib/permissions/
mod.rs1pub mod tracking;
14
15use crate::config::{PermissionMode, PermissionRule, PermissionsConfig};
16
17#[derive(Debug, Clone)]
19pub enum PermissionDecision {
20 Allow,
22 Deny(String),
24 Ask(String),
26}
27
28pub struct PermissionChecker {
30 default_mode: PermissionMode,
31 rules: Vec<PermissionRule>,
32}
33
34impl PermissionChecker {
35 pub fn from_config(config: &PermissionsConfig) -> Self {
37 Self {
38 default_mode: config.default_mode,
39 rules: config.rules.clone(),
40 }
41 }
42
43 pub fn allow_all() -> Self {
45 Self {
46 default_mode: PermissionMode::Allow,
47 rules: Vec::new(),
48 }
49 }
50
51 pub fn check(&self, tool_name: &str, input: &serde_json::Value) -> PermissionDecision {
56 if is_write_tool(tool_name)
58 && let Some(reason) = check_protected_path(input)
59 {
60 return PermissionDecision::Deny(reason);
61 }
62
63 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 mode_to_decision(self.default_mode, tool_name)
80 }
81
82 pub fn check_read(&self, tool_name: &str, input: &serde_json::Value) -> PermissionDecision {
84 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 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
118fn 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 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
139const PROTECTED_DIRS: &[&str] = &[
141 ".git/",
142 ".git\\",
143 ".husky/",
144 ".husky\\",
145 "node_modules/",
146 "node_modules\\",
147];
148
149fn is_write_tool(tool_name: &str) -> bool {
151 matches!(
152 tool_name,
153 "FileWrite" | "FileEdit" | "MultiEdit" | "NotebookEdit"
154 )
155}
156
157fn 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 assert!(matches!(
218 checker.check(
219 "FileWrite",
220 &serde_json::json!({"file_path": ".git/config"})
221 ),
222 PermissionDecision::Deny(_)
223 ));
224
225 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 assert!(matches!(
236 checker.check(
237 "FileWrite",
238 &serde_json::json!({"file_path": ".husky/pre-commit"})
239 ),
240 PermissionDecision::Deny(_)
241 ));
242
243 assert!(matches!(
245 checker.check("FileRead", &serde_json::json!({"file_path": ".git/config"})),
246 PermissionDecision::Allow
247 ));
248
249 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 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 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 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}