2418 lines · 80.9 KB · Rust
RawDownload
use crate::AgentTool;use crate::tools::TerminalTool;use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};use settings::ToolPermissionMode;use shell_command_parser::{    TerminalCommandValidation, extract_commands, validate_terminal_command,};use std::path::{Component, Path};use std::sync::LazyLock;use util::shell::ShellKind; const HARDCODED_SECURITY_DENIAL_MESSAGE: &str = "Blocked by built-in security rule. This operation is considered too \     harmful to be allowed, and cannot be overridden by settings.";const INVALID_TERMINAL_COMMAND_MESSAGE: &str = "The terminal command could not be approved because terminal does not \     allow shell substitutions or interpolations in permission-protected commands. Forbidden examples include $VAR, \     ${VAR}, $(...), backticks, $((...)), <(...), and >(...). Resolve those values before calling terminal, or ask \     the user for the literal value to use."; /// Security rules that are always enforced and cannot be overridden by any setting./// These protect against catastrophic operations like wiping filesystems.pub struct HardcodedSecurityRules {    pub terminal_deny: Vec<CompiledRegex>,} pub static HARDCODED_SECURITY_RULES: LazyLock<HardcodedSecurityRules> = LazyLock::new(|| {    // Flag group matches any short flags (-rf, -rfv, -v, etc.) or long flags (--recursive, --force, etc.)    // This ensures extra flags like -rfv, -v -rf, --recursive --force don't bypass the rules.    const FLAGS: &str = r"(--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?\s+|-[a-zA-Z]+\s+)*";    // Trailing flags that may appear after the path operand (GNU rm accepts flags after operands)    const TRAILING_FLAGS: &str = r"(\s+--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?|\s+-[a-zA-Z]+)*\s*";     HardcodedSecurityRules {        terminal_deny: vec![            // Recursive deletion of root - "rm -rf /", "rm -rfv /", "rm -rf /*", "rm / -rf"            CompiledRegex::new(                &format!(r"\brm\s+{FLAGS}(--\s+)?/\*?{TRAILING_FLAGS}$"),                false,            )            .expect("hardcoded regex should compile"),            // Recursive deletion of home - "rm -rf ~" or "rm -rf ~/" or "rm -rf ~/*" or "rm ~ -rf" (but not ~/subdir)            CompiledRegex::new(                &format!(r"\brm\s+{FLAGS}(--\s+)?~/?\*?{TRAILING_FLAGS}$"),                false,            )            .expect("hardcoded regex should compile"),            // Recursive deletion of home via $HOME - "rm -rf $HOME" or "rm -rf ${HOME}" or "rm $HOME -rf" or with /*            CompiledRegex::new(                &format!(r"\brm\s+{FLAGS}(--\s+)?(\$HOME|\$\{{HOME\}})/?(\*)?{TRAILING_FLAGS}$"),                false,            )            .expect("hardcoded regex should compile"),            // Recursive deletion of current directory - "rm -rf ." or "rm -rf ./" or "rm -rf ./*" or "rm . -rf"            CompiledRegex::new(                &format!(r"\brm\s+{FLAGS}(--\s+)?\./?\*?{TRAILING_FLAGS}$"),                false,            )            .expect("hardcoded regex should compile"),            // Recursive deletion of parent directory - "rm -rf .." or "rm -rf ../" or "rm -rf ../*" or "rm .. -rf"            CompiledRegex::new(                &format!(r"\brm\s+{FLAGS}(--\s+)?\.\./?\*?{TRAILING_FLAGS}$"),                false,            )            .expect("hardcoded regex should compile"),        ],    }}); /// Checks if input matches any hardcoded security rules that cannot be bypassed./// Returns a Deny decision if blocked, None otherwise.fn check_hardcoded_security_rules(    tool_name: &str,    inputs: &[String],    shell_kind: ShellKind,) -> Option<ToolPermissionDecision> {    // Currently only terminal tool has hardcoded rules    if tool_name != TerminalTool::NAME {        return None;    }     let rules = &*HARDCODED_SECURITY_RULES;    let terminal_patterns = &rules.terminal_deny;     for input in inputs {        // First: check the original input as-is (and its path-normalized form)        if matches_hardcoded_patterns(input, terminal_patterns) {            return Some(ToolPermissionDecision::Deny(                HARDCODED_SECURITY_DENIAL_MESSAGE.into(),            ));        }         // Second: parse and check individual sub-commands (for chained commands)        if shell_kind.supports_posix_chaining() {            if let Some(commands) = extract_commands(input) {                for command in &commands {                    if matches_hardcoded_patterns(command, terminal_patterns) {                        return Some(ToolPermissionDecision::Deny(                            HARDCODED_SECURITY_DENIAL_MESSAGE.into(),                        ));                    }                }            }        }    }     None} /// Checks a single command against hardcoded patterns, both as-is and with/// path arguments normalized (to catch traversal bypasses like `rm -rf /tmp/../../`/// and multi-path bypasses like `rm -rf /tmp /`).fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {    for pattern in patterns {        if pattern.is_match(command) {            return true;        }    }     for expanded in expand_rm_to_single_path_commands(command) {        for pattern in patterns {            if pattern.is_match(&expanded) {                return true;            }        }    }     false} /// For rm commands, expands multi-path arguments into individual single-path/// commands with normalized paths. This catches both traversal bypasses like/// `rm -rf /tmp/../../` and multi-path bypasses like `rm -rf /tmp /`.fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {    let trimmed = command.trim();     let first_token = trimmed.split_whitespace().next();    if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {        return vec![];    }     let parts: Vec<&str> = trimmed.split_whitespace().collect();    let mut flags = Vec::new();    let mut paths = Vec::new();    let mut past_double_dash = false;     for part in parts.iter().skip(1) {        if !past_double_dash && *part == "--" {            past_double_dash = true;            flags.push(*part);            continue;        }        if !past_double_dash && part.starts_with('-') {            flags.push(*part);        } else {            paths.push(*part);        }    }     let flags_str = if flags.is_empty() {        String::new()    } else {        format!("{} ", flags.join(" "))    };     let mut results = Vec::new();    for path in &paths {        if path.starts_with('$') {            let home_prefix = if path.starts_with("${HOME}") {                Some("${HOME}")            } else if path.starts_with("$HOME") {                Some("$HOME")            } else {                None            };             if let Some(prefix) = home_prefix {                let suffix = &path[prefix.len()..];                if suffix.is_empty() {                    results.push(format!("rm {flags_str}{path}"));                } else if suffix.starts_with('/') {                    let normalized_suffix = normalize_path(suffix);                    let reconstructed = if normalized_suffix == "/" {                        prefix.to_string()                    } else {                        format!("{prefix}{normalized_suffix}")                    };                    results.push(format!("rm {flags_str}{reconstructed}"));                } else {                    results.push(format!("rm {flags_str}{path}"));                }            } else {                results.push(format!("rm {flags_str}{path}"));            }            continue;        }         let mut normalized = normalize_path(path);        if normalized.is_empty() && !Path::new(path).has_root() {            normalized = ".".to_string();        }         results.push(format!("rm {flags_str}{normalized}"));    }     results} #[derive(Debug, Clone, PartialEq, Eq)]pub enum ToolPermissionDecision {    Allow,    Deny(String),    Confirm,} impl ToolPermissionDecision {    /// Determines the permission decision for a tool invocation based on configured rules.    ///    /// # Precedence Order (highest to lowest)    ///    /// 1. **Hardcoded security rules** - Critical safety checks (e.g., blocking `rm -rf /`)    ///    that cannot be bypassed by any user settings.    /// 2. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.    ///    This takes precedence over `always_confirm` and `always_allow` patterns.    /// 3. **`always_confirm`** - If any confirm pattern matches (and no deny matched),    ///    the user is prompted for confirmation.    /// 4. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),    ///    the tool call proceeds without prompting.    /// 5. **Tool-specific `default`** - If no patterns match and the tool has an explicit    ///    `default` configured, that mode is used.    /// 6. **Global `default`** - Falls back to `tool_permissions.default` when no    ///    tool-specific default is set, or when the tool has no entry at all.    ///    /// # Shell Compatibility (Terminal Tool Only)    ///    /// For the terminal tool, commands are parsed to extract sub-commands for security.    /// All currently supported `ShellKind` variants are treated as compatible because    /// brush-parser can handle their command chaining syntax. If a new `ShellKind`    /// variant is added that brush-parser cannot safely parse, it should be excluded    /// from `ShellKind::supports_posix_chaining()`, which will cause `always_allow`    /// patterns to be disabled for that shell.    ///    /// # Pattern Matching Tips    ///    /// Patterns are matched as regular expressions against the tool input (e.g., the command    /// string for the terminal tool). Some tips for writing effective patterns:    ///    /// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will    ///   match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".    ///   This is important for security rules where you want to block specific commands    ///   without accidentally blocking unrelated commands that happen to contain the same    ///   substring.    /// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.    /// - Use `^` and `$` anchors to match the start/end of the input.    pub fn from_input(        tool_name: &str,        inputs: &[String],        permissions: &ToolPermissions,        shell_kind: ShellKind,    ) -> ToolPermissionDecision {        // First, check hardcoded security rules, such as banning `rm -rf /` in terminal tool.        // These cannot be bypassed by any user settings.        if let Some(denial) = check_hardcoded_security_rules(tool_name, inputs, shell_kind) {            return denial;        }         let rules = permissions.tools.get(tool_name);         // Check for invalid regex patterns before evaluating rules.        // If any patterns failed to compile, block the tool call entirely.        if let Some(error) = rules.and_then(|rules| check_invalid_patterns(tool_name, rules)) {            return ToolPermissionDecision::Deny(error);        }         if tool_name == TerminalTool::NAME            && !rules.map_or(                matches!(permissions.default, ToolPermissionMode::Allow),                |rules| is_unconditional_allow_all(rules, permissions.default),            )            && inputs.iter().any(|input| {                matches!(                    validate_terminal_command(input),                    TerminalCommandValidation::Unsafe | TerminalCommandValidation::Unsupported                )            })        {            return ToolPermissionDecision::Deny(INVALID_TERMINAL_COMMAND_MESSAGE.into());        }         let rules = match rules {            Some(rules) => rules,            None => {                // No tool-specific rules, use the global default                return match permissions.default {                    ToolPermissionMode::Allow => ToolPermissionDecision::Allow,                    ToolPermissionMode::Deny => {                        ToolPermissionDecision::Deny("Blocked by global default: deny".into())                    }                    ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,                };            }        };         // For the terminal tool, parse each input command to extract all sub-commands.        // This prevents shell injection attacks where a user configures an allow        // pattern like "^ls" and an attacker crafts "ls && rm -rf /".        //        // If parsing fails or the shell syntax is unsupported, always_allow is        // disabled for this command (we set allow_enabled to false to signal this).        if tool_name == TerminalTool::NAME {            // Our shell parser (brush-parser) only supports POSIX-like shell syntax.            // See the doc comment above for the list of compatible/incompatible shells.            if !shell_kind.supports_posix_chaining() {                // For shells with incompatible syntax, we can't reliably parse                // the command to extract sub-commands.                if !rules.always_allow.is_empty() {                    // If the user has configured always_allow patterns, we must deny                    // because we can't safely verify the command doesn't contain                    // hidden sub-commands that bypass the allow patterns.                    return ToolPermissionDecision::Deny(format!(                        "The {} shell does not support \"always allow\" patterns for the terminal \                         tool because Zed cannot parse its command chaining syntax. Please remove \                         the always_allow patterns from your tool_permissions settings, or switch \                         to a POSIX-conforming shell.",                        shell_kind                    ));                }                // No always_allow rules, so we can still check deny/confirm patterns.                return check_commands(                    inputs.iter().map(|s| s.to_string()),                    rules,                    tool_name,                    false,                    permissions.default,                );            }             // Expand each input into its sub-commands and check them all together.            let mut all_commands = Vec::new();            let mut any_parse_failed = false;            for input in inputs {                match extract_commands(input) {                    Some(commands) => all_commands.extend(commands),                    None => {                        any_parse_failed = true;                        all_commands.push(input.to_string());                    }                }            }            // If any command failed to parse, disable allow patterns for safety.            check_commands(                all_commands,                rules,                tool_name,                !any_parse_failed,                permissions.default,            )        } else {            check_commands(                inputs.iter().map(|s| s.to_string()),                rules,                tool_name,                true,                permissions.default,            )        }    }} /// Evaluates permission rules against a set of commands.////// This function performs a single pass through all commands with the following logic:/// - **DENY**: If ANY command matches a deny pattern, deny immediately (short-circuit)/// - **CONFIRM**: Track if ANY command matches a confirm pattern/// - **ALLOW**: Track if ALL commands match at least one allow pattern////// The `allow_enabled` flag controls whether allow patterns are checked. This is set/// to `false` when we can't reliably parse shell commands (e.g., parse failures or/// unsupported shell syntax), ensuring we don't auto-allow potentially dangerous commands.fn check_commands(    commands: impl IntoIterator<Item = String>,    rules: &ToolRules,    tool_name: &str,    allow_enabled: bool,    global_default: ToolPermissionMode,) -> ToolPermissionDecision {    // Single pass through all commands:    // - DENY: If ANY command matches a deny pattern, deny immediately (short-circuit)    // - CONFIRM: Track if ANY command matches a confirm pattern    // - ALLOW: Track if ALL commands match at least one allow pattern    let mut any_matched_confirm = false;    let mut all_matched_allow = true;    let mut had_any_commands = false;     for command in commands {        had_any_commands = true;         // DENY: immediate return if any command matches a deny pattern        if rules.always_deny.iter().any(|r| r.is_match(&command)) {            return ToolPermissionDecision::Deny(format!(                "Command blocked by security rule for {} tool",                tool_name            ));        }         // CONFIRM: remember if any command matches a confirm pattern        if rules.always_confirm.iter().any(|r| r.is_match(&command)) {            any_matched_confirm = true;        }         // ALLOW: track if all commands match at least one allow pattern        if !rules.always_allow.iter().any(|r| r.is_match(&command)) {            all_matched_allow = false;        }    }     // After processing all commands, check accumulated state    if any_matched_confirm {        return ToolPermissionDecision::Confirm;    }     if allow_enabled && all_matched_allow && had_any_commands {        return ToolPermissionDecision::Allow;    }     match rules.default.unwrap_or(global_default) {        ToolPermissionMode::Deny => {            ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))        }        ToolPermissionMode::Allow => ToolPermissionDecision::Allow,        ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,    }} fn is_unconditional_allow_all(rules: &ToolRules, global_default: ToolPermissionMode) -> bool {    // `always_allow` is intentionally not checked here: when the effective default    // is already Allow and there are no deny/confirm restrictions, allow patterns    // are redundant — the user has opted into allowing everything.    rules.always_deny.is_empty()        && rules.always_confirm.is_empty()        && matches!(            rules.default.unwrap_or(global_default),            ToolPermissionMode::Allow        )} /// Checks if the tool rules contain any invalid regex patterns./// Returns an error message if invalid patterns are found.fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {    if rules.invalid_patterns.is_empty() {        return None;    }     let count = rules.invalid_patterns.len();    let pattern_word = if count == 1 { "pattern" } else { "patterns" };     Some(format!(        "The {} tool cannot run because {} regex {} failed to compile. \         Please fix the invalid patterns in your tool_permissions settings.",        tool_name, count, pattern_word    ))} /// Convenience wrapper that extracts permission settings from `AgentSettings`.////// This is the primary entry point for tools to check permissions. It extracts/// `tool_permissions` from the settings and/// delegates to [`ToolPermissionDecision::from_input`], using the system shell.pub fn decide_permission_from_settings(    tool_name: &str,    inputs: &[String],    settings: &AgentSettings,) -> ToolPermissionDecision {    ToolPermissionDecision::from_input(        tool_name,        inputs,        &settings.tool_permissions,        ShellKind::system(),    )} /// Normalizes a path by collapsing `.` and `..` segments without touching the filesystem.pub fn normalize_path(raw: &str) -> String {    let is_absolute = Path::new(raw).has_root();    let mut components: Vec<&str> = Vec::new();    for component in Path::new(raw).components() {        match component {            Component::CurDir => {}            Component::ParentDir => {                if components.last() == Some(&"..") {                    components.push("..");                } else if !components.is_empty() {                    components.pop();                } else if !is_absolute {                    components.push("..");                }            }            Component::Normal(segment) => {                if let Some(s) = segment.to_str() {                    components.push(s);                }            }

Lines 1–500 of 2418.