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 struct PermTest {
134 tool: &'static str,
135 input: &'static str,
136 mode: ToolPermissionMode,
137 allow: Vec<&'static str>,
138 deny: Vec<&'static str>,
139 confirm: Vec<&'static str>,
140 global: bool,
141 }
142
143 impl PermTest {
144 fn new(input: &'static str) -> Self {
145 Self {
146 tool: "terminal",
147 input,
148 mode: ToolPermissionMode::Confirm,
149 allow: vec![],
150 deny: vec![],
151 confirm: vec![],
152 global: false,
153 }
154 }
155
156 fn tool(mut self, t: &'static str) -> Self {
157 self.tool = t;
158 self
159 }
160 fn mode(mut self, m: ToolPermissionMode) -> Self {
161 self.mode = m;
162 self
163 }
164 fn allow(mut self, p: &[&'static str]) -> Self {
165 self.allow = p.to_vec();
166 self
167 }
168 fn deny(mut self, p: &[&'static str]) -> Self {
169 self.deny = p.to_vec();
170 self
171 }
172 fn confirm(mut self, p: &[&'static str]) -> Self {
173 self.confirm = p.to_vec();
174 self
175 }
176 fn global(mut self, g: bool) -> Self {
177 self.global = g;
178 self
179 }
180
181 fn is_allow(self) {
182 assert_eq!(
183 self.run(),
184 ToolPermissionDecision::Allow,
185 "expected Allow for '{}'",
186 self.input
187 );
188 }
189 fn is_deny(self) {
190 assert!(
191 matches!(self.run(), ToolPermissionDecision::Deny(_)),
192 "expected Deny for '{}'",
193 self.input
194 );
195 }
196 fn is_confirm(self) {
197 assert_eq!(
198 self.run(),
199 ToolPermissionDecision::Confirm,
200 "expected Confirm for '{}'",
201 self.input
202 );
203 }
204
205 fn run(&self) -> ToolPermissionDecision {
206 let mut tools = collections::HashMap::default();
207 tools.insert(
208 Arc::from(self.tool),
209 ToolRules {
210 default_mode: self.mode,
211 always_allow: self
212 .allow
213 .iter()
214 .filter_map(|p| CompiledRegex::new(p, false))
215 .collect(),
216 always_deny: self
217 .deny
218 .iter()
219 .filter_map(|p| CompiledRegex::new(p, false))
220 .collect(),
221 always_confirm: self
222 .confirm
223 .iter()
224 .filter_map(|p| CompiledRegex::new(p, false))
225 .collect(),
226 invalid_patterns: vec![],
227 },
228 );
229 decide_permission(
230 self.tool,
231 self.input,
232 &ToolPermissions { tools },
233 self.global,
234 )
235 }
236 }
237
238 fn t(input: &'static str) -> PermTest {
239 PermTest::new(input)
240 }
241
242 fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
243 decide_permission(
244 "terminal",
245 input,
246 &ToolPermissions {
247 tools: collections::HashMap::default(),
248 },
249 global,
250 )
251 }
252
253 // allow pattern matches
254 #[test]
255 fn allow_exact_match() {
256 t("cargo test").allow(&["^cargo\\s"]).is_allow();
257 }
258 #[test]
259 fn allow_with_args() {
260 t("cargo build --release").allow(&["^cargo\\s"]).is_allow();
261 }
262 #[test]
263 fn allow_one_of_many() {
264 t("npm install").allow(&["^cargo\\s", "^npm\\s"]).is_allow();
265 }
266 #[test]
267 fn allow_middle_pattern() {
268 t("run cargo now").allow(&["cargo"]).is_allow();
269 }
270 #[test]
271 fn allow_anchor_prevents_middle() {
272 t("run cargo now").allow(&["^cargo"]).is_confirm();
273 }
274
275 // allow pattern doesn't match -> falls through
276 #[test]
277 fn allow_no_match_confirms() {
278 t("python x.py").allow(&["^cargo\\s"]).is_confirm();
279 }
280 #[test]
281 fn allow_no_match_global_allows() {
282 t("python x.py")
283 .allow(&["^cargo\\s"])
284 .global(true)
285 .is_allow();
286 }
287
288 // deny pattern matches
289 #[test]
290 fn deny_blocks() {
291 t("rm -rf /").deny(&["rm\\s+-rf"]).is_deny();
292 }
293 #[test]
294 fn deny_blocks_with_global() {
295 t("rm -rf /").deny(&["rm\\s+-rf"]).global(true).is_deny();
296 }
297 #[test]
298 fn deny_blocks_with_mode_allow() {
299 t("rm -rf /")
300 .deny(&["rm\\s+-rf"])
301 .mode(ToolPermissionMode::Allow)
302 .is_deny();
303 }
304 #[test]
305 fn deny_middle_match() {
306 t("echo rm -rf x").deny(&["rm\\s+-rf"]).is_deny();
307 }
308 #[test]
309 fn deny_no_match_allows() {
310 t("ls -la").deny(&["rm\\s+-rf"]).global(true).is_allow();
311 }
312
313 // confirm pattern matches
314 #[test]
315 fn confirm_requires_confirm() {
316 t("sudo apt install").confirm(&["sudo\\s"]).is_confirm();
317 }
318 #[test]
319 fn confirm_overrides_global() {
320 t("sudo reboot")
321 .confirm(&["sudo\\s"])
322 .global(true)
323 .is_confirm();
324 }
325 #[test]
326 fn confirm_overrides_mode_allow() {
327 t("sudo x")
328 .confirm(&["sudo"])
329 .mode(ToolPermissionMode::Allow)
330 .is_confirm();
331 }
332
333 // confirm beats allow
334 #[test]
335 fn confirm_beats_allow() {
336 t("git push --force")
337 .allow(&["^git\\s"])
338 .confirm(&["--force"])
339 .is_confirm();
340 }
341 #[test]
342 fn confirm_beats_allow_overlap() {
343 t("deploy prod")
344 .allow(&["deploy"])
345 .confirm(&["prod"])
346 .is_confirm();
347 }
348 #[test]
349 fn allow_when_confirm_no_match() {
350 t("git status")
351 .allow(&["^git\\s"])
352 .confirm(&["--force"])
353 .is_allow();
354 }
355
356 // deny beats allow
357 #[test]
358 fn deny_beats_allow() {
359 t("rm -rf /tmp/x")
360 .allow(&["/tmp/"])
361 .deny(&["rm\\s+-rf"])
362 .is_deny();
363 }
364 #[test]
365 fn deny_beats_allow_diff() {
366 t("bad deploy").allow(&["deploy"]).deny(&["bad"]).is_deny();
367 }
368
369 // deny beats confirm
370 #[test]
371 fn deny_beats_confirm() {
372 t("sudo rm -rf /")
373 .confirm(&["sudo"])
374 .deny(&["rm\\s+-rf"])
375 .is_deny();
376 }
377
378 // deny beats everything
379 #[test]
380 fn deny_beats_all() {
381 t("bad cmd")
382 .allow(&["cmd"])
383 .confirm(&["cmd"])
384 .deny(&["bad"])
385 .is_deny();
386 }
387
388 // no patterns -> default_mode
389 #[test]
390 fn default_confirm() {
391 t("python x.py")
392 .mode(ToolPermissionMode::Confirm)
393 .is_confirm();
394 }
395 #[test]
396 fn default_allow() {
397 t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
398 }
399 #[test]
400 fn default_deny() {
401 t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
402 }
403
404 // default_mode confirm + global
405 #[test]
406 fn default_confirm_global_false() {
407 t("x")
408 .mode(ToolPermissionMode::Confirm)
409 .global(false)
410 .is_confirm();
411 }
412 #[test]
413 fn default_confirm_global_true() {
414 t("x")
415 .mode(ToolPermissionMode::Confirm)
416 .global(true)
417 .is_allow();
418 }
419
420 // no rules at all -> global setting
421 #[test]
422 fn no_rules_global_false() {
423 assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
424 }
425 #[test]
426 fn no_rules_global_true() {
427 assert_eq!(no_rules("x", true), ToolPermissionDecision::Allow);
428 }
429
430 // empty input
431 #[test]
432 fn empty_input_no_match() {
433 t("").deny(&["rm"]).is_confirm();
434 }
435 #[test]
436 fn empty_input_global() {
437 t("").deny(&["rm"]).global(true).is_allow();
438 }
439
440 // multiple patterns - any match
441 #[test]
442 fn multi_deny_first() {
443 t("rm x").deny(&["rm", "del", "drop"]).is_deny();
444 }
445 #[test]
446 fn multi_deny_last() {
447 t("drop x").deny(&["rm", "del", "drop"]).is_deny();
448 }
449 #[test]
450 fn multi_allow_first() {
451 t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
452 }
453 #[test]
454 fn multi_allow_last() {
455 t("git x").allow(&["^cargo", "^npm", "^git"]).is_allow();
456 }
457 #[test]
458 fn multi_none_match() {
459 t("python x")
460 .allow(&["^cargo", "^npm"])
461 .deny(&["rm"])
462 .is_confirm();
463 }
464
465 // tool isolation
466 #[test]
467 fn other_tool_not_affected() {
468 let mut tools = collections::HashMap::default();
469 tools.insert(
470 Arc::from("terminal"),
471 ToolRules {
472 default_mode: ToolPermissionMode::Deny,
473 always_allow: vec![],
474 always_deny: vec![],
475 always_confirm: vec![],
476 invalid_patterns: vec![],
477 },
478 );
479 tools.insert(
480 Arc::from("edit_file"),
481 ToolRules {
482 default_mode: ToolPermissionMode::Allow,
483 always_allow: vec![],
484 always_deny: vec![],
485 always_confirm: vec![],
486 invalid_patterns: vec![],
487 },
488 );
489 let p = ToolPermissions { tools };
490 assert!(matches!(
491 decide_permission("terminal", "x", &p, true),
492 ToolPermissionDecision::Deny(_)
493 ));
494 assert_eq!(
495 decide_permission("edit_file", "x", &p, false),
496 ToolPermissionDecision::Allow
497 );
498 }
499
500 #[test]
501 fn partial_tool_name_no_match() {
502 let mut tools = collections::HashMap::default();
503 tools.insert(
504 Arc::from("term"),
505 ToolRules {
506 default_mode: ToolPermissionMode::Deny,
507 always_allow: vec![],
508 always_deny: vec![],
509 always_confirm: vec![],
510 invalid_patterns: vec![],
511 },
512 );
513 let p = ToolPermissions { tools };
514 assert_eq!(
515 decide_permission("terminal", "x", &p, true),
516 ToolPermissionDecision::Allow
517 );
518 }
519
520 // invalid patterns block the tool
521 #[test]
522 fn invalid_pattern_blocks() {
523 let mut tools = collections::HashMap::default();
524 tools.insert(
525 Arc::from("terminal"),
526 ToolRules {
527 default_mode: ToolPermissionMode::Allow,
528 always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
529 always_deny: vec![],
530 always_confirm: vec![],
531 invalid_patterns: vec![InvalidRegexPattern {
532 pattern: "[bad".into(),
533 rule_type: "always_deny".into(),
534 error: "err".into(),
535 }],
536 },
537 );
538 let p = ToolPermissions { tools };
539 assert!(matches!(
540 decide_permission("terminal", "echo hi", &p, true),
541 ToolPermissionDecision::Deny(_)
542 ));
543 }
544
545 // user scenario: only echo allowed, git should confirm
546 #[test]
547 fn user_scenario_only_echo() {
548 t("echo hello").allow(&["^echo\\s"]).is_allow();
549 }
550 #[test]
551 fn user_scenario_git_confirms() {
552 t("git status").allow(&["^echo\\s"]).is_confirm();
553 }
554 #[test]
555 fn user_scenario_rm_confirms() {
556 t("rm -rf /").allow(&["^echo\\s"]).is_confirm();
557 }
558
559 // mcp tools
560 #[test]
561 fn mcp_allow() {
562 t("")
563 .tool("mcp:fs:read")
564 .mode(ToolPermissionMode::Allow)
565 .is_allow();
566 }
567 #[test]
568 fn mcp_deny() {
569 t("")
570 .tool("mcp:bad:del")
571 .mode(ToolPermissionMode::Deny)
572 .is_deny();
573 }
574 #[test]
575 fn mcp_confirm() {
576 t("")
577 .tool("mcp:gh:issue")
578 .mode(ToolPermissionMode::Confirm)
579 .is_confirm();
580 }
581 #[test]
582 fn mcp_confirm_global() {
583 t("")
584 .tool("mcp:gh:issue")
585 .mode(ToolPermissionMode::Confirm)
586 .global(true)
587 .is_allow();
588 }
589
590 // mcp vs builtin isolation
591 #[test]
592 fn mcp_doesnt_collide_with_builtin() {
593 let mut tools = collections::HashMap::default();
594 tools.insert(
595 Arc::from("terminal"),
596 ToolRules {
597 default_mode: ToolPermissionMode::Deny,
598 always_allow: vec![],
599 always_deny: vec![],
600 always_confirm: vec![],
601 invalid_patterns: vec![],
602 },
603 );
604 tools.insert(
605 Arc::from("mcp:srv:terminal"),
606 ToolRules {
607 default_mode: ToolPermissionMode::Allow,
608 always_allow: vec![],
609 always_deny: vec![],
610 always_confirm: vec![],
611 invalid_patterns: vec![],
612 },
613 );
614 let p = ToolPermissions { tools };
615 assert!(matches!(
616 decide_permission("terminal", "x", &p, false),
617 ToolPermissionDecision::Deny(_)
618 ));
619 assert_eq!(
620 decide_permission("mcp:srv:terminal", "x", &p, false),
621 ToolPermissionDecision::Allow
622 );
623 }
624}