| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004 |
- //! 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");
- }
- }
|