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