Quellcode durchsuchen

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
Jobdori vor 2 Monaten
Ursprung
Commit
1cfd78ac61

+ 52 - 2
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"));
+    }
+}

+ 1004 - 0
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<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");
+    }
+}

+ 1 - 0
rust/crates/runtime/src/lib.rs

@@ -1,4 +1,5 @@
 mod bash;
+pub mod bash_validation;
 mod bootstrap;
 mod compact;
 mod config;