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