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 }
|
async-stream = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -1,16 +1,129 @@
|
|||||||
//! Shell execution tool
|
//! Shell execution tool with security controls
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
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 zclaw_types::{Result, ZclawError};
|
||||||
|
|
||||||
use crate::tool::{Tool, ToolContext};
|
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 {
|
impl ShellExecTool {
|
||||||
pub fn new() -> Self {
|
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 {
|
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 {
|
fn input_schema(&self) -> Value {
|
||||||
@@ -34,7 +147,11 @@ impl Tool for ShellExecTool {
|
|||||||
},
|
},
|
||||||
"timeout": {
|
"timeout": {
|
||||||
"type": "integer",
|
"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"]
|
"required": ["command"]
|
||||||
@@ -45,11 +162,79 @@ impl Tool for ShellExecTool {
|
|||||||
let command = input["command"].as_str()
|
let command = input["command"].as_str()
|
||||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'command' parameter".into()))?;
|
.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!({
|
Ok(json!({
|
||||||
"stdout": format!("Command output placeholder for: {}", command),
|
"stdout": stdout,
|
||||||
"stderr": "",
|
"stderr": stderr,
|
||||||
"exit_code": 0
|
"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()
|
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}")]
|
#[error("MCP error: {0}")]
|
||||||
McpError(String),
|
McpError(String),
|
||||||
|
|
||||||
|
#[error("Security error: {0}")]
|
||||||
|
SecurityError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result type alias for ZCLAW operations
|
/// Result type alias for ZCLAW operations
|
||||||
|
|||||||
Reference in New Issue
Block a user