1use crate::AgentTool;
2use crate::shell_parser::extract_commands;
3use crate::tools::TerminalTool;
4use agent_settings::{AgentSettings, ToolPermissions, ToolRules};
5use settings::ToolPermissionMode;
6use util::shell::ShellKind;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum ToolPermissionDecision {
10 Allow,
11 Deny(String),
12 Confirm,
13}
14
15impl ToolPermissionDecision {
16 /// Determines the permission decision for a tool invocation based on configured rules.
17 ///
18 /// # Precedence Order (highest to lowest)
19 ///
20 /// 1. **`always_allow_tool_actions`** - When enabled, allows all tool actions without
21 /// prompting. This global setting bypasses all other checks including deny patterns.
22 /// Use with caution as it disables all security rules.
23 /// 2. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
24 /// This takes precedence over `always_confirm` and `always_allow` patterns.
25 /// 3. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
26 /// the user is prompted for confirmation.
27 /// 4. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
28 /// the tool call proceeds without prompting.
29 /// 5. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
30 ///
31 /// # Shell Compatibility (Terminal Tool Only)
32 ///
33 /// For the terminal tool, commands are parsed using brush-parser to extract sub-commands
34 /// for security. This ensures that patterns like `^cargo\b` cannot be bypassed with
35 /// shell injection like `cargo && rm -rf /`.
36 ///
37 /// The parser handles `;` (sequential execution), `|` (piping), `&&` and `||`
38 /// (conditional execution), `$()` and backticks (command substitution), and process
39 /// substitution.
40 ///
41 /// # Shell Notes
42 ///
43 /// - **Nushell**: Uses `;` for sequential execution. The `and`/`or` keywords are
44 /// boolean operators on values (e.g., `$true and $false`), not command chaining.
45 /// - **Elvish**: Uses `;` to separate pipelines, which brush-parser handles. Elvish
46 /// does not have `&&` or `||` operators. Its `and`/`or` are special commands that
47 /// operate on values, not command chaining (e.g., `and $true $false`).
48 /// - **Rc (Plan 9)**: Uses `;` for sequential execution. Does not have `&&`/`||`.
49 ///
50 /// # Pattern Matching Tips
51 ///
52 /// Patterns are matched as regular expressions against the tool input (e.g., the command
53 /// string for the terminal tool). Some tips for writing effective patterns:
54 ///
55 /// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
56 /// match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
57 /// This is important for security rules where you want to block specific commands
58 /// without accidentally blocking unrelated commands that happen to contain the same
59 /// substring.
60 /// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
61 /// - Use `^` and `$` anchors to match the start/end of the input.
62 pub fn from_input(
63 tool_name: &str,
64 input: &str,
65 permissions: &ToolPermissions,
66 always_allow_tool_actions: bool,
67 shell_kind: Option<ShellKind>,
68 ) -> ToolPermissionDecision {
69 // If always_allow_tool_actions is enabled, bypass all permission checks.
70 // This is intentionally placed first - it's a global override that the user
71 // must explicitly enable, understanding that it bypasses all security rules.
72 if always_allow_tool_actions {
73 return ToolPermissionDecision::Allow;
74 }
75
76 let rules = match permissions.tools.get(tool_name) {
77 Some(rules) => rules,
78 None => {
79 return ToolPermissionDecision::Confirm;
80 }
81 };
82
83 // Check for invalid regex patterns before evaluating rules.
84 // If any patterns failed to compile, block the tool call entirely.
85 if let Some(error) = check_invalid_patterns(tool_name, rules) {
86 return ToolPermissionDecision::Deny(error);
87 }
88
89 // For the terminal tool, parse the command to extract all sub-commands.
90 // This prevents shell injection attacks where a user configures an allow
91 // pattern like "^ls" and an attacker crafts "ls && rm -rf /".
92 //
93 // If parsing fails or the shell syntax is unsupported, always_allow is
94 // disabled for this command (we set allow_enabled to false to signal this).
95 if tool_name == TerminalTool::name() {
96 // Check if this shell's syntax can be parsed by brush-parser.
97 // See the doc comment above for shell compatibility notes.
98 let supports_chaining = shell_kind
99 .map(|k| k.supports_posix_chaining())
100 .unwrap_or(false);
101 if !supports_chaining {
102 // For shells with incompatible syntax, we can't reliably parse
103 // the command to extract sub-commands.
104 if !rules.always_allow.is_empty() {
105 // If the user has configured always_allow patterns, we must deny
106 // because we can't safely verify the command doesn't contain
107 // hidden sub-commands that bypass the allow patterns.
108 const SUFFIX: &str = " does not support \"always allow\" patterns for the \
109 terminal tool because Zed cannot parse its command chaining syntax. \
110 Please remove the always_allow patterns from your tool_permissions \
111 settings, or switch to a supported shell.";
112 let message = if let Some(name) = shell_kind.map(|k| k.name()) {
113 format!("The {} shell{}", name, SUFFIX)
114 } else {
115 format!("This shell is unrecognized, and{}", SUFFIX)
116 };
117 return ToolPermissionDecision::Deny(message);
118 }
119 // No always_allow rules, so we can still check deny/confirm patterns.
120 return check_commands(std::iter::once(input.to_string()), rules, tool_name, false);
121 }
122
123 match extract_commands(input) {
124 Some(commands) => check_commands(commands, rules, tool_name, true),
125 None => {
126 // The command failed to parse, so we check to see if we should auto-deny
127 // or auto-confirm; if neither auto-deny nor auto-confirm applies here,
128 // fall back on the default (based on the user's settings, which is Confirm
129 // if not specified otherwise). Ignore "always allow" when it failed to parse.
130 check_commands(std::iter::once(input.to_string()), rules, tool_name, false)
131 }
132 }
133 } else {
134 check_commands(std::iter::once(input.to_string()), rules, tool_name, true)
135 }
136 }
137}
138
139/// Evaluates permission rules against a set of commands.
140///
141/// This function performs a single pass through all commands with the following logic:
142/// - **DENY**: If ANY command matches a deny pattern, deny immediately (short-circuit)
143/// - **CONFIRM**: Track if ANY command matches a confirm pattern
144/// - **ALLOW**: Track if ALL commands match at least one allow pattern
145///
146/// The `allow_enabled` flag controls whether allow patterns are checked. This is set
147/// to `false` when we can't reliably parse shell commands (e.g., parse failures or
148/// unsupported shell syntax), ensuring we don't auto-allow potentially dangerous commands.
149fn check_commands(
150 commands: impl IntoIterator<Item = String>,
151 rules: &ToolRules,
152 tool_name: &str,
153 allow_enabled: bool,
154) -> ToolPermissionDecision {
155 // Single pass through all commands:
156 // - DENY: If ANY command matches a deny pattern, deny immediately (short-circuit)
157 // - CONFIRM: Track if ANY command matches a confirm pattern
158 // - ALLOW: Track if ALL commands match at least one allow pattern
159 let mut any_matched_confirm = false;
160 let mut all_matched_allow = true;
161 let mut had_any_commands = false;
162
163 for command in commands {
164 had_any_commands = true;
165
166 // DENY: immediate return if any command matches a deny pattern
167 if rules.always_deny.iter().any(|r| r.is_match(&command)) {
168 return ToolPermissionDecision::Deny(format!(
169 "Command blocked by security rule for {} tool",
170 tool_name
171 ));
172 }
173
174 // CONFIRM: remember if any command matches a confirm pattern
175 if rules.always_confirm.iter().any(|r| r.is_match(&command)) {
176 any_matched_confirm = true;
177 }
178
179 // ALLOW: track if all commands match at least one allow pattern
180 if !rules.always_allow.iter().any(|r| r.is_match(&command)) {
181 all_matched_allow = false;
182 }
183 }
184
185 // After processing all commands, check accumulated state
186 if any_matched_confirm {
187 return ToolPermissionDecision::Confirm;
188 }
189
190 if allow_enabled && all_matched_allow && had_any_commands {
191 return ToolPermissionDecision::Allow;
192 }
193
194 match rules.default_mode {
195 ToolPermissionMode::Deny => {
196 ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
197 }
198 ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
199 ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
200 }
201}
202
203/// Checks if the tool rules contain any invalid regex patterns.
204/// Returns an error message if invalid patterns are found.
205fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
206 if rules.invalid_patterns.is_empty() {
207 return None;
208 }
209
210 let count = rules.invalid_patterns.len();
211 let pattern_word = if count == 1 { "pattern" } else { "patterns" };
212
213 Some(format!(
214 "The {} tool cannot run because {} regex {} failed to compile. \
215 Please fix the invalid patterns in your tool_permissions settings.",
216 tool_name, count, pattern_word
217 ))
218}
219
220/// Convenience wrapper that extracts permission settings from `AgentSettings`.
221///
222/// This is the primary entry point for tools to check permissions. It extracts
223/// `tool_permissions` and `always_allow_tool_actions` from the settings and
224/// delegates to [`ToolPermissionDecision::from_input`], using the system shell.
225pub fn decide_permission_from_settings(
226 tool_name: &str,
227 input: &str,
228 settings: &AgentSettings,
229) -> ToolPermissionDecision {
230 ToolPermissionDecision::from_input(
231 tool_name,
232 input,
233 &settings.tool_permissions,
234 settings.always_allow_tool_actions,
235 ShellKind::system(),
236 )
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::pattern_extraction::extract_terminal_pattern;
243 use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
244 use std::sync::Arc;
245 use util::shell::PosixShell;
246
247 fn pattern(command: &str) -> &'static str {
248 Box::leak(
249 extract_terminal_pattern(command)
250 .expect("failed to extract pattern")
251 .into_boxed_str(),
252 )
253 }
254
255 struct PermTest {
256 tool: &'static str,
257 input: &'static str,
258 mode: ToolPermissionMode,
259 allow: Vec<(&'static str, bool)>,
260 deny: Vec<(&'static str, bool)>,
261 confirm: Vec<(&'static str, bool)>,
262 global: bool,
263 shell: Option<ShellKind>,
264 }
265
266 impl PermTest {
267 fn new(input: &'static str) -> Self {
268 Self {
269 tool: "terminal",
270 input,
271 mode: ToolPermissionMode::Confirm,
272 allow: vec![],
273 deny: vec![],
274 confirm: vec![],
275 global: false,
276 shell: Some(ShellKind::Posix(PosixShell::Sh)),
277 }
278 }
279
280 fn tool(mut self, t: &'static str) -> Self {
281 self.tool = t;
282 self
283 }
284 fn mode(mut self, m: ToolPermissionMode) -> Self {
285 self.mode = m;
286 self
287 }
288 fn allow(mut self, p: &[&'static str]) -> Self {
289 self.allow = p.iter().map(|s| (*s, false)).collect();
290 self
291 }
292 fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
293 self.allow = p.iter().map(|s| (*s, true)).collect();
294 self
295 }
296 fn deny(mut self, p: &[&'static str]) -> Self {
297 self.deny = p.iter().map(|s| (*s, false)).collect();
298 self
299 }
300 fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
301 self.deny = p.iter().map(|s| (*s, true)).collect();
302 self
303 }
304 fn confirm(mut self, p: &[&'static str]) -> Self {
305 self.confirm = p.iter().map(|s| (*s, false)).collect();
306 self
307 }
308 fn global(mut self, g: bool) -> Self {
309 self.global = g;
310 self
311 }
312 fn shell(mut self, s: Option<ShellKind>) -> Self {
313 self.shell = s;
314 self
315 }
316
317 fn is_allow(self) {
318 assert_eq!(
319 self.run(),
320 ToolPermissionDecision::Allow,
321 "expected Allow for '{}'",
322 self.input
323 );
324 }
325 fn is_deny(self) {
326 assert!(
327 matches!(self.run(), ToolPermissionDecision::Deny(_)),
328 "expected Deny for '{}'",
329 self.input
330 );
331 }
332 fn is_confirm(self) {
333 assert_eq!(
334 self.run(),
335 ToolPermissionDecision::Confirm,
336 "expected Confirm for '{}'",
337 self.input
338 );
339 }
340
341 fn run(&self) -> ToolPermissionDecision {
342 let mut tools = collections::HashMap::default();
343 tools.insert(
344 Arc::from(self.tool),
345 ToolRules {
346 default_mode: self.mode,
347 always_allow: self
348 .allow
349 .iter()
350 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
351 .collect(),
352 always_deny: self
353 .deny
354 .iter()
355 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
356 .collect(),
357 always_confirm: self
358 .confirm
359 .iter()
360 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
361 .collect(),
362 invalid_patterns: vec![],
363 },
364 );
365 ToolPermissionDecision::from_input(
366 self.tool,
367 self.input,
368 &ToolPermissions { tools },
369 self.global,
370 self.shell,
371 )
372 }
373 }
374
375 fn t(input: &'static str) -> PermTest {
376 PermTest::new(input)
377 }
378
379 fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
380 ToolPermissionDecision::from_input(
381 "terminal",
382 input,
383 &ToolPermissions {
384 tools: collections::HashMap::default(),
385 },
386 global,
387 Some(ShellKind::Posix(PosixShell::Sh)),
388 )
389 }
390
391 // allow pattern matches
392 #[test]
393 fn allow_exact_match() {
394 t("cargo test").allow(&[pattern("cargo")]).is_allow();
395 }
396 #[test]
397 fn allow_one_of_many_patterns() {
398 t("npm install")
399 .allow(&[pattern("cargo"), pattern("npm")])
400 .is_allow();
401 t("git status")
402 .allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
403 .is_allow();
404 }
405 #[test]
406 fn allow_middle_pattern() {
407 t("run cargo now").allow(&["cargo"]).is_allow();
408 }
409 #[test]
410 fn allow_anchor_prevents_middle() {
411 t("run cargo now").allow(&["^cargo"]).is_confirm();
412 }
413
414 // allow pattern doesn't match -> falls through
415 #[test]
416 fn allow_no_match_confirms() {
417 t("python x.py").allow(&[pattern("cargo")]).is_confirm();
418 }
419 #[test]
420 fn allow_no_match_global_allows() {
421 t("python x.py")
422 .allow(&[pattern("cargo")])
423 .global(true)
424 .is_allow();
425 }
426
427 // deny pattern matches
428 #[test]
429 fn deny_blocks() {
430 t("rm -rf /").deny(&["rm\\s+-rf"]).is_deny();
431 }
432 #[test]
433 fn global_bypasses_deny() {
434 // always_allow_tool_actions bypasses ALL checks, including deny
435 t("rm -rf /").deny(&["rm\\s+-rf"]).global(true).is_allow();
436 }
437 #[test]
438 fn deny_blocks_with_mode_allow() {
439 t("rm -rf /")
440 .deny(&["rm\\s+-rf"])
441 .mode(ToolPermissionMode::Allow)
442 .is_deny();
443 }
444 #[test]
445 fn deny_middle_match() {
446 t("echo rm -rf x").deny(&["rm\\s+-rf"]).is_deny();
447 }
448 #[test]
449 fn deny_no_match_falls_through() {
450 t("ls -la")
451 .deny(&["rm\\s+-rf"])
452 .mode(ToolPermissionMode::Allow)
453 .is_allow();
454 }
455
456 // confirm pattern matches
457 #[test]
458 fn confirm_requires_confirm() {
459 t("sudo apt install")
460 .confirm(&[pattern("sudo")])
461 .is_confirm();
462 }
463 #[test]
464 fn global_overrides_confirm() {
465 t("sudo reboot")
466 .confirm(&[pattern("sudo")])
467 .global(true)
468 .is_allow();
469 }
470 #[test]
471 fn confirm_overrides_mode_allow() {
472 t("sudo x")
473 .confirm(&["sudo"])
474 .mode(ToolPermissionMode::Allow)
475 .is_confirm();
476 }
477
478 // confirm beats allow
479 #[test]
480 fn confirm_beats_allow() {
481 t("git push --force")
482 .allow(&[pattern("git")])
483 .confirm(&["--force"])
484 .is_confirm();
485 }
486 #[test]
487 fn confirm_beats_allow_overlap() {
488 t("deploy prod")
489 .allow(&["deploy"])
490 .confirm(&["prod"])
491 .is_confirm();
492 }
493 #[test]
494 fn allow_when_confirm_no_match() {
495 t("git status")
496 .allow(&[pattern("git")])
497 .confirm(&["--force"])
498 .is_allow();
499 }
500
501 // deny beats allow
502 #[test]
503 fn deny_beats_allow() {
504 t("rm -rf /tmp/x")
505 .allow(&["/tmp/"])
506 .deny(&["rm\\s+-rf"])
507 .is_deny();
508 }
509
510 #[test]
511 fn deny_beats_confirm() {
512 t("sudo rm -rf /")
513 .confirm(&["sudo"])
514 .deny(&["rm\\s+-rf"])
515 .is_deny();
516 }
517
518 // deny beats everything
519 #[test]
520 fn deny_beats_all() {
521 t("bad cmd")
522 .allow(&["cmd"])
523 .confirm(&["cmd"])
524 .deny(&["bad"])
525 .is_deny();
526 }
527
528 // no patterns -> default_mode
529 #[test]
530 fn default_confirm() {
531 t("python x.py")
532 .mode(ToolPermissionMode::Confirm)
533 .is_confirm();
534 }
535 #[test]
536 fn default_allow() {
537 t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
538 }
539 #[test]
540 fn default_deny() {
541 t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
542 }
543 #[test]
544 fn default_deny_global_true() {
545 t("python x.py")
546 .mode(ToolPermissionMode::Deny)
547 .global(true)
548 .is_allow();
549 }
550
551 #[test]
552 fn default_confirm_global_true() {
553 t("x")
554 .mode(ToolPermissionMode::Confirm)
555 .global(true)
556 .is_allow();
557 }
558
559 #[test]
560 fn no_rules_confirms_by_default() {
561 assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
562 }
563
564 #[test]
565 fn empty_input_no_match() {
566 t("")
567 .deny(&["rm"])
568 .mode(ToolPermissionMode::Allow)
569 .is_allow();
570 }
571
572 #[test]
573 fn empty_input_with_allow_falls_to_default() {
574 t("").allow(&["^ls"]).is_confirm();
575 }
576
577 #[test]
578 fn multi_deny_any_match() {
579 t("rm x").deny(&["rm", "del", "drop"]).is_deny();
580 t("drop x").deny(&["rm", "del", "drop"]).is_deny();
581 }
582
583 #[test]
584 fn multi_allow_any_match() {
585 t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
586 }
587 #[test]
588 fn multi_none_match() {
589 t("python x")
590 .allow(&["^cargo", "^npm"])
591 .deny(&["rm"])
592 .is_confirm();
593 }
594
595 // tool isolation
596 #[test]
597 fn other_tool_not_affected() {
598 let mut tools = collections::HashMap::default();
599 tools.insert(
600 Arc::from("terminal"),
601 ToolRules {
602 default_mode: ToolPermissionMode::Deny,
603 always_allow: vec![],
604 always_deny: vec![],
605 always_confirm: vec![],
606 invalid_patterns: vec![],
607 },
608 );
609 tools.insert(
610 Arc::from("edit_file"),
611 ToolRules {
612 default_mode: ToolPermissionMode::Allow,
613 always_allow: vec![],
614 always_deny: vec![],
615 always_confirm: vec![],
616 invalid_patterns: vec![],
617 },
618 );
619 let p = ToolPermissions { tools };
620 // With always_allow_tool_actions=true, even default_mode: Deny is overridden
621 assert_eq!(
622 ToolPermissionDecision::from_input(
623 "terminal",
624 "x",
625 &p,
626 true,
627 Some(ShellKind::Posix(PosixShell::Sh))
628 ),
629 ToolPermissionDecision::Allow
630 );
631 // With always_allow_tool_actions=false, default_mode: Deny is respected
632 assert!(matches!(
633 ToolPermissionDecision::from_input(
634 "terminal",
635 "x",
636 &p,
637 false,
638 Some(ShellKind::Posix(PosixShell::Sh))
639 ),
640 ToolPermissionDecision::Deny(_)
641 ));
642 assert_eq!(
643 ToolPermissionDecision::from_input(
644 "edit_file",
645 "x",
646 &p,
647 false,
648 Some(ShellKind::Posix(PosixShell::Sh))
649 ),
650 ToolPermissionDecision::Allow
651 );
652 }
653
654 #[test]
655 fn partial_tool_name_no_match() {
656 let mut tools = collections::HashMap::default();
657 tools.insert(
658 Arc::from("term"),
659 ToolRules {
660 default_mode: ToolPermissionMode::Deny,
661 always_allow: vec![],
662 always_deny: vec![],
663 always_confirm: vec![],
664 invalid_patterns: vec![],
665 },
666 );
667 let p = ToolPermissions { tools };
668 // "terminal" should not match "term" rules, so falls back to Confirm (no rules)
669 assert_eq!(
670 ToolPermissionDecision::from_input(
671 "terminal",
672 "x",
673 &p,
674 false,
675 Some(ShellKind::Posix(PosixShell::Sh))
676 ),
677 ToolPermissionDecision::Confirm
678 );
679 }
680
681 // invalid patterns block the tool (but global bypasses all checks)
682 #[test]
683 fn invalid_pattern_blocks() {
684 let mut tools = collections::HashMap::default();
685 tools.insert(
686 Arc::from("terminal"),
687 ToolRules {
688 default_mode: ToolPermissionMode::Allow,
689 always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
690 always_deny: vec![],
691 always_confirm: vec![],
692 invalid_patterns: vec![InvalidRegexPattern {
693 pattern: "[bad".into(),
694 rule_type: "always_deny".into(),
695 error: "err".into(),
696 }],
697 },
698 );
699 let p = ToolPermissions {
700 tools: tools.clone(),
701 };
702 // With global=true, all checks are bypassed including invalid pattern check
703 assert!(matches!(
704 ToolPermissionDecision::from_input(
705 "terminal",
706 "echo hi",
707 &p,
708 true,
709 Some(ShellKind::Posix(PosixShell::Sh))
710 ),
711 ToolPermissionDecision::Allow
712 ));
713 // With global=false, invalid patterns block the tool
714 assert!(matches!(
715 ToolPermissionDecision::from_input(
716 "terminal",
717 "echo hi",
718 &p,
719 false,
720 Some(ShellKind::Posix(PosixShell::Sh))
721 ),
722 ToolPermissionDecision::Deny(_)
723 ));
724 }
725
726 #[test]
727 fn shell_injection_via_double_ampersand_not_allowed() {
728 t("ls && rm -rf /").allow(&["^ls"]).is_confirm();
729 }
730
731 #[test]
732 fn shell_injection_via_semicolon_not_allowed() {
733 t("ls; rm -rf /").allow(&["^ls"]).is_confirm();
734 }
735
736 #[test]
737 fn shell_injection_via_pipe_not_allowed() {
738 t("ls | xargs rm -rf").allow(&["^ls"]).is_confirm();
739 }
740
741 #[test]
742 fn shell_injection_via_backticks_not_allowed() {
743 t("echo `rm -rf /`").allow(&[pattern("echo")]).is_confirm();
744 }
745
746 #[test]
747 fn shell_injection_via_dollar_parens_not_allowed() {
748 t("echo $(rm -rf /)").allow(&[pattern("echo")]).is_confirm();
749 }
750
751 #[test]
752 fn shell_injection_via_or_operator_not_allowed() {
753 t("ls || rm -rf /").allow(&["^ls"]).is_confirm();
754 }
755
756 #[test]
757 fn shell_injection_via_background_operator_not_allowed() {
758 t("ls & rm -rf /").allow(&["^ls"]).is_confirm();
759 }
760
761 #[test]
762 fn shell_injection_via_newline_not_allowed() {
763 t("ls\nrm -rf /").allow(&["^ls"]).is_confirm();
764 }
765
766 #[test]
767 fn shell_injection_via_process_substitution_input_not_allowed() {
768 t("cat <(rm -rf /)").allow(&["^cat"]).is_confirm();
769 }
770
771 #[test]
772 fn shell_injection_via_process_substitution_output_not_allowed() {
773 t("ls >(rm -rf /)").allow(&["^ls"]).is_confirm();
774 }
775
776 #[test]
777 fn shell_injection_without_spaces_not_allowed() {
778 t("ls&&rm -rf /").allow(&["^ls"]).is_confirm();
779 t("ls;rm -rf /").allow(&["^ls"]).is_confirm();
780 }
781
782 #[test]
783 fn shell_injection_multiple_chained_operators_not_allowed() {
784 t("ls && echo hello && rm -rf /")
785 .allow(&["^ls"])
786 .is_confirm();
787 }
788
789 #[test]
790 fn shell_injection_mixed_operators_not_allowed() {
791 t("ls; echo hello && rm -rf /").allow(&["^ls"]).is_confirm();
792 }
793
794 #[test]
795 fn shell_injection_pipe_stderr_not_allowed() {
796 t("ls |& rm -rf /").allow(&["^ls"]).is_confirm();
797 }
798
799 #[test]
800 fn allow_requires_all_commands_to_match() {
801 t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
802 }
803
804 #[test]
805 fn deny_triggers_on_any_matching_command() {
806 t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
807 }
808
809 #[test]
810 fn deny_catches_injected_command() {
811 t("ls && rm -rf /").allow(&["^ls"]).deny(&["^rm"]).is_deny();
812 }
813
814 #[test]
815 fn confirm_triggers_on_any_matching_command() {
816 t("ls && sudo reboot")
817 .allow(&["^ls"])
818 .confirm(&["^sudo"])
819 .is_confirm();
820 }
821
822 #[test]
823 fn always_allow_button_works_end_to_end() {
824 // This test verifies that the "Always Allow" button behavior works correctly:
825 // 1. User runs a command like "cargo build"
826 // 2. They click "Always Allow for `cargo` commands"
827 // 3. The pattern extracted from that command should match future cargo commands
828 let original_command = "cargo build --release";
829 let extracted_pattern = pattern(original_command);
830
831 // The extracted pattern should allow the original command
832 t(original_command).allow(&[extracted_pattern]).is_allow();
833
834 // It should also allow other commands with the same base command
835 t("cargo test").allow(&[extracted_pattern]).is_allow();
836 t("cargo fmt").allow(&[extracted_pattern]).is_allow();
837
838 // But not commands with different base commands
839 t("npm install").allow(&[extracted_pattern]).is_confirm();
840
841 // And it should work with subcommand extraction (chained commands)
842 t("cargo build && cargo test")
843 .allow(&[extracted_pattern])
844 .is_allow();
845
846 // But reject if any subcommand doesn't match
847 t("cargo build && npm install")
848 .allow(&[extracted_pattern])
849 .is_confirm();
850 }
851
852 #[test]
853 fn nested_command_substitution_all_checked() {
854 t("echo $(cat $(whoami).txt)")
855 .allow(&["^echo", "^cat", "^whoami"])
856 .is_allow();
857 }
858
859 #[test]
860 fn parse_failure_falls_back_to_confirm() {
861 t("ls &&").allow(&["^ls$"]).is_confirm();
862 }
863
864 #[test]
865 fn mcp_tool_default_modes() {
866 t("")
867 .tool("mcp:fs:read")
868 .mode(ToolPermissionMode::Allow)
869 .is_allow();
870 t("")
871 .tool("mcp:bad:del")
872 .mode(ToolPermissionMode::Deny)
873 .is_deny();
874 t("")
875 .tool("mcp:gh:issue")
876 .mode(ToolPermissionMode::Confirm)
877 .is_confirm();
878 t("")
879 .tool("mcp:gh:issue")
880 .mode(ToolPermissionMode::Confirm)
881 .global(true)
882 .is_allow();
883 }
884
885 #[test]
886 fn mcp_doesnt_collide_with_builtin() {
887 let mut tools = collections::HashMap::default();
888 tools.insert(
889 Arc::from("terminal"),
890 ToolRules {
891 default_mode: ToolPermissionMode::Deny,
892 always_allow: vec![],
893 always_deny: vec![],
894 always_confirm: vec![],
895 invalid_patterns: vec![],
896 },
897 );
898 tools.insert(
899 Arc::from("mcp:srv:terminal"),
900 ToolRules {
901 default_mode: ToolPermissionMode::Allow,
902 always_allow: vec![],
903 always_deny: vec![],
904 always_confirm: vec![],
905 invalid_patterns: vec![],
906 },
907 );
908 let p = ToolPermissions { tools };
909 assert!(matches!(
910 ToolPermissionDecision::from_input(
911 "terminal",
912 "x",
913 &p,
914 false,
915 Some(ShellKind::Posix(PosixShell::Sh))
916 ),
917 ToolPermissionDecision::Deny(_)
918 ));
919 assert_eq!(
920 ToolPermissionDecision::from_input(
921 "mcp:srv:terminal",
922 "x",
923 &p,
924 false,
925 Some(ShellKind::Posix(PosixShell::Sh))
926 ),
927 ToolPermissionDecision::Allow
928 );
929 }
930
931 #[test]
932 fn case_insensitive_by_default() {
933 t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
934 t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
935 }
936
937 #[test]
938 fn case_sensitive_allow() {
939 t("cargo test")
940 .allow_case_sensitive(&[pattern("cargo")])
941 .is_allow();
942 t("CARGO TEST")
943 .allow_case_sensitive(&[pattern("cargo")])
944 .is_confirm();
945 }
946
947 #[test]
948 fn case_sensitive_deny() {
949 t("rm -rf /")
950 .deny_case_sensitive(&[pattern("rm")])
951 .is_deny();
952 t("RM -RF /")
953 .deny_case_sensitive(&[pattern("rm")])
954 .mode(ToolPermissionMode::Allow)
955 .is_allow();
956 }
957
958 #[test]
959 fn nushell_allows_with_allow_pattern() {
960 // Nushell uses `;` for sequential execution, which brush-parser handles.
961 // The `and`/`or` keywords are boolean operators, not command chaining.
962 t("ls")
963 .allow(&["^ls"])
964 .shell(Some(ShellKind::Nushell))
965 .is_allow();
966 }
967
968 #[test]
969 fn nushell_denies_with_deny_pattern() {
970 t("rm -rf /")
971 .deny(&["rm\\s+-rf"])
972 .shell(Some(ShellKind::Nushell))
973 .is_deny();
974 }
975
976 #[test]
977 fn nushell_confirms_with_confirm_pattern() {
978 t("sudo reboot")
979 .confirm(&["sudo"])
980 .shell(Some(ShellKind::Nushell))
981 .is_confirm();
982 }
983
984 #[test]
985 fn nushell_falls_through_to_default() {
986 t("ls")
987 .deny(&["rm"])
988 .mode(ToolPermissionMode::Allow)
989 .shell(Some(ShellKind::Nushell))
990 .is_allow();
991 }
992
993 #[test]
994 fn elvish_allows_with_allow_pattern() {
995 // Elvish uses `;` to separate pipelines, which brush-parser handles.
996 // The `and`/`or` special commands require parentheses around expressions.
997 t("ls")
998 .allow(&["^ls"])
999 .shell(Some(ShellKind::Elvish))
1000 .is_allow();
1001 }
1002
1003 #[test]
1004 fn rc_allows_with_allow_pattern() {
1005 // Rc (Plan 9) uses `;` for sequential execution, which brush-parser handles.
1006 // Rc does not have `&&`/`||` operators.
1007 t("ls")
1008 .allow(&["^ls"])
1009 .shell(Some(ShellKind::Rc))
1010 .is_allow();
1011 }
1012
1013 #[test]
1014 fn unknown_shell_denies_when_always_allow_configured() {
1015 // Unknown shells have unrecognized syntax, so we cannot safely parse them.
1016 // For security, always_allow patterns are disabled.
1017 t("ls").allow(&["^ls"]).shell(None).is_deny();
1018 }
1019
1020 #[test]
1021 fn unknown_shell_allows_deny_patterns() {
1022 // Deny patterns still work for unknown shells since they're checked
1023 // against the raw input string.
1024 t("rm -rf /").deny(&["rm\\s+-rf"]).shell(None).is_deny();
1025 }
1026
1027 #[test]
1028 fn unknown_shell_allows_confirm_patterns() {
1029 // Confirm patterns still work for unknown shells.
1030 t("sudo reboot").confirm(&["sudo"]).shell(None).is_confirm();
1031 }
1032
1033 #[test]
1034 fn unknown_shell_falls_through_to_default() {
1035 // With no always_allow patterns, unknown shells use the default mode.
1036 t("ls")
1037 .deny(&["rm"])
1038 .mode(ToolPermissionMode::Allow)
1039 .shell(None)
1040 .is_allow();
1041 }
1042
1043 #[test]
1044 fn multiple_invalid_patterns_pluralizes_message() {
1045 let mut tools = collections::HashMap::default();
1046 tools.insert(
1047 Arc::from("terminal"),
1048 ToolRules {
1049 default_mode: ToolPermissionMode::Allow,
1050 always_allow: vec![],
1051 always_deny: vec![],
1052 always_confirm: vec![],
1053 invalid_patterns: vec![
1054 InvalidRegexPattern {
1055 pattern: "[bad1".into(),
1056 rule_type: "always_deny".into(),
1057 error: "err1".into(),
1058 },
1059 InvalidRegexPattern {
1060 pattern: "[bad2".into(),
1061 rule_type: "always_allow".into(),
1062 error: "err2".into(),
1063 },
1064 ],
1065 },
1066 );
1067 let p = ToolPermissions { tools };
1068
1069 let result = ToolPermissionDecision::from_input(
1070 "terminal",
1071 "x",
1072 &p,
1073 false,
1074 Some(ShellKind::Posix(PosixShell::Sh)),
1075 );
1076 match result {
1077 ToolPermissionDecision::Deny(msg) => {
1078 assert!(msg.contains("2 regex patterns"), "Expected plural: {}", msg);
1079 }
1080 _ => panic!("Expected Deny"),
1081 }
1082 }
1083}