1use agent_settings::{AgentSettings, ToolPermissions, ToolRules};
2use settings::ToolPermissionMode;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum ToolPermissionDecision {
6 Allow,
7 Deny(String),
8 Confirm,
9}
10
11/// Determines the permission decision for a tool invocation based on configured rules.
12///
13/// # Precedence Order (highest to lowest)
14///
15/// 1. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
16/// This takes precedence over all other rules for security.
17/// 2. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
18/// the user is prompted for confirmation regardless of other settings.
19/// 3. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
20/// the tool call proceeds without prompting.
21/// 4. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
22/// 5. **`always_allow_tool_actions`** - Global setting used as fallback when no tool-specific
23/// rules are configured, or when `default_mode` is `Confirm`.
24///
25/// # Pattern Matching Tips
26///
27/// Patterns are matched as regular expressions against the tool input (e.g., the command
28/// string for the terminal tool). Some tips for writing effective patterns:
29///
30/// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
31/// match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
32/// This is important for security rules where you want to block specific commands
33/// without accidentally blocking unrelated commands that happen to contain the same
34/// substring.
35/// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
36/// - Use `^` and `$` anchors to match the start/end of the input.
37pub fn decide_permission(
38 tool_name: &str,
39 input: &str,
40 permissions: &ToolPermissions,
41 always_allow_tool_actions: bool,
42) -> ToolPermissionDecision {
43 let rules = permissions.tools.get(tool_name);
44
45 let rules = match rules {
46 Some(rules) => rules,
47 None => {
48 return if always_allow_tool_actions {
49 ToolPermissionDecision::Allow
50 } else {
51 ToolPermissionDecision::Confirm
52 };
53 }
54 };
55
56 // Check for invalid regex patterns before evaluating rules.
57 // If any patterns failed to compile, block the tool call entirely.
58 if let Some(error) = check_invalid_patterns(tool_name, rules) {
59 return ToolPermissionDecision::Deny(error);
60 }
61
62 if rules.always_deny.iter().any(|r| r.is_match(input)) {
63 return ToolPermissionDecision::Deny(format!(
64 "Command blocked by security rule for {} tool",
65 tool_name
66 ));
67 }
68
69 if rules.always_confirm.iter().any(|r| r.is_match(input)) {
70 return ToolPermissionDecision::Confirm;
71 }
72
73 if rules.always_allow.iter().any(|r| r.is_match(input)) {
74 return ToolPermissionDecision::Allow;
75 }
76
77 match rules.default_mode {
78 ToolPermissionMode::Deny => {
79 ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
80 }
81 ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
82 ToolPermissionMode::Confirm => {
83 if always_allow_tool_actions {
84 ToolPermissionDecision::Allow
85 } else {
86 ToolPermissionDecision::Confirm
87 }
88 }
89 }
90}
91
92/// Checks if the tool rules contain any invalid regex patterns.
93/// Returns an error message if invalid patterns are found.
94fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
95 if rules.invalid_patterns.is_empty() {
96 return None;
97 }
98
99 let count = rules.invalid_patterns.len();
100 let pattern_word = if count == 1 { "pattern" } else { "patterns" };
101
102 Some(format!(
103 "The {} tool cannot run because {} regex {} failed to compile. \
104 Please fix the invalid patterns in your tool_permissions settings.",
105 tool_name, count, pattern_word
106 ))
107}
108
109/// Convenience wrapper that extracts permission settings from `AgentSettings`.
110///
111/// This is the primary entry point for tools to check permissions. It extracts
112/// `tool_permissions` and `always_allow_tool_actions` from the settings and
113/// delegates to [`decide_permission`].
114pub fn decide_permission_from_settings(
115 tool_name: &str,
116 input: &str,
117 settings: &AgentSettings,
118) -> ToolPermissionDecision {
119 decide_permission(
120 tool_name,
121 input,
122 &settings.tool_permissions,
123 settings.always_allow_tool_actions,
124 )
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
131 use std::sync::Arc;
132
133 fn empty_permissions() -> ToolPermissions {
134 ToolPermissions {
135 tools: collections::HashMap::default(),
136 }
137 }
138
139 fn terminal_rules_with_deny(patterns: &[&str]) -> ToolPermissions {
140 let mut tools = collections::HashMap::default();
141 tools.insert(
142 Arc::from("terminal"),
143 ToolRules {
144 default_mode: ToolPermissionMode::Confirm,
145 always_allow: vec![],
146 always_deny: patterns
147 .iter()
148 .filter_map(|p| CompiledRegex::new(p, false))
149 .collect(),
150 always_confirm: vec![],
151 invalid_patterns: vec![],
152 },
153 );
154 ToolPermissions { tools }
155 }
156
157 fn terminal_rules_with_allow(patterns: &[&str]) -> ToolPermissions {
158 let mut tools = collections::HashMap::default();
159 tools.insert(
160 Arc::from("terminal"),
161 ToolRules {
162 default_mode: ToolPermissionMode::Confirm,
163 always_allow: patterns
164 .iter()
165 .filter_map(|p| CompiledRegex::new(p, false))
166 .collect(),
167 always_deny: vec![],
168 always_confirm: vec![],
169 invalid_patterns: vec![],
170 },
171 );
172 ToolPermissions { tools }
173 }
174
175 #[test]
176 fn test_deny_takes_precedence_over_allow() {
177 let mut tools = collections::HashMap::default();
178 tools.insert(
179 Arc::from("terminal"),
180 ToolRules {
181 default_mode: ToolPermissionMode::Allow,
182 always_allow: vec![CompiledRegex::new("dangerous", false).unwrap()],
183 always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
184 always_confirm: vec![],
185 invalid_patterns: vec![],
186 },
187 );
188 let permissions = ToolPermissions { tools };
189
190 let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
191 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
192 }
193
194 #[test]
195 fn test_deny_takes_precedence_over_confirm() {
196 let mut tools = collections::HashMap::default();
197 tools.insert(
198 Arc::from("terminal"),
199 ToolRules {
200 default_mode: ToolPermissionMode::Allow,
201 always_allow: vec![],
202 always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
203 always_confirm: vec![CompiledRegex::new("dangerous", false).unwrap()],
204 invalid_patterns: vec![],
205 },
206 );
207 let permissions = ToolPermissions { tools };
208
209 let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
210 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
211 }
212
213 #[test]
214 fn test_confirm_takes_precedence_over_allow() {
215 let mut tools = collections::HashMap::default();
216 tools.insert(
217 Arc::from("terminal"),
218 ToolRules {
219 default_mode: ToolPermissionMode::Allow,
220 always_allow: vec![CompiledRegex::new("risky", false).unwrap()],
221 always_deny: vec![],
222 always_confirm: vec![CompiledRegex::new("risky", false).unwrap()],
223 invalid_patterns: vec![],
224 },
225 );
226 let permissions = ToolPermissions { tools };
227
228 let decision = decide_permission("terminal", "do risky thing", &permissions, true);
229 assert_eq!(decision, ToolPermissionDecision::Confirm);
230 }
231
232 #[test]
233 fn test_no_tool_rules_uses_global_setting() {
234 let permissions = empty_permissions();
235
236 let decision = decide_permission("terminal", "any command", &permissions, false);
237 assert_eq!(decision, ToolPermissionDecision::Confirm);
238
239 let decision = decide_permission("terminal", "any command", &permissions, true);
240 assert_eq!(decision, ToolPermissionDecision::Allow);
241 }
242
243 #[test]
244 fn test_default_mode_fallthrough() {
245 // default_mode: Allow - should allow regardless of global setting
246 let mut tools = collections::HashMap::default();
247 tools.insert(
248 Arc::from("terminal"),
249 ToolRules {
250 default_mode: ToolPermissionMode::Allow,
251 always_allow: vec![],
252 always_deny: vec![],
253 always_confirm: vec![],
254 invalid_patterns: vec![],
255 },
256 );
257 let permissions = ToolPermissions { tools };
258 let decision = decide_permission("terminal", "any command", &permissions, false);
259 assert_eq!(decision, ToolPermissionDecision::Allow);
260
261 // default_mode: Deny - should deny regardless of global setting
262 let mut tools = collections::HashMap::default();
263 tools.insert(
264 Arc::from("terminal"),
265 ToolRules {
266 default_mode: ToolPermissionMode::Deny,
267 always_allow: vec![],
268 always_deny: vec![],
269 always_confirm: vec![],
270 invalid_patterns: vec![],
271 },
272 );
273 let permissions = ToolPermissions { tools };
274 let decision = decide_permission("terminal", "any command", &permissions, true);
275 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
276
277 // default_mode: Confirm - respects global always_allow_tool_actions
278 let mut tools = collections::HashMap::default();
279 tools.insert(
280 Arc::from("terminal"),
281 ToolRules {
282 default_mode: ToolPermissionMode::Confirm,
283 always_allow: vec![],
284 always_deny: vec![],
285 always_confirm: vec![],
286 invalid_patterns: vec![],
287 },
288 );
289 let permissions = ToolPermissions { tools };
290 let decision = decide_permission("terminal", "any command", &permissions, false);
291 assert_eq!(decision, ToolPermissionDecision::Confirm);
292 let decision = decide_permission("terminal", "any command", &permissions, true);
293 assert_eq!(decision, ToolPermissionDecision::Allow);
294 }
295
296 #[test]
297 fn test_empty_input() {
298 let permissions = terminal_rules_with_deny(&["rm"]);
299
300 // Empty input doesn't match the deny pattern, so falls through to default_mode (Confirm)
301 let decision = decide_permission("terminal", "", &permissions, false);
302 assert_eq!(decision, ToolPermissionDecision::Confirm);
303
304 // With always_allow_tool_actions=true and default_mode=Confirm, it returns Allow
305 let decision = decide_permission("terminal", "", &permissions, true);
306 assert_eq!(decision, ToolPermissionDecision::Allow);
307 }
308
309 #[test]
310 fn test_multiple_patterns_any_match() {
311 // Multiple deny patterns - any match should deny
312 let permissions = terminal_rules_with_deny(&["rm", "dangerous", "delete"]);
313
314 let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
315 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
316
317 let decision = decide_permission("terminal", "delete file", &permissions, true);
318 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
319
320 // Multiple allow patterns - any match should allow
321 let permissions = terminal_rules_with_allow(&["^cargo", "^npm", "^git"]);
322
323 let decision = decide_permission("terminal", "cargo build", &permissions, false);
324 assert_eq!(decision, ToolPermissionDecision::Allow);
325
326 let decision = decide_permission("terminal", "npm install", &permissions, false);
327 assert_eq!(decision, ToolPermissionDecision::Allow);
328
329 // No pattern matches - falls through to default
330 let decision = decide_permission("terminal", "rm file", &permissions, false);
331 assert_eq!(decision, ToolPermissionDecision::Confirm);
332 }
333
334 #[test]
335 fn test_case_insensitive_matching() {
336 // Case-insensitive by default (case_sensitive: false)
337 let mut tools = collections::HashMap::default();
338 tools.insert(
339 Arc::from("terminal"),
340 ToolRules {
341 default_mode: ToolPermissionMode::Confirm,
342 always_allow: vec![],
343 always_deny: vec![CompiledRegex::new(r"\brm\b", false).unwrap()],
344 always_confirm: vec![],
345 invalid_patterns: vec![],
346 },
347 );
348 let permissions = ToolPermissions { tools };
349
350 // Should match regardless of case
351 let decision = decide_permission("terminal", "RM file.txt", &permissions, true);
352 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
353
354 let decision = decide_permission("terminal", "Rm file.txt", &permissions, true);
355 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
356
357 let decision = decide_permission("terminal", "rm file.txt", &permissions, true);
358 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
359 }
360
361 #[test]
362 fn test_case_sensitive_matching() {
363 // Case-sensitive matching when explicitly enabled
364 let mut tools = collections::HashMap::default();
365 tools.insert(
366 Arc::from("terminal"),
367 ToolRules {
368 default_mode: ToolPermissionMode::Confirm,
369 always_allow: vec![],
370 always_deny: vec![CompiledRegex::new("DROP TABLE", true).unwrap()],
371 always_confirm: vec![],
372 invalid_patterns: vec![],
373 },
374 );
375 let permissions = ToolPermissions { tools };
376
377 // Should only match exact case
378 let decision = decide_permission("terminal", "DROP TABLE users", &permissions, true);
379 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
380
381 // Should NOT match different case
382 let decision = decide_permission("terminal", "drop table users", &permissions, true);
383 assert_eq!(decision, ToolPermissionDecision::Allow);
384 }
385
386 #[test]
387 fn test_multi_tool_isolation() {
388 // Rules for terminal should not affect edit_file
389 let mut tools = collections::HashMap::default();
390 tools.insert(
391 Arc::from("terminal"),
392 ToolRules {
393 default_mode: ToolPermissionMode::Deny,
394 always_allow: vec![],
395 always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
396 always_confirm: vec![],
397 invalid_patterns: vec![],
398 },
399 );
400 tools.insert(
401 Arc::from("edit_file"),
402 ToolRules {
403 default_mode: ToolPermissionMode::Allow,
404 always_allow: vec![],
405 always_deny: vec![],
406 always_confirm: vec![],
407 invalid_patterns: vec![],
408 },
409 );
410 let permissions = ToolPermissions { tools };
411
412 // Terminal with "dangerous" should be denied
413 let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
414 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
415
416 // edit_file with "dangerous" should be allowed (no deny rules for edit_file)
417 let decision = decide_permission("edit_file", "dangerous_file.txt", &permissions, true);
418 assert_eq!(decision, ToolPermissionDecision::Allow);
419
420 // Terminal without "dangerous" should still be denied due to default_mode: Deny
421 let decision = decide_permission("terminal", "safe command", &permissions, true);
422 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
423 }
424
425 #[test]
426 fn test_invalid_patterns_block_tool() {
427 let mut tools = collections::HashMap::default();
428 tools.insert(
429 Arc::from("terminal"),
430 ToolRules {
431 default_mode: ToolPermissionMode::Allow,
432 always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
433 always_deny: vec![],
434 always_confirm: vec![],
435 invalid_patterns: vec![InvalidRegexPattern {
436 pattern: "[invalid(regex".to_string(),
437 rule_type: "always_deny".to_string(),
438 error: "unclosed character class".to_string(),
439 }],
440 },
441 );
442 let permissions = ToolPermissions { tools };
443
444 // Even though "echo" matches always_allow, the tool should be blocked
445 // because there are invalid patterns
446 let decision = decide_permission("terminal", "echo hello", &permissions, true);
447 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
448
449 if let ToolPermissionDecision::Deny(msg) = decision {
450 assert!(
451 msg.contains("regex"),
452 "error message should mention regex: {}",
453 msg
454 );
455 assert!(
456 msg.contains("settings"),
457 "error message should mention settings: {}",
458 msg
459 );
460 assert!(
461 msg.contains("terminal"),
462 "error message should mention the tool name: {}",
463 msg
464 );
465 }
466 }
467
468 #[test]
469 fn test_same_pattern_in_deny_and_allow_deny_wins() {
470 // When the same pattern appears in both deny and allow lists, deny should win
471 let mut tools = collections::HashMap::default();
472 tools.insert(
473 Arc::from("terminal"),
474 ToolRules {
475 default_mode: ToolPermissionMode::Allow,
476 always_allow: vec![CompiledRegex::new("deploy", false).unwrap()],
477 always_deny: vec![CompiledRegex::new("deploy", false).unwrap()],
478 always_confirm: vec![],
479 invalid_patterns: vec![],
480 },
481 );
482 let permissions = ToolPermissions { tools };
483
484 let decision = decide_permission("terminal", "deploy production", &permissions, true);
485 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
486 }
487
488 #[test]
489 fn test_same_pattern_in_confirm_and_allow_confirm_wins() {
490 // When the same pattern appears in both confirm and allow lists, confirm should win
491 let mut tools = collections::HashMap::default();
492 tools.insert(
493 Arc::from("terminal"),
494 ToolRules {
495 default_mode: ToolPermissionMode::Allow,
496 always_allow: vec![CompiledRegex::new("deploy", false).unwrap()],
497 always_deny: vec![],
498 always_confirm: vec![CompiledRegex::new("deploy", false).unwrap()],
499 invalid_patterns: vec![],
500 },
501 );
502 let permissions = ToolPermissions { tools };
503
504 let decision = decide_permission("terminal", "deploy production", &permissions, true);
505 assert_eq!(decision, ToolPermissionDecision::Confirm);
506 }
507
508 #[test]
509 fn test_partial_tool_name_does_not_match() {
510 // Rules for "term" should not affect "terminal"
511 let mut tools = collections::HashMap::default();
512 tools.insert(
513 Arc::from("term"),
514 ToolRules {
515 default_mode: ToolPermissionMode::Deny,
516 always_allow: vec![],
517 always_deny: vec![],
518 always_confirm: vec![],
519 invalid_patterns: vec![],
520 },
521 );
522 let permissions = ToolPermissions { tools };
523
524 // "terminal" should not be affected by "term" rules, falls back to global setting
525 let decision = decide_permission("terminal", "echo hello", &permissions, true);
526 assert_eq!(decision, ToolPermissionDecision::Allow);
527
528 let decision = decide_permission("terminal", "echo hello", &permissions, false);
529 assert_eq!(decision, ToolPermissionDecision::Confirm);
530 }
531
532 #[test]
533 fn test_very_long_input() {
534 // Test that very long inputs are handled correctly
535 let permissions = terminal_rules_with_deny(&[r"\brm\b"]);
536
537 // Long input without the pattern should not match
538 let long_safe_input = "echo ".to_string() + &"a".repeat(100_000);
539 let decision = decide_permission("terminal", &long_safe_input, &permissions, true);
540 assert_eq!(decision, ToolPermissionDecision::Allow);
541
542 // Long input with the pattern should match
543 let long_dangerous_input = "a".repeat(50_000) + " rm " + &"b".repeat(50_000);
544 let decision = decide_permission("terminal", &long_dangerous_input, &permissions, true);
545 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
546 }
547}