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:
Richard Feldman authored and GitHub committed 2026-02-06 17:17:43 -05:00
1 parent 4a89623977
commit 7fa4cfcfed
1 file changed
+638 -45
+638 -45
View File
@@ -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
);
}
}