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