tool_permissions.rs

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