Files
claw-code/rust/crates/runtime/src/bash_validation.rs
Jobdori 1cfd78ac61 feat: bash validation module + output truncation parity
- Add bash_validation.rs with 9 submodules (1004 lines):
  readOnlyValidation, destructiveCommandWarning, modeValidation,
  sedValidation, pathValidation, commandSemantics, bashPermissions,
  bashSecurity, shouldUseSandbox
- Wire into runtime lib.rs
- Add MAX_OUTPUT_BYTES (16KB) truncation to bash.rs
- Add 4 truncation tests, all passing
- Full test suite: 270+ green
2026-04-03 19:31:49 +09:00

1005 lines
28 KiB
Rust

//! Bash command validation submodules.
//!
//! Ports the upstream `BashTool` validation pipeline:
//! - `readOnlyValidation` — block write-like commands in read-only mode
//! - `destructiveCommandWarning` — flag dangerous destructive commands
//! - `modeValidation` — enforce permission mode constraints on commands
//! - `sedValidation` — validate sed expressions before execution
//! - `pathValidation` — detect suspicious path patterns
//! - `commandSemantics` — classify command intent
use std::path::Path;
use crate::permissions::PermissionMode;
/// Result of validating a bash command before execution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationResult {
/// Command is safe to execute.
Allow,
/// Command should be blocked with the given reason.
Block { reason: String },
/// Command requires user confirmation with the given warning.
Warn { message: String },
}
/// Semantic classification of a bash command's intent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandIntent {
/// Read-only operations: ls, cat, grep, find, etc.
ReadOnly,
/// File system writes: cp, mv, mkdir, touch, tee, etc.
Write,
/// Destructive operations: rm, shred, truncate, etc.
Destructive,
/// Network operations: curl, wget, ssh, etc.
Network,
/// Process management: kill, pkill, etc.
ProcessManagement,
/// Package management: apt, brew, pip, npm, etc.
PackageManagement,
/// System administration: sudo, chmod, chown, mount, etc.
SystemAdmin,
/// Unknown or unclassifiable command.
Unknown,
}
// ---------------------------------------------------------------------------
// readOnlyValidation
// ---------------------------------------------------------------------------
/// Commands that perform write operations and should be blocked in read-only mode.
const WRITE_COMMANDS: &[&str] = &[
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "chmod", "chown", "chgrp", "ln", "install", "tee",
"truncate", "shred", "mkfifo", "mknod", "dd",
];
/// Commands that modify system state and should be blocked in read-only mode.
const STATE_MODIFYING_COMMANDS: &[&str] = &[
"apt",
"apt-get",
"yum",
"dnf",
"pacman",
"brew",
"pip",
"pip3",
"npm",
"yarn",
"pnpm",
"bun",
"cargo",
"gem",
"go",
"rustup",
"docker",
"systemctl",
"service",
"mount",
"umount",
"kill",
"pkill",
"killall",
"reboot",
"shutdown",
"halt",
"poweroff",
"useradd",
"userdel",
"usermod",
"groupadd",
"groupdel",
"crontab",
"at",
];
/// Shell redirection operators that indicate writes.
const WRITE_REDIRECTIONS: &[&str] = &[">", ">>", ">&"];
/// Validate that a command is allowed under read-only mode.
///
/// Corresponds to upstream `tools/BashTool/readOnlyValidation.ts`.
#[must_use]
pub fn validate_read_only(command: &str, mode: PermissionMode) -> ValidationResult {
if mode != PermissionMode::ReadOnly {
return ValidationResult::Allow;
}
let first_command = extract_first_command(command);
// Check for write commands.
for &write_cmd in WRITE_COMMANDS {
if first_command == write_cmd {
return ValidationResult::Block {
reason: format!(
"Command '{write_cmd}' modifies the filesystem and is not allowed in read-only mode"
),
};
}
}
// Check for state-modifying commands.
for &state_cmd in STATE_MODIFYING_COMMANDS {
if first_command == state_cmd {
return ValidationResult::Block {
reason: format!(
"Command '{state_cmd}' modifies system state and is not allowed in read-only mode"
),
};
}
}
// Check for sudo wrapping write commands.
if first_command == "sudo" {
let inner = extract_sudo_inner(command);
if !inner.is_empty() {
let inner_result = validate_read_only(inner, mode);
if inner_result != ValidationResult::Allow {
return inner_result;
}
}
}
// Check for write redirections.
for &redir in WRITE_REDIRECTIONS {
if command.contains(redir) {
return ValidationResult::Block {
reason: format!(
"Command contains write redirection '{redir}' which is not allowed in read-only mode"
),
};
}
}
// Check for git commands that modify state.
if first_command == "git" {
return validate_git_read_only(command);
}
ValidationResult::Allow
}
/// Git subcommands that are read-only safe.
const GIT_READ_ONLY_SUBCOMMANDS: &[&str] = &[
"status",
"log",
"diff",
"show",
"branch",
"tag",
"stash",
"remote",
"fetch",
"ls-files",
"ls-tree",
"cat-file",
"rev-parse",
"describe",
"shortlog",
"blame",
"bisect",
"reflog",
"config",
];
fn validate_git_read_only(command: &str) -> ValidationResult {
let parts: Vec<&str> = command.split_whitespace().collect();
// Skip past "git" and any flags (e.g., "git -C /path")
let subcommand = parts.iter().skip(1).find(|p| !p.starts_with('-'));
match subcommand {
Some(&sub) if GIT_READ_ONLY_SUBCOMMANDS.contains(&sub) => ValidationResult::Allow,
Some(&sub) => ValidationResult::Block {
reason: format!(
"Git subcommand '{sub}' modifies repository state and is not allowed in read-only mode"
),
},
None => ValidationResult::Allow, // bare "git" is fine
}
}
// ---------------------------------------------------------------------------
// destructiveCommandWarning
// ---------------------------------------------------------------------------
/// Patterns that indicate potentially destructive commands.
const DESTRUCTIVE_PATTERNS: &[(&str, &str)] = &[
(
"rm -rf /",
"Recursive forced deletion at root — this will destroy the system",
),
("rm -rf ~", "Recursive forced deletion of home directory"),
(
"rm -rf *",
"Recursive forced deletion of all files in current directory",
),
("rm -rf .", "Recursive forced deletion of current directory"),
(
"mkfs",
"Filesystem creation will destroy existing data on the device",
),
(
"dd if=",
"Direct disk write — can overwrite partitions or devices",
),
("> /dev/sd", "Writing to raw disk device"),
(
"chmod -R 777",
"Recursively setting world-writable permissions",
),
("chmod -R 000", "Recursively removing all permissions"),
(":(){ :|:& };:", "Fork bomb — will crash the system"),
];
/// Commands that are always destructive regardless of arguments.
const ALWAYS_DESTRUCTIVE_COMMANDS: &[&str] = &["shred", "wipefs"];
/// Warn if a command looks destructive.
///
/// Corresponds to upstream `tools/BashTool/destructiveCommandWarning.ts`.
#[must_use]
pub fn check_destructive(command: &str) -> ValidationResult {
// Check known destructive patterns.
for &(pattern, warning) in DESTRUCTIVE_PATTERNS {
if command.contains(pattern) {
return ValidationResult::Warn {
message: format!("Destructive command detected: {warning}"),
};
}
}
// Check always-destructive commands.
let first = extract_first_command(command);
for &cmd in ALWAYS_DESTRUCTIVE_COMMANDS {
if first == cmd {
return ValidationResult::Warn {
message: format!(
"Command '{cmd}' is inherently destructive and may cause data loss"
),
};
}
}
// Check for "rm -rf" with broad targets.
if command.contains("rm ") && command.contains("-r") && command.contains("-f") {
// Already handled the most dangerous patterns above.
// Flag any remaining "rm -rf" as a warning.
return ValidationResult::Warn {
message: "Recursive forced deletion detected — verify the target path is correct"
.to_string(),
};
}
ValidationResult::Allow
}
// ---------------------------------------------------------------------------
// modeValidation
// ---------------------------------------------------------------------------
/// Validate that a command is consistent with the given permission mode.
///
/// Corresponds to upstream `tools/BashTool/modeValidation.ts`.
#[must_use]
pub fn validate_mode(command: &str, mode: PermissionMode) -> ValidationResult {
match mode {
PermissionMode::ReadOnly => validate_read_only(command, mode),
PermissionMode::WorkspaceWrite => {
// In workspace-write mode, check for system-level destructive
// operations that go beyond workspace scope.
if command_targets_outside_workspace(command) {
return ValidationResult::Warn {
message:
"Command appears to target files outside the workspace — requires elevated permission"
.to_string(),
};
}
ValidationResult::Allow
}
PermissionMode::DangerFullAccess | PermissionMode::Allow | PermissionMode::Prompt => {
ValidationResult::Allow
}
}
}
/// Heuristic: does the command reference absolute paths outside typical workspace dirs?
fn command_targets_outside_workspace(command: &str) -> bool {
let system_paths = [
"/etc/", "/usr/", "/var/", "/boot/", "/sys/", "/proc/", "/dev/", "/sbin/", "/lib/", "/opt/",
];
let first = extract_first_command(command);
let is_write_cmd = WRITE_COMMANDS.contains(&first.as_str())
|| STATE_MODIFYING_COMMANDS.contains(&first.as_str());
if !is_write_cmd {
return false;
}
for sys_path in &system_paths {
if command.contains(sys_path) {
return true;
}
}
false
}
// ---------------------------------------------------------------------------
// sedValidation
// ---------------------------------------------------------------------------
/// Validate sed expressions for safety.
///
/// Corresponds to upstream `tools/BashTool/sedValidation.ts`.
#[must_use]
pub fn validate_sed(command: &str, mode: PermissionMode) -> ValidationResult {
let first = extract_first_command(command);
if first != "sed" {
return ValidationResult::Allow;
}
// In read-only mode, block sed -i (in-place editing).
if mode == PermissionMode::ReadOnly && command.contains(" -i") {
return ValidationResult::Block {
reason: "sed -i (in-place editing) is not allowed in read-only mode".to_string(),
};
}
ValidationResult::Allow
}
// ---------------------------------------------------------------------------
// pathValidation
// ---------------------------------------------------------------------------
/// Validate that command paths don't include suspicious traversal patterns.
///
/// Corresponds to upstream `tools/BashTool/pathValidation.ts`.
#[must_use]
pub fn validate_paths(command: &str, workspace: &Path) -> ValidationResult {
// Check for directory traversal attempts.
if command.contains("../") {
let workspace_str = workspace.to_string_lossy();
// Allow traversal if it resolves within workspace (heuristic).
if !command.contains(&*workspace_str) {
return ValidationResult::Warn {
message: "Command contains directory traversal pattern '../' — verify the target path resolves within the workspace".to_string(),
};
}
}
// Check for home directory references that could escape workspace.
if command.contains("~/") || command.contains("$HOME") {
return ValidationResult::Warn {
message:
"Command references home directory — verify it stays within the workspace scope"
.to_string(),
};
}
ValidationResult::Allow
}
// ---------------------------------------------------------------------------
// commandSemantics
// ---------------------------------------------------------------------------
/// Commands that are read-only (no filesystem or state modification).
const SEMANTIC_READ_ONLY_COMMANDS: &[&str] = &[
"ls",
"cat",
"head",
"tail",
"less",
"more",
"wc",
"sort",
"uniq",
"grep",
"egrep",
"fgrep",
"find",
"which",
"whereis",
"whatis",
"man",
"info",
"file",
"stat",
"du",
"df",
"free",
"uptime",
"uname",
"hostname",
"whoami",
"id",
"groups",
"env",
"printenv",
"echo",
"printf",
"date",
"cal",
"bc",
"expr",
"test",
"true",
"false",
"pwd",
"tree",
"diff",
"cmp",
"md5sum",
"sha256sum",
"sha1sum",
"xxd",
"od",
"hexdump",
"strings",
"readlink",
"realpath",
"basename",
"dirname",
"seq",
"yes",
"tput",
"column",
"jq",
"yq",
"xargs",
"tr",
"cut",
"paste",
"awk",
"sed",
];
/// Commands that perform network operations.
const NETWORK_COMMANDS: &[&str] = &[
"curl",
"wget",
"ssh",
"scp",
"rsync",
"ftp",
"sftp",
"nc",
"ncat",
"telnet",
"ping",
"traceroute",
"dig",
"nslookup",
"host",
"whois",
"ifconfig",
"ip",
"netstat",
"ss",
"nmap",
];
/// Commands that manage processes.
const PROCESS_COMMANDS: &[&str] = &[
"kill", "pkill", "killall", "ps", "top", "htop", "bg", "fg", "jobs", "nohup", "disown", "wait",
"nice", "renice",
];
/// Commands that manage packages.
const PACKAGE_COMMANDS: &[&str] = &[
"apt", "apt-get", "yum", "dnf", "pacman", "brew", "pip", "pip3", "npm", "yarn", "pnpm", "bun",
"cargo", "gem", "go", "rustup", "snap", "flatpak",
];
/// Commands that require system administrator privileges.
const SYSTEM_ADMIN_COMMANDS: &[&str] = &[
"sudo",
"su",
"chroot",
"mount",
"umount",
"fdisk",
"parted",
"lsblk",
"blkid",
"systemctl",
"service",
"journalctl",
"dmesg",
"modprobe",
"insmod",
"rmmod",
"iptables",
"ufw",
"firewall-cmd",
"sysctl",
"crontab",
"at",
"useradd",
"userdel",
"usermod",
"groupadd",
"groupdel",
"passwd",
"visudo",
];
/// Classify the semantic intent of a bash command.
///
/// Corresponds to upstream `tools/BashTool/commandSemantics.ts`.
#[must_use]
pub fn classify_command(command: &str) -> CommandIntent {
let first = extract_first_command(command);
classify_by_first_command(&first, command)
}
fn classify_by_first_command(first: &str, command: &str) -> CommandIntent {
if SEMANTIC_READ_ONLY_COMMANDS.contains(&first) {
if first == "sed" && command.contains(" -i") {
return CommandIntent::Write;
}
return CommandIntent::ReadOnly;
}
if ALWAYS_DESTRUCTIVE_COMMANDS.contains(&first) || first == "rm" {
return CommandIntent::Destructive;
}
if WRITE_COMMANDS.contains(&first) {
return CommandIntent::Write;
}
if NETWORK_COMMANDS.contains(&first) {
return CommandIntent::Network;
}
if PROCESS_COMMANDS.contains(&first) {
return CommandIntent::ProcessManagement;
}
if PACKAGE_COMMANDS.contains(&first) {
return CommandIntent::PackageManagement;
}
if SYSTEM_ADMIN_COMMANDS.contains(&first) {
return CommandIntent::SystemAdmin;
}
if first == "git" {
return classify_git_command(command);
}
CommandIntent::Unknown
}
fn classify_git_command(command: &str) -> CommandIntent {
let parts: Vec<&str> = command.split_whitespace().collect();
let subcommand = parts.iter().skip(1).find(|p| !p.starts_with('-'));
match subcommand {
Some(&sub) if GIT_READ_ONLY_SUBCOMMANDS.contains(&sub) => CommandIntent::ReadOnly,
_ => CommandIntent::Write,
}
}
// ---------------------------------------------------------------------------
// Pipeline: run all validations
// ---------------------------------------------------------------------------
/// Run the full validation pipeline on a bash command.
///
/// Returns the first non-Allow result, or Allow if all validations pass.
#[must_use]
pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) -> ValidationResult {
// 1. Mode-level validation (includes read-only checks).
let result = validate_mode(command, mode);
if result != ValidationResult::Allow {
return result;
}
// 2. Sed-specific validation.
let result = validate_sed(command, mode);
if result != ValidationResult::Allow {
return result;
}
// 3. Destructive command warnings.
let result = check_destructive(command);
if result != ValidationResult::Allow {
return result;
}
// 4. Path validation.
validate_paths(command, workspace)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Extract the first bare command from a pipeline/chain, stripping env vars and sudo.
fn extract_first_command(command: &str) -> String {
let trimmed = command.trim();
// Skip leading environment variable assignments (KEY=val cmd ...).
let mut remaining = trimmed;
loop {
let next = remaining.trim_start();
if let Some(eq_pos) = next.find('=') {
let before_eq = &next[..eq_pos];
// Valid env var name: alphanumeric + underscore, no spaces.
if !before_eq.is_empty()
&& before_eq
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
// Skip past the value (might be quoted).
let after_eq = &next[eq_pos + 1..];
if let Some(space) = find_end_of_value(after_eq) {
remaining = &after_eq[space..];
continue;
}
// No space found means value goes to end of string — no actual command.
return String::new();
}
}
break;
}
remaining
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
/// Extract the command following "sudo" (skip sudo flags).
fn extract_sudo_inner(command: &str) -> &str {
let parts: Vec<&str> = command.split_whitespace().collect();
let sudo_idx = parts.iter().position(|&p| p == "sudo");
match sudo_idx {
Some(idx) => {
// Skip flags after sudo.
let rest = &parts[idx + 1..];
for &part in rest {
if !part.starts_with('-') {
// Found the inner command — return from here to end.
let offset = command.find(part).unwrap_or(0);
return &command[offset..];
}
}
""
}
None => "",
}
}
/// Find the end of a value in `KEY=value rest` (handles basic quoting).
fn find_end_of_value(s: &str) -> Option<usize> {
let s = s.trim_start();
if s.is_empty() {
return None;
}
let first = s.as_bytes()[0];
if first == b'"' || first == b'\'' {
let quote = first;
let mut i = 1;
while i < s.len() {
if s.as_bytes()[i] == quote && (i == 0 || s.as_bytes()[i - 1] != b'\\') {
// Skip past quote.
i += 1;
// Find next whitespace.
while i < s.len() && !s.as_bytes()[i].is_ascii_whitespace() {
i += 1;
}
return if i < s.len() { Some(i) } else { None };
}
i += 1;
}
None
} else {
s.find(char::is_whitespace)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
// --- readOnlyValidation ---
#[test]
fn blocks_rm_in_read_only() {
assert!(matches!(
validate_read_only("rm -rf /tmp/x", PermissionMode::ReadOnly),
ValidationResult::Block { reason } if reason.contains("rm")
));
}
#[test]
fn allows_rm_in_workspace_write() {
assert_eq!(
validate_read_only("rm -rf /tmp/x", PermissionMode::WorkspaceWrite),
ValidationResult::Allow
);
}
#[test]
fn blocks_write_redirections_in_read_only() {
assert!(matches!(
validate_read_only("echo hello > file.txt", PermissionMode::ReadOnly),
ValidationResult::Block { reason } if reason.contains("redirection")
));
}
#[test]
fn allows_read_commands_in_read_only() {
assert_eq!(
validate_read_only("ls -la", PermissionMode::ReadOnly),
ValidationResult::Allow
);
assert_eq!(
validate_read_only("cat /etc/hosts", PermissionMode::ReadOnly),
ValidationResult::Allow
);
assert_eq!(
validate_read_only("grep -r pattern .", PermissionMode::ReadOnly),
ValidationResult::Allow
);
}
#[test]
fn blocks_sudo_write_in_read_only() {
assert!(matches!(
validate_read_only("sudo rm -rf /tmp/x", PermissionMode::ReadOnly),
ValidationResult::Block { reason } if reason.contains("rm")
));
}
#[test]
fn blocks_git_push_in_read_only() {
assert!(matches!(
validate_read_only("git push origin main", PermissionMode::ReadOnly),
ValidationResult::Block { reason } if reason.contains("push")
));
}
#[test]
fn allows_git_status_in_read_only() {
assert_eq!(
validate_read_only("git status", PermissionMode::ReadOnly),
ValidationResult::Allow
);
}
#[test]
fn blocks_package_install_in_read_only() {
assert!(matches!(
validate_read_only("npm install express", PermissionMode::ReadOnly),
ValidationResult::Block { reason } if reason.contains("npm")
));
}
// --- destructiveCommandWarning ---
#[test]
fn warns_rm_rf_root() {
assert!(matches!(
check_destructive("rm -rf /"),
ValidationResult::Warn { message } if message.contains("root")
));
}
#[test]
fn warns_rm_rf_home() {
assert!(matches!(
check_destructive("rm -rf ~"),
ValidationResult::Warn { message } if message.contains("home")
));
}
#[test]
fn warns_shred() {
assert!(matches!(
check_destructive("shred /dev/sda"),
ValidationResult::Warn { message } if message.contains("destructive")
));
}
#[test]
fn warns_fork_bomb() {
assert!(matches!(
check_destructive(":(){ :|:& };:"),
ValidationResult::Warn { message } if message.contains("Fork bomb")
));
}
#[test]
fn allows_safe_commands() {
assert_eq!(check_destructive("ls -la"), ValidationResult::Allow);
assert_eq!(check_destructive("echo hello"), ValidationResult::Allow);
}
// --- modeValidation ---
#[test]
fn workspace_write_warns_system_paths() {
assert!(matches!(
validate_mode("cp file.txt /etc/config", PermissionMode::WorkspaceWrite),
ValidationResult::Warn { message } if message.contains("outside the workspace")
));
}
#[test]
fn workspace_write_allows_local_writes() {
assert_eq!(
validate_mode("cp file.txt ./backup/", PermissionMode::WorkspaceWrite),
ValidationResult::Allow
);
}
// --- sedValidation ---
#[test]
fn blocks_sed_inplace_in_read_only() {
assert!(matches!(
validate_sed("sed -i 's/old/new/' file.txt", PermissionMode::ReadOnly),
ValidationResult::Block { reason } if reason.contains("sed -i")
));
}
#[test]
fn allows_sed_stdout_in_read_only() {
assert_eq!(
validate_sed("sed 's/old/new/' file.txt", PermissionMode::ReadOnly),
ValidationResult::Allow
);
}
// --- pathValidation ---
#[test]
fn warns_directory_traversal() {
let workspace = PathBuf::from("/workspace/project");
assert!(matches!(
validate_paths("cat ../../../etc/passwd", &workspace),
ValidationResult::Warn { message } if message.contains("traversal")
));
}
#[test]
fn warns_home_directory_reference() {
let workspace = PathBuf::from("/workspace/project");
assert!(matches!(
validate_paths("cat ~/.ssh/id_rsa", &workspace),
ValidationResult::Warn { message } if message.contains("home directory")
));
}
// --- commandSemantics ---
#[test]
fn classifies_read_only_commands() {
assert_eq!(classify_command("ls -la"), CommandIntent::ReadOnly);
assert_eq!(classify_command("cat file.txt"), CommandIntent::ReadOnly);
assert_eq!(
classify_command("grep -r pattern ."),
CommandIntent::ReadOnly
);
assert_eq!(
classify_command("find . -name '*.rs'"),
CommandIntent::ReadOnly
);
}
#[test]
fn classifies_write_commands() {
assert_eq!(classify_command("cp a.txt b.txt"), CommandIntent::Write);
assert_eq!(classify_command("mv old.txt new.txt"), CommandIntent::Write);
assert_eq!(classify_command("mkdir -p /tmp/dir"), CommandIntent::Write);
}
#[test]
fn classifies_destructive_commands() {
assert_eq!(
classify_command("rm -rf /tmp/x"),
CommandIntent::Destructive
);
assert_eq!(
classify_command("shred /dev/sda"),
CommandIntent::Destructive
);
}
#[test]
fn classifies_network_commands() {
assert_eq!(
classify_command("curl https://example.com"),
CommandIntent::Network
);
assert_eq!(classify_command("wget file.zip"), CommandIntent::Network);
}
#[test]
fn classifies_sed_inplace_as_write() {
assert_eq!(
classify_command("sed -i 's/old/new/' file.txt"),
CommandIntent::Write
);
}
#[test]
fn classifies_sed_stdout_as_read_only() {
assert_eq!(
classify_command("sed 's/old/new/' file.txt"),
CommandIntent::ReadOnly
);
}
#[test]
fn classifies_git_status_as_read_only() {
assert_eq!(classify_command("git status"), CommandIntent::ReadOnly);
assert_eq!(
classify_command("git log --oneline"),
CommandIntent::ReadOnly
);
}
#[test]
fn classifies_git_push_as_write() {
assert_eq!(
classify_command("git push origin main"),
CommandIntent::Write
);
}
// --- validate_command (full pipeline) ---
#[test]
fn pipeline_blocks_write_in_read_only() {
let workspace = PathBuf::from("/workspace");
assert!(matches!(
validate_command("rm -rf /tmp/x", PermissionMode::ReadOnly, &workspace),
ValidationResult::Block { .. }
));
}
#[test]
fn pipeline_warns_destructive_in_write_mode() {
let workspace = PathBuf::from("/workspace");
assert!(matches!(
validate_command("rm -rf /", PermissionMode::WorkspaceWrite, &workspace),
ValidationResult::Warn { .. }
));
}
#[test]
fn pipeline_allows_safe_read_in_read_only() {
let workspace = PathBuf::from("/workspace");
assert_eq!(
validate_command("ls -la", PermissionMode::ReadOnly, &workspace),
ValidationResult::Allow
);
}
// --- extract_first_command ---
#[test]
fn extracts_command_from_env_prefix() {
assert_eq!(extract_first_command("FOO=bar ls -la"), "ls");
assert_eq!(extract_first_command("A=1 B=2 echo hello"), "echo");
}
#[test]
fn extracts_plain_command() {
assert_eq!(extract_first_command("grep -r pattern ."), "grep");
}
}