feat(security): add security configuration and tool validation
Security Configuration: - config/security.toml with shell_exec, file_read, file_write, web_fetch, browser, and mcp settings - Command whitelist/blacklist for shell execution - Path restrictions for file operations - SSRF protection for web fetch Tool Security Implementation: - ShellSecurityConfig with whitelist/blacklist validation - ShellExecTool with actual command execution - Timeout and output size limits - Security checks before command execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
107
config/security.toml
Normal file
107
config/security.toml
Normal file
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<String>,
|
||||
pub blocked_commands: Vec<String>,
|
||||
}
|
||||
|
||||
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::<SecurityConfigFile>(&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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user