Strengthen hardcoded rm security rules and add path normalization (#48640)
This PR hardens the terminal tool's hardcoded security rules for
destructive commands like `rm -rf /`, and adds path normalization to
prevent traversal-based bypasses.
## Path normalization
Adds `normalize_path` which resolves `..`, `.`, and redundant path
separators, and `decide_permission_for_path` which checks permissions
against both raw and normalized paths (taking the most restrictive
result). This prevents attacks like `rm -rf /tmp/../../` which
previously bypassed the `rm -rf /` rule.
## rm command expansion
Adds `expand_rm_to_single_path_commands` which splits multi-argument rm
commands into individual single-path commands for checking. This catches
cases like `rm -rf /tmp /` where the dangerous path is the second
argument.
## Regex hardening
- **FLAGS**: Now accepts digits, underscores, and uppercase in long
flags (e.g. `--no-preserve-root`)
- **`--flag=value`**: Correctly matched as a single flag token
- **Trailing flags**: Handles GNU rm's acceptance of flags after path
operands (e.g. `rm / -rf`)
- **`--` marker**: Detects end-of-options bypass attempts (e.g. `rm -rf
-- /`)
- **Whitespace**: Handles tabs and other whitespace, not just spaces
## `$HOME`/`${HOME}` handling
Normalizes the suffix after `$HOME`/`${HOME}` variable references so
that traversal attacks like `rm -rf $HOME/./` or `rm -rf ${HOME}/foo/..`
are correctly detected.
Release Notes:
- Strengthened terminal security rules to detect path traversal attacks
in destructive commands like `rm -rf`.
This commit is contained in:
1 parent
4a89623977
commit
7fa4cfcfed
1 file changed
+638
-45
@@ -3,6 +3,7 @@ use crate::shell_parser::extract_commands;
|
||||
use crate::tools::TerminalTool;
|
||||
use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};
|
||||
use settings::ToolPermissionMode;
|
||||
use std::path::{Component, Path};
|
||||
use std::sync::LazyLock;
|
||||
use util::shell::ShellKind;
|
||||
|
||||
@@ -16,24 +17,44 @@ pub struct HardcodedSecurityRules {
|
||||
}
|
||||
|
||||
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 {
|
||||
// Case-insensitive; `(-[rf]+\s+)*` handles `-rf`, `-fr`, `-RF`, `-r -f`, etc.
|
||||
terminal_deny: vec![
|
||||
// Recursive deletion of root - "rm -rf /" or "rm -rf / "
|
||||
CompiledRegex::new(r"rm\s+(-[rf]+\s+)*/\s*$", false)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// Recursive deletion of home - "rm -rf ~" or "rm -rf ~/" (but not ~/subdir)
|
||||
CompiledRegex::new(r"rm\s+(-[rf]+\s+)*~/?\s*$", false)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// Recursive deletion of home via $HOME - "rm -rf $HOME" or "rm -rf ${HOME}"
|
||||
CompiledRegex::new(r"rm\s+(-[rf]+\s+)*(\$HOME|\$\{HOME\})/?\s*$", false)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// Recursive deletion of current directory - "rm -rf ." or "rm -rf ./"
|
||||
CompiledRegex::new(r"rm\s+(-[rf]+\s+)*\./?\s*$", false)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// Recursive deletion of parent directory - "rm -rf .." or "rm -rf ../"
|
||||
CompiledRegex::new(r"rm\s+(-[rf]+\s+)*\.\./?\s*$", false)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// 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"),
|
||||
],
|
||||
}
|
||||
});
|
||||
@@ -53,25 +74,21 @@ fn check_hardcoded_security_rules(
|
||||
let rules = &*HARDCODED_SECURITY_RULES;
|
||||
let terminal_patterns = &rules.terminal_deny;
|
||||
|
||||
// First: check the original input as-is
|
||||
for pattern in terminal_patterns {
|
||||
if pattern.is_match(input) {
|
||||
return Some(ToolPermissionDecision::Deny(
|
||||
HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
|
||||
));
|
||||
}
|
||||
// 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 {
|
||||
for pattern in terminal_patterns {
|
||||
if pattern.is_match(command) {
|
||||
return Some(ToolPermissionDecision::Deny(
|
||||
HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
|
||||
));
|
||||
}
|
||||
if matches_hardcoded_patterns(command, terminal_patterns) {
|
||||
return Some(ToolPermissionDecision::Deny(
|
||||
HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +97,105 @@ fn check_hardcoded_security_rules(
|
||||
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,
|
||||
@@ -92,28 +208,27 @@ impl ToolPermissionDecision {
|
||||
///
|
||||
/// # Precedence Order (highest to lowest)
|
||||
///
|
||||
/// 1. **`always_allow_tool_actions`** - When enabled, allows all tool actions without
|
||||
/// prompting. This global setting bypasses all other checks including deny patterns.
|
||||
/// Use with caution as it disables all security rules.
|
||||
/// 2. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
|
||||
/// 1. **Hardcoded security rules** - Critical safety checks (e.g., blocking `rm -rf /`)
|
||||
/// that cannot be bypassed by any user settings, including `always_allow_tool_actions`.
|
||||
/// 2. **`always_allow_tool_actions`** - When enabled, allows all tool actions without
|
||||
/// prompting. This global setting bypasses user-configured deny/confirm/allow patterns,
|
||||
/// but does **not** bypass hardcoded security rules.
|
||||
/// 3. **`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),
|
||||
/// 4. **`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),
|
||||
/// 5. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
|
||||
/// the tool call proceeds without prompting.
|
||||
/// 5. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
|
||||
/// 6. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
|
||||
///
|
||||
/// # Shell Compatibility (Terminal Tool Only)
|
||||
///
|
||||
/// For the terminal tool, commands are parsed to extract sub-commands for security.
|
||||
/// This parsing only works for shells with POSIX-like `&&` / `||` / `;` / `|` syntax:
|
||||
///
|
||||
/// **Compatible shells:** Posix (sh, bash, dash, zsh), Fish 3.0+, PowerShell 7+/Pwsh,
|
||||
/// Cmd, Xonsh, Csh, Tcsh
|
||||
///
|
||||
/// **Incompatible shells:** Nushell, Elvish, Rc (Plan 9)
|
||||
///
|
||||
/// For incompatible shells, `always_allow` patterns are disabled for safety.
|
||||
/// 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
|
||||
///
|
||||
@@ -303,15 +418,119 @@ pub fn decide_permission_from_settings(
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalizes a path by collapsing `.` and `..` segments without touching the filesystem.
|
||||
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_path(
|
||||
tool_name: &str,
|
||||
raw_path: &str,
|
||||
settings: &AgentSettings,
|
||||
) -> ToolPermissionDecision {
|
||||
let raw_decision = decide_permission_from_settings(tool_name, raw_path, settings);
|
||||
|
||||
let simplified = normalize_path(raw_path);
|
||||
if simplified == raw_path {
|
||||
return raw_decision;
|
||||
}
|
||||
|
||||
let simplified_decision = decide_permission_from_settings(tool_name, &simplified, settings);
|
||||
|
||||
most_restrictive(raw_decision, simplified_decision)
|
||||
}
|
||||
|
||||
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::{EditFileTool, TerminalTool};
|
||||
use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
|
||||
use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules};
|
||||
use gpui::px;
|
||||
use settings::{DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn test_agent_settings(
|
||||
tool_permissions: ToolPermissions,
|
||||
always_allow_tool_actions: bool,
|
||||
) -> AgentSettings {
|
||||
AgentSettings {
|
||||
enabled: true,
|
||||
button: true,
|
||||
dock: DockPosition::Right,
|
||||
agents_panel_dock: DockSide::Left,
|
||||
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(),
|
||||
always_allow_tool_actions,
|
||||
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)
|
||||
@@ -1125,6 +1344,18 @@ mod tests {
|
||||
t("rm -r -f /").is_deny();
|
||||
t("rm -f -r /").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 -- /").is_deny();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1141,6 +1372,40 @@ mod tests {
|
||||
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]
|
||||
@@ -1156,6 +1421,18 @@ mod tests {
|
||||
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]
|
||||
@@ -1210,4 +1487,320 @@ mod tests {
|
||||
t("echo hello; rm -rf .").is_deny();
|
||||
t("echo hello; rm -rf ..").is_deny();
|
||||
}
|
||||
|
||||
#[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();
|
||||
// Trailing --flag=value after path
|
||||
t("rm / --no-preserve-root=yes -rf").is_deny();
|
||||
t("rm ~ -rf --no-preserve-root=yes").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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardcoded_blocks_rm_with_path_traversal() {
|
||||
// Traversal to root via ..
|
||||
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(true).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 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 {
|
||||
tools: Default::default(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
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_mode: ToolPermissionMode::Allow,
|
||||
always_allow: vec![],
|
||||
always_deny: vec![deny_regex],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
let settings = test_agent_settings(ToolPermissions { tools }, false);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user