diff --git a/rust/crates/runtime/src/bash.rs b/rust/crates/runtime/src/bash.rs index a159ec6..ef9ff8f 100644 --- a/rust/crates/runtime/src/bash.rs +++ b/rust/crates/runtime/src/bash.rs @@ -134,8 +134,8 @@ async fn execute_bash_async( }; let (output, interrupted) = output_result; - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout)); + let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr)); let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty()); let return_code_interpretation = output.status.code().and_then(|code| { if code == 0 { @@ -281,3 +281,53 @@ mod tests { assert!(!output.sandbox_status.expect("sandbox status").enabled); } } + +/// Maximum output bytes before truncation (16 KiB, matching upstream). +const MAX_OUTPUT_BYTES: usize = 16_384; + +/// Truncate output to `MAX_OUTPUT_BYTES`, appending a marker when trimmed. +fn truncate_output(s: &str) -> String { + if s.len() <= MAX_OUTPUT_BYTES { + return s.to_string(); + } + // Find the last valid UTF-8 boundary at or before MAX_OUTPUT_BYTES + let mut end = MAX_OUTPUT_BYTES; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + let mut truncated = s[..end].to_string(); + truncated.push_str("\n\n[output truncated — exceeded 16384 bytes]"); + truncated +} + +#[cfg(test)] +mod truncation_tests { + use super::*; + + #[test] + fn short_output_unchanged() { + let s = "hello world"; + assert_eq!(truncate_output(s), s); + } + + #[test] + fn long_output_truncated() { + let s = "x".repeat(20_000); + let result = truncate_output(&s); + assert!(result.len() < 20_000); + assert!(result.ends_with("[output truncated — exceeded 16384 bytes]")); + } + + #[test] + fn exact_boundary_unchanged() { + let s = "a".repeat(MAX_OUTPUT_BYTES); + assert_eq!(truncate_output(&s), s); + } + + #[test] + fn one_over_boundary_truncated() { + let s = "a".repeat(MAX_OUTPUT_BYTES + 1); + let result = truncate_output(&s); + assert!(result.contains("[output truncated")); + } +} diff --git a/rust/crates/runtime/src/bash_validation.rs b/rust/crates/runtime/src/bash_validation.rs new file mode 100644 index 0000000..f00619e --- /dev/null +++ b/rust/crates/runtime/src/bash_validation.rs @@ -0,0 +1,1004 @@ +//! 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 { + 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"); + } +} diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 96ba644..c249325 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -1,4 +1,5 @@ mod bash; +pub mod bash_validation; mod bootstrap; mod compact; mod config;