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_allow_tool_actions`** - When enabled, allows all tool actions except those
16/// blocked by `always_deny` patterns. This global setting takes precedence over
17/// `always_confirm` patterns and `default_mode`.
18/// 2. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
19/// This takes precedence over all other rules for security (including `always_allow_tool_actions`).
20/// 3. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
21/// the user is prompted for confirmation (unless `always_allow_tool_actions` is enabled).
22/// 4. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
23/// the tool call proceeds without prompting.
24/// 5. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
25///
26/// # Pattern Matching Tips
27///
28/// Patterns are matched as regular expressions against the tool input (e.g., the command
29/// string for the terminal tool). Some tips for writing effective patterns:
30///
31/// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
32/// match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
33/// This is important for security rules where you want to block specific commands
34/// without accidentally blocking unrelated commands that happen to contain the same
35/// substring.
36/// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
37/// - Use `^` and `$` anchors to match the start/end of the input.
38pub fn decide_permission(
39 tool_name: &str,
40 input: &str,
41 permissions: &ToolPermissions,
42 always_allow_tool_actions: bool,
43) -> ToolPermissionDecision {
44 let rules = permissions.tools.get(tool_name);
45
46 let rules = match rules {
47 Some(rules) => rules,
48 None => {
49 return if always_allow_tool_actions {
50 ToolPermissionDecision::Allow
51 } else {
52 ToolPermissionDecision::Confirm
53 };
54 }
55 };
56
57 // Check for invalid regex patterns before evaluating rules.
58 // If any patterns failed to compile, block the tool call entirely.
59 if let Some(error) = check_invalid_patterns(tool_name, rules) {
60 return ToolPermissionDecision::Deny(error);
61 }
62
63 if rules.always_deny.iter().any(|r| r.is_match(input)) {
64 return ToolPermissionDecision::Deny(format!(
65 "Command blocked by security rule for {} tool",
66 tool_name
67 ));
68 }
69
70 if rules.always_confirm.iter().any(|r| r.is_match(input)) {
71 if !always_allow_tool_actions {
72 return ToolPermissionDecision::Confirm;
73 }
74 }
75
76 if rules.always_allow.iter().any(|r| r.is_match(input)) {
77 return ToolPermissionDecision::Allow;
78 }
79
80 if always_allow_tool_actions {
81 return ToolPermissionDecision::Allow;
82 }
83
84 match rules.default_mode {
85 ToolPermissionMode::Deny => {
86 ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
87 }
88 ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
89 ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
90 }
91}
92
93/// Checks if the tool rules contain any invalid regex patterns.
94/// Returns an error message if invalid patterns are found.
95fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
96 if rules.invalid_patterns.is_empty() {
97 return None;
98 }
99
100 let count = rules.invalid_patterns.len();
101 let pattern_word = if count == 1 { "pattern" } else { "patterns" };
102
103 Some(format!(
104 "The {} tool cannot run because {} regex {} failed to compile. \
105 Please fix the invalid patterns in your tool_permissions settings.",
106 tool_name, count, pattern_word
107 ))
108}
109
110/// Convenience wrapper that extracts permission settings from `AgentSettings`.
111///
112/// This is the primary entry point for tools to check permissions. It extracts
113/// `tool_permissions` and `always_allow_tool_actions` from the settings and
114/// delegates to [`decide_permission`].
115pub fn decide_permission_from_settings(
116 tool_name: &str,
117 input: &str,
118 settings: &AgentSettings,
119) -> ToolPermissionDecision {
120 decide_permission(
121 tool_name,
122 input,
123 &settings.tool_permissions,
124 settings.always_allow_tool_actions,
125 )
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
132 use std::sync::Arc;
133
134 struct PermTest {
135 tool: &'static str,
136 input: &'static str,
137 mode: ToolPermissionMode,
138 allow: Vec<&'static str>,
139 deny: Vec<&'static str>,
140 confirm: Vec<&'static str>,
141 global: bool,
142 }
143
144 impl PermTest {
145 fn new(input: &'static str) -> Self {
146 Self {
147 tool: "terminal",
148 input,
149 mode: ToolPermissionMode::Confirm,
150 allow: vec![],
151 deny: vec![],
152 confirm: vec![],
153 global: false,
154 }
155 }
156
157 fn tool(mut self, t: &'static str) -> Self {
158 self.tool = t;
159 self
160 }
161 fn mode(mut self, m: ToolPermissionMode) -> Self {
162 self.mode = m;
163 self
164 }
165 fn allow(mut self, p: &[&'static str]) -> Self {
166 self.allow = p.to_vec();
167 self
168 }
169 fn deny(mut self, p: &[&'static str]) -> Self {
170 self.deny = p.to_vec();
171 self
172 }
173 fn confirm(mut self, p: &[&'static str]) -> Self {
174 self.confirm = p.to_vec();
175 self
176 }
177 fn global(mut self, g: bool) -> Self {
178 self.global = g;
179 self
180 }
181
182 fn is_allow(self) {
183 assert_eq!(
184 self.run(),
185 ToolPermissionDecision::Allow,
186 "expected Allow for '{}'",
187 self.input
188 );
189 }
190 fn is_deny(self) {
191 assert!(
192 matches!(self.run(), ToolPermissionDecision::Deny(_)),
193 "expected Deny for '{}'",
194 self.input
195 );
196 }
197 fn is_confirm(self) {
198 assert_eq!(
199 self.run(),
200 ToolPermissionDecision::Confirm,
201 "expected Confirm for '{}'",
202 self.input
203 );
204 }
205
206 fn run(&self) -> ToolPermissionDecision {
207 let mut tools = collections::HashMap::default();
208 tools.insert(
209 Arc::from(self.tool),
210 ToolRules {
211 default_mode: self.mode,
212 always_allow: self
213 .allow
214 .iter()
215 .filter_map(|p| CompiledRegex::new(p, false))
216 .collect(),
217 always_deny: self
218 .deny
219 .iter()
220 .filter_map(|p| CompiledRegex::new(p, false))
221 .collect(),
222 always_confirm: self
223 .confirm
224 .iter()
225 .filter_map(|p| CompiledRegex::new(p, false))
226 .collect(),
227 invalid_patterns: vec![],
228 },
229 );
230 decide_permission(
231 self.tool,
232 self.input,
233 &ToolPermissions { tools },
234 self.global,
235 )
236 }
237 }
238
239 fn t(input: &'static str) -> PermTest {
240 PermTest::new(input)
241 }
242
243 fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
244 decide_permission(
245 "terminal",
246 input,
247 &ToolPermissions {
248 tools: collections::HashMap::default(),
249 },
250 global,
251 )
252 }
253
254 // allow pattern matches
255 #[test]
256 fn allow_exact_match() {
257 t("cargo test").allow(&["^cargo\\s"]).is_allow();
258 }
259 #[test]
260 fn allow_with_args() {
261 t("cargo build --release").allow(&["^cargo\\s"]).is_allow();
262 }
263 #[test]
264 fn allow_one_of_many() {
265 t("npm install").allow(&["^cargo\\s", "^npm\\s"]).is_allow();
266 }
267 #[test]
268 fn allow_middle_pattern() {
269 t("run cargo now").allow(&["cargo"]).is_allow();
270 }
271 #[test]
272 fn allow_anchor_prevents_middle() {
273 t("run cargo now").allow(&["^cargo"]).is_confirm();
274 }
275
276 // allow pattern doesn't match -> falls through
277 #[test]
278 fn allow_no_match_confirms() {
279 t("python x.py").allow(&["^cargo\\s"]).is_confirm();
280 }
281 #[test]
282 fn allow_no_match_global_allows() {
283 t("python x.py")
284 .allow(&["^cargo\\s"])
285 .global(true)
286 .is_allow();
287 }
288
289 // deny pattern matches
290 #[test]
291 fn deny_blocks() {
292 t("rm -rf /").deny(&["rm\\s+-rf"]).is_deny();
293 }
294 #[test]
295 fn deny_blocks_with_global() {
296 t("rm -rf /").deny(&["rm\\s+-rf"]).global(true).is_deny();
297 }
298 #[test]
299 fn deny_blocks_with_mode_allow() {
300 t("rm -rf /")
301 .deny(&["rm\\s+-rf"])
302 .mode(ToolPermissionMode::Allow)
303 .is_deny();
304 }
305 #[test]
306 fn deny_middle_match() {
307 t("echo rm -rf x").deny(&["rm\\s+-rf"]).is_deny();
308 }
309 #[test]
310 fn deny_no_match_allows() {
311 t("ls -la").deny(&["rm\\s+-rf"]).global(true).is_allow();
312 }
313
314 // confirm pattern matches
315 #[test]
316 fn confirm_requires_confirm() {
317 t("sudo apt install").confirm(&["sudo\\s"]).is_confirm();
318 }
319 #[test]
320 fn global_overrides_confirm() {
321 t("sudo reboot")
322 .confirm(&["sudo\\s"])
323 .global(true)
324 .is_allow();
325 }
326 #[test]
327 fn confirm_overrides_mode_allow() {
328 t("sudo x")
329 .confirm(&["sudo"])
330 .mode(ToolPermissionMode::Allow)
331 .is_confirm();
332 }
333
334 // confirm beats allow
335 #[test]
336 fn confirm_beats_allow() {
337 t("git push --force")
338 .allow(&["^git\\s"])
339 .confirm(&["--force"])
340 .is_confirm();
341 }
342 #[test]
343 fn confirm_beats_allow_overlap() {
344 t("deploy prod")
345 .allow(&["deploy"])
346 .confirm(&["prod"])
347 .is_confirm();
348 }
349 #[test]
350 fn allow_when_confirm_no_match() {
351 t("git status")
352 .allow(&["^git\\s"])
353 .confirm(&["--force"])
354 .is_allow();
355 }
356
357 // deny beats allow
358 #[test]
359 fn deny_beats_allow() {
360 t("rm -rf /tmp/x")
361 .allow(&["/tmp/"])
362 .deny(&["rm\\s+-rf"])
363 .is_deny();
364 }
365 #[test]
366 fn deny_beats_allow_diff() {
367 t("bad deploy").allow(&["deploy"]).deny(&["bad"]).is_deny();
368 }
369
370 // deny beats confirm
371 #[test]
372 fn deny_beats_confirm() {
373 t("sudo rm -rf /")
374 .confirm(&["sudo"])
375 .deny(&["rm\\s+-rf"])
376 .is_deny();
377 }
378
379 // deny beats everything
380 #[test]
381 fn deny_beats_all() {
382 t("bad cmd")
383 .allow(&["cmd"])
384 .confirm(&["cmd"])
385 .deny(&["bad"])
386 .is_deny();
387 }
388
389 // no patterns -> default_mode
390 #[test]
391 fn default_confirm() {
392 t("python x.py")
393 .mode(ToolPermissionMode::Confirm)
394 .is_confirm();
395 }
396 #[test]
397 fn default_allow() {
398 t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
399 }
400 #[test]
401 fn default_deny() {
402 t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
403 }
404 #[test]
405 fn default_deny_global_true() {
406 t("python x.py")
407 .mode(ToolPermissionMode::Deny)
408 .global(true)
409 .is_allow();
410 }
411
412 // default_mode confirm + global
413 #[test]
414 fn default_confirm_global_false() {
415 t("x")
416 .mode(ToolPermissionMode::Confirm)
417 .global(false)
418 .is_confirm();
419 }
420 #[test]
421 fn default_confirm_global_true() {
422 t("x")
423 .mode(ToolPermissionMode::Confirm)
424 .global(true)
425 .is_allow();
426 }
427
428 // no rules at all -> global setting
429 #[test]
430 fn no_rules_global_false() {
431 assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
432 }
433 #[test]
434 fn no_rules_global_true() {
435 assert_eq!(no_rules("x", true), ToolPermissionDecision::Allow);
436 }
437
438 // empty input
439 #[test]
440 fn empty_input_no_match() {
441 t("").deny(&["rm"]).is_confirm();
442 }
443 #[test]
444 fn empty_input_global() {
445 t("").deny(&["rm"]).global(true).is_allow();
446 }
447
448 // multiple patterns - any match
449 #[test]
450 fn multi_deny_first() {
451 t("rm x").deny(&["rm", "del", "drop"]).is_deny();
452 }
453 #[test]
454 fn multi_deny_last() {
455 t("drop x").deny(&["rm", "del", "drop"]).is_deny();
456 }
457 #[test]
458 fn multi_allow_first() {
459 t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
460 }
461 #[test]
462 fn multi_allow_last() {
463 t("git x").allow(&["^cargo", "^npm", "^git"]).is_allow();
464 }
465 #[test]
466 fn multi_none_match() {
467 t("python x")
468 .allow(&["^cargo", "^npm"])
469 .deny(&["rm"])
470 .is_confirm();
471 }
472
473 // tool isolation
474 #[test]
475 fn other_tool_not_affected() {
476 let mut tools = collections::HashMap::default();
477 tools.insert(
478 Arc::from("terminal"),
479 ToolRules {
480 default_mode: ToolPermissionMode::Deny,
481 always_allow: vec![],
482 always_deny: vec![],
483 always_confirm: vec![],
484 invalid_patterns: vec![],
485 },
486 );
487 tools.insert(
488 Arc::from("edit_file"),
489 ToolRules {
490 default_mode: ToolPermissionMode::Allow,
491 always_allow: vec![],
492 always_deny: vec![],
493 always_confirm: vec![],
494 invalid_patterns: vec![],
495 },
496 );
497 let p = ToolPermissions { tools };
498 // With always_allow_tool_actions=true, even default_mode: Deny is overridden
499 assert_eq!(
500 decide_permission("terminal", "x", &p, true),
501 ToolPermissionDecision::Allow
502 );
503 // With always_allow_tool_actions=false, default_mode: Deny is respected
504 assert!(matches!(
505 decide_permission("terminal", "x", &p, false),
506 ToolPermissionDecision::Deny(_)
507 ));
508 assert_eq!(
509 decide_permission("edit_file", "x", &p, false),
510 ToolPermissionDecision::Allow
511 );
512 }
513
514 #[test]
515 fn partial_tool_name_no_match() {
516 let mut tools = collections::HashMap::default();
517 tools.insert(
518 Arc::from("term"),
519 ToolRules {
520 default_mode: ToolPermissionMode::Deny,
521 always_allow: vec![],
522 always_deny: vec![],
523 always_confirm: vec![],
524 invalid_patterns: vec![],
525 },
526 );
527 let p = ToolPermissions { tools };
528 assert_eq!(
529 decide_permission("terminal", "x", &p, true),
530 ToolPermissionDecision::Allow
531 );
532 }
533
534 // invalid patterns block the tool
535 #[test]
536 fn invalid_pattern_blocks() {
537 let mut tools = collections::HashMap::default();
538 tools.insert(
539 Arc::from("terminal"),
540 ToolRules {
541 default_mode: ToolPermissionMode::Allow,
542 always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
543 always_deny: vec![],
544 always_confirm: vec![],
545 invalid_patterns: vec![InvalidRegexPattern {
546 pattern: "[bad".into(),
547 rule_type: "always_deny".into(),
548 error: "err".into(),
549 }],
550 },
551 );
552 let p = ToolPermissions { tools };
553 assert!(matches!(
554 decide_permission("terminal", "echo hi", &p, true),
555 ToolPermissionDecision::Deny(_)
556 ));
557 }
558
559 // user scenario: only echo allowed, git should confirm
560 #[test]
561 fn user_scenario_only_echo() {
562 t("echo hello").allow(&["^echo\\s"]).is_allow();
563 }
564 #[test]
565 fn user_scenario_git_confirms() {
566 t("git status").allow(&["^echo\\s"]).is_confirm();
567 }
568 #[test]
569 fn user_scenario_rm_confirms() {
570 t("rm -rf /").allow(&["^echo\\s"]).is_confirm();
571 }
572
573 // mcp tools
574 #[test]
575 fn mcp_allow() {
576 t("")
577 .tool("mcp:fs:read")
578 .mode(ToolPermissionMode::Allow)
579 .is_allow();
580 }
581 #[test]
582 fn mcp_deny() {
583 t("")
584 .tool("mcp:bad:del")
585 .mode(ToolPermissionMode::Deny)
586 .is_deny();
587 }
588 #[test]
589 fn mcp_confirm() {
590 t("")
591 .tool("mcp:gh:issue")
592 .mode(ToolPermissionMode::Confirm)
593 .is_confirm();
594 }
595 #[test]
596 fn mcp_confirm_global() {
597 t("")
598 .tool("mcp:gh:issue")
599 .mode(ToolPermissionMode::Confirm)
600 .global(true)
601 .is_allow();
602 }
603
604 // mcp vs builtin isolation
605 #[test]
606 fn mcp_doesnt_collide_with_builtin() {
607 let mut tools = collections::HashMap::default();
608 tools.insert(
609 Arc::from("terminal"),
610 ToolRules {
611 default_mode: ToolPermissionMode::Deny,
612 always_allow: vec![],
613 always_deny: vec![],
614 always_confirm: vec![],
615 invalid_patterns: vec![],
616 },
617 );
618 tools.insert(
619 Arc::from("mcp:srv:terminal"),
620 ToolRules {
621 default_mode: ToolPermissionMode::Allow,
622 always_allow: vec![],
623 always_deny: vec![],
624 always_confirm: vec![],
625 invalid_patterns: vec![],
626 },
627 );
628 let p = ToolPermissions { tools };
629 assert!(matches!(
630 decide_permission("terminal", "x", &p, false),
631 ToolPermissionDecision::Deny(_)
632 ));
633 assert_eq!(
634 decide_permission("mcp:srv:terminal", "x", &p, false),
635 ToolPermissionDecision::Allow
636 );
637 }
638}