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