diff --git a/config/security.toml b/config/security.toml new file mode 100644 index 0000000..7eb8935 --- /dev/null +++ b/config/security.toml @@ -0,0 +1,107 @@ +# ZCLAW Security Configuration +# Controls which commands and operations are allowed + +[shell_exec] +# Enable shell command execution +enabled = true +# Default timeout in seconds +default_timeout = 60 +# Maximum output size in bytes +max_output_size = 1048576 # 1MB + +# Whitelist of allowed commands +# If whitelist is non-empty, only these commands are allowed +allowed_commands = [ + "git", + "npm", + "pnpm", + "node", + "cargo", + "rustc", + "python", + "python3", + "pip", + "ls", + "cat", + "echo", + "mkdir", + "rm", + "cp", + "mv", + "grep", + "find", + "head", + "tail", + "wc", +] + +# Blacklist of dangerous commands (always blocked) +blocked_commands = [ + "rm -rf /", + "dd", + "mkfs", + "format", + "shutdown", + "reboot", + "init", + "systemctl", +] + +[file_read] +enabled = true +# Allowed directory prefixes (empty = allow all) +allowed_paths = [] +# Blocked paths (always blocked) +blocked_paths = [ + "/etc/shadow", + "/etc/passwd", + "~/.ssh", + "~/.gnupg", +] + +[file_write] +enabled = true +# Maximum file size in bytes (10MB) +max_file_size = 10485760 +# Blocked paths +blocked_paths = [ + "/etc", + "/usr", + "/bin", + "/sbin", + "C:\\Windows", + "C:\\Program Files", +] + +[web_fetch] +enabled = true +# Request timeout in seconds +timeout = 30 +# Maximum response size in bytes (10MB) +max_response_size = 10485760 +# Block internal/private IP ranges (SSRF protection) +block_private_ips = true +# Allowed domains (empty = allow all) +allowed_domains = [] +# Blocked domains +blocked_domains = [] + +[browser] +# Browser automation settings +enabled = true +# Default page load timeout in seconds +page_timeout = 30 +# Maximum concurrent sessions +max_sessions = 5 +# Block access to internal networks +block_internal_networks = true + +[mcp] +# MCP protocol settings +enabled = true +# Allowed MCP servers (empty = allow all) +allowed_servers = [] +# Blocked MCP servers +blocked_servers = [] +# Maximum tool execution time in seconds +max_tool_time = 300 diff --git a/crates/zclaw-runtime/Cargo.toml b/crates/zclaw-runtime/Cargo.toml index 7d37d54..70056c3 100644 --- a/crates/zclaw-runtime/Cargo.toml +++ b/crates/zclaw-runtime/Cargo.toml @@ -17,6 +17,7 @@ futures = { workspace = true } async-stream = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/zclaw-runtime/src/tool/builtin/shell_exec.rs b/crates/zclaw-runtime/src/tool/builtin/shell_exec.rs index 77bf1db..7802a0c 100644 --- a/crates/zclaw-runtime/src/tool/builtin/shell_exec.rs +++ b/crates/zclaw-runtime/src/tool/builtin/shell_exec.rs @@ -1,16 +1,129 @@ -//! Shell execution tool +//! Shell execution tool with security controls use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::HashSet; +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; use zclaw_types::{Result, ZclawError}; use crate::tool::{Tool, ToolContext}; -pub struct ShellExecTool; +/// Security configuration for shell execution +#[derive(Debug, Clone, Deserialize)] +pub struct ShellSecurityConfig { + pub enabled: bool, + pub default_timeout: u64, + pub max_output_size: usize, + pub allowed_commands: Vec, + pub blocked_commands: Vec, +} + +impl Default for ShellSecurityConfig { + fn default() -> Self { + Self { + enabled: true, + default_timeout: 60, + max_output_size: 1024 * 1024, // 1MB + allowed_commands: vec![ + "git".to_string(), "npm".to_string(), "pnpm".to_string(), + "node".to_string(), "cargo".to_string(), "rustc".to_string(), + "python".to_string(), "python3".to_string(), "pip".to_string(), + "ls".to_string(), "cat".to_string(), "echo".to_string(), + "mkdir".to_string(), "rm".to_string(), "cp".to_string(), + "mv".to_string(), "grep".to_string(), "find".to_string(), + "head".to_string(), "tail".to_string(), "wc".to_string(), + ], + blocked_commands: vec![ + "rm -rf /".to_string(), "dd".to_string(), "mkfs".to_string(), + "shutdown".to_string(), "reboot".to_string(), + "format".to_string(), "init".to_string(), + ], + } + } +} + +impl ShellSecurityConfig { + /// Load from config file + pub fn load() -> Self { + // Try to load from config/security.toml + if let Ok(content) = std::fs::read_to_string("config/security.toml") { + if let Ok(config) = toml::from_str::(&content) { + return config.shell_exec; + } + } + Self::default() + } + + /// Check if a command is allowed + pub fn is_command_allowed(&self, command: &str) -> Result<()> { + if !self.enabled { + return Err(ZclawError::SecurityError( + "Shell execution is disabled".to_string() + )); + } + + // Parse the base command + let base_cmd = command.split_whitespace().next().unwrap_or(""); + + // Check blocked commands first + for blocked in &self.blocked_commands { + if command.starts_with(blocked) || command.contains(blocked) { + return Err(ZclawError::SecurityError( + format!("Command blocked: {}", blocked) + )); + } + } + + // If whitelist is non-empty, check against it + if !self.allowed_commands.is_empty() { + let allowed: HashSet<&str> = self.allowed_commands + .iter() + .map(|s| s.as_str()) + .collect(); + + // Extract base command name (strip path if present) + let cmd_name = if base_cmd.contains('/') || base_cmd.contains('\\') { + base_cmd.rsplit(|c| c == '/' || c == '\\').next().unwrap_or(base_cmd) + } else if cfg!(windows) && base_cmd.ends_with(".exe") { + &base_cmd[..base_cmd.len() - 4] + } else { + base_cmd + }; + + if !allowed.contains(cmd_name) && !allowed.contains(base_cmd) { + return Err(ZclawError::SecurityError( + format!("Command not in whitelist: {}", base_cmd) + )); + } + } + + Ok(()) + } +} + +/// Security config file structure +#[derive(Debug, Deserialize)] +struct SecurityConfigFile { + shell_exec: ShellSecurityConfig, +} + +pub struct ShellExecTool { + config: ShellSecurityConfig, +} impl ShellExecTool { pub fn new() -> Self { - Self + Self { + config: ShellSecurityConfig::load(), + } + } + + /// Create with custom config (for testing) + pub fn with_config(config: ShellSecurityConfig) -> Self { + Self { config } } } @@ -21,7 +134,7 @@ impl Tool for ShellExecTool { } fn description(&self) -> &str { - "Execute a shell command and return the output" + "Execute a shell command with security controls and return the output" } fn input_schema(&self) -> Value { @@ -34,7 +147,11 @@ impl Tool for ShellExecTool { }, "timeout": { "type": "integer", - "description": "Timeout in seconds (default: 30)" + "description": "Timeout in seconds (default: 60)" + }, + "cwd": { + "type": "string", + "description": "Working directory for the command" } }, "required": ["command"] @@ -45,11 +162,79 @@ impl Tool for ShellExecTool { let command = input["command"].as_str() .ok_or_else(|| ZclawError::InvalidInput("Missing 'command' parameter".into()))?; - // TODO: Implement actual shell execution with security constraints + let timeout_secs = input["timeout"].as_u64().unwrap_or(self.config.default_timeout); + let cwd = input["cwd"].as_str(); + + // Security check + self.config.is_command_allowed(command)?; + + // Parse command into program and args + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Err(ZclawError::InvalidInput("Empty command".into())); + } + + let program = parts[0]; + let args = &parts[1..]; + + // Build command + let mut cmd = Command::new(program); + cmd.args(args); + + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + + // Set up pipes + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let start = Instant::now(); + + // Execute command + let output = tokio::task::spawn_blocking(move || { + cmd.output() + }) + .await + .map_err(|e| ZclawError::ToolError(format!("Task spawn error: {}", e)))? + .map_err(|e| ZclawError::ToolError(format!("Command execution failed: {}", e)))?; + + let duration = start.elapsed(); + + // Check timeout + if duration > Duration::from_secs(timeout_secs) { + return Err(ZclawError::Timeout( + format!("Command timed out after {} seconds", timeout_secs) + )); + } + + // Truncate output if too large + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let stdout = if stdout.len() > self.config.max_output_size { + format!("{}...\n[Output truncated, exceeded {} bytes]", + &stdout[..self.config.max_output_size], + self.config.max_output_size) + } else { + stdout.to_string() + }; + + let stderr = if stderr.len() > self.config.max_output_size { + format!("{}...\n[Output truncated, exceeded {} bytes]", + &stderr[..self.config.max_output_size], + self.config.max_output_size) + } else { + stderr.to_string() + }; + Ok(json!({ - "stdout": format!("Command output placeholder for: {}", command), - "stderr": "", - "exit_code": 0 + "stdout": stdout, + "stderr": stderr, + "exit_code": output.status.code().unwrap_or(-1), + "duration_ms": duration.as_millis(), + "success": output.status.success() })) } } @@ -59,3 +244,32 @@ impl Default for ShellExecTool { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_security_config_default() { + let config = ShellSecurityConfig::default(); + assert!(config.enabled); + assert!(!config.allowed_commands.is_empty()); + assert!(!config.blocked_commands.is_empty()); + } + + #[test] + fn test_command_allowed() { + let config = ShellSecurityConfig::default(); + + // Should allow whitelisted commands + assert!(config.is_command_allowed("ls -la").is_ok()); + assert!(config.is_command_allowed("git status").is_ok()); + + // Should block dangerous commands + assert!(config.is_command_allowed("rm -rf /").is_err()); + assert!(config.is_command_allowed("shutdown").is_err()); + + // Should block non-whitelisted commands + assert!(config.is_command_allowed("dangerous_command").is_err()); + } +} diff --git a/crates/zclaw-types/src/error.rs b/crates/zclaw-types/src/error.rs index 84ae434..9a71bfc 100644 --- a/crates/zclaw-types/src/error.rs +++ b/crates/zclaw-types/src/error.rs @@ -52,6 +52,9 @@ pub enum ZclawError { #[error("MCP error: {0}")] McpError(String), + + #[error("Security error: {0}")] + SecurityError(String), } /// Result type alias for ZCLAW operations