mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-03 10:09:30 +00:00
Add Docker execution sandbox for secure shell command isolation
Implements a general-purpose Docker sandbox (inspired by Codex) that provides: - Container isolation for shell commands with ephemeral containers - HTTP proxy for network access control with domain allowlist - Credential injection by proxy (secrets never enter containers) - Three security policies: ReadOnly, WorkspaceWrite, FullAccess - Resource limits (memory, CPU, timeout enforcement) Key components: - SandboxManager: Main entry point coordinating proxy and containers - NetworkProxy: HTTP proxy validating requests and injecting credentials - ContainerRunner: Docker lifecycle management via bollard - DomainAllowlist: Pattern matching for allowed network destinations The ShellTool now routes commands through the sandbox when enabled, with automatic fallback to direct execution if Docker is unavailable. Configuration via SANDBOX_ENABLED, SANDBOX_POLICY, SANDBOX_TIMEOUT_SECS, SANDBOX_MEMORY_LIMIT_MB, SANDBOX_IMAGE, SANDBOX_EXTRA_DOMAINS env vars. Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4ab20ff939
commit
a39f5aa1a4
+145
-30
@@ -1,15 +1,27 @@
|
||||
//! Shell execution tool for running commands in a sandboxed environment.
|
||||
//!
|
||||
//! Provides controlled command execution with:
|
||||
//! - Docker sandbox isolation (when enabled)
|
||||
//! - Working directory isolation
|
||||
//! - Timeout enforcement
|
||||
//! - Output capture and truncation
|
||||
//! - Blocked command patterns for safety
|
||||
//!
|
||||
//! # Execution Modes
|
||||
//!
|
||||
//! When sandbox is available and enabled:
|
||||
//! - Commands run inside ephemeral Docker containers
|
||||
//! - Network traffic goes through a validating proxy
|
||||
//! - Credentials are injected by the proxy, never exposed to commands
|
||||
//!
|
||||
//! When sandbox is unavailable:
|
||||
//! - Commands run directly on host with basic protections
|
||||
//! - Blocked command patterns are still enforced
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -17,6 +29,7 @@ use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::sandbox::{SandboxManager, SandboxPolicy};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Maximum output size before truncation (64KB).
|
||||
@@ -62,7 +75,6 @@ static DANGEROUS_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||
});
|
||||
|
||||
/// Shell command execution tool.
|
||||
#[derive(Debug)]
|
||||
pub struct ShellTool {
|
||||
/// Working directory for commands (if None, uses job's working dir or cwd).
|
||||
working_dir: Option<PathBuf>,
|
||||
@@ -70,6 +82,22 @@ pub struct ShellTool {
|
||||
timeout: Duration,
|
||||
/// Whether to allow potentially dangerous commands (requires explicit approval).
|
||||
allow_dangerous: bool,
|
||||
/// Optional sandbox manager for Docker execution.
|
||||
sandbox: Option<Arc<SandboxManager>>,
|
||||
/// Sandbox policy to use when sandbox is available.
|
||||
sandbox_policy: SandboxPolicy,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ShellTool {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ShellTool")
|
||||
.field("working_dir", &self.working_dir)
|
||||
.field("timeout", &self.timeout)
|
||||
.field("allow_dangerous", &self.allow_dangerous)
|
||||
.field("sandbox", &self.sandbox.is_some())
|
||||
.field("sandbox_policy", &self.sandbox_policy)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellTool {
|
||||
@@ -79,6 +107,8 @@ impl ShellTool {
|
||||
working_dir: None,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
allow_dangerous: false,
|
||||
sandbox: None,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +124,18 @@ impl ShellTool {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable sandbox execution with the given manager.
|
||||
pub fn with_sandbox(mut self, sandbox: Arc<SandboxManager>) -> Self {
|
||||
self.sandbox = Some(sandbox);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the sandbox policy.
|
||||
pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
|
||||
self.sandbox_policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a command is blocked.
|
||||
fn is_blocked(&self, cmd: &str) -> Option<&'static str> {
|
||||
let normalized = cmd.to_lowercase();
|
||||
@@ -115,28 +157,44 @@ impl ShellTool {
|
||||
None
|
||||
}
|
||||
|
||||
/// Execute a command and capture output.
|
||||
async fn execute_command(
|
||||
/// Execute a command through the sandbox.
|
||||
async fn execute_sandboxed(
|
||||
&self,
|
||||
sandbox: &SandboxManager,
|
||||
cmd: &str,
|
||||
workdir: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<(String, i64), ToolError> {
|
||||
// Override sandbox config timeout if needed
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
sandbox
|
||||
.execute_with_policy(
|
||||
cmd,
|
||||
workdir,
|
||||
self.sandbox_policy,
|
||||
std::collections::HashMap::new(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
let combined = truncate_output(&output.output);
|
||||
Ok((combined, output.exit_code))
|
||||
}
|
||||
Ok(Err(e)) => Err(ToolError::ExecutionFailed(format!("Sandbox error: {}", e))),
|
||||
Err(_) => Err(ToolError::Timeout(timeout)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a command directly (fallback when sandbox unavailable).
|
||||
async fn execute_direct(
|
||||
&self,
|
||||
cmd: &str,
|
||||
workdir: Option<&str>,
|
||||
timeout: Option<u64>,
|
||||
workdir: &PathBuf,
|
||||
timeout: Duration,
|
||||
) -> Result<(String, i32), ToolError> {
|
||||
// Check for blocked commands
|
||||
if let Some(reason) = self.is_blocked(cmd) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"{}: {}",
|
||||
reason,
|
||||
truncate_for_error(cmd)
|
||||
)));
|
||||
}
|
||||
|
||||
// Determine working directory
|
||||
let cwd = workdir
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| self.working_dir.clone())
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
|
||||
// Build command
|
||||
let mut command = if cfg!(target_os = "windows") {
|
||||
let mut c = Command::new("cmd");
|
||||
@@ -149,7 +207,7 @@ impl ShellTool {
|
||||
};
|
||||
|
||||
command
|
||||
.current_dir(&cwd)
|
||||
.current_dir(workdir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
@@ -159,11 +217,8 @@ impl ShellTool {
|
||||
.spawn()
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn command: {}", e)))?;
|
||||
|
||||
// Determine timeout
|
||||
let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
|
||||
|
||||
// Wait with timeout
|
||||
let result = tokio::time::timeout(timeout_duration, async {
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
let status = child.wait().await?;
|
||||
|
||||
// Read stdout
|
||||
@@ -204,10 +259,56 @@ impl ShellTool {
|
||||
Err(_) => {
|
||||
// Timeout - try to kill the process
|
||||
let _ = child.kill().await;
|
||||
Err(ToolError::Timeout(timeout_duration))
|
||||
Err(ToolError::Timeout(timeout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a command, using sandbox if available.
|
||||
async fn execute_command(
|
||||
&self,
|
||||
cmd: &str,
|
||||
workdir: Option<&str>,
|
||||
timeout: Option<u64>,
|
||||
) -> Result<(String, i64), ToolError> {
|
||||
// Check for blocked commands
|
||||
if let Some(reason) = self.is_blocked(cmd) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"{}: {}",
|
||||
reason,
|
||||
truncate_for_error(cmd)
|
||||
)));
|
||||
}
|
||||
|
||||
// Determine working directory
|
||||
let cwd = workdir
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| self.working_dir.clone())
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
|
||||
// Determine timeout
|
||||
let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
|
||||
|
||||
// Try sandbox execution if available
|
||||
if let Some(ref sandbox) = self.sandbox {
|
||||
if sandbox.is_initialized() || sandbox.config().enabled {
|
||||
match self
|
||||
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
|
||||
.await
|
||||
{
|
||||
Ok((output, code)) => return Ok((output, code)),
|
||||
Err(e) => {
|
||||
// Log sandbox failure and fall through to direct execution
|
||||
tracing::warn!("Sandbox execution failed, falling back to direct: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct execution
|
||||
let (output, code) = self.execute_direct(cmd, &cwd, timeout_duration).await?;
|
||||
Ok((output, code as i64))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShellTool {
|
||||
@@ -224,7 +325,8 @@ impl Tool for ShellTool {
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute shell commands. Use for running builds, tests, git operations, and other CLI tasks. \
|
||||
Commands run in a subprocess with captured output. Long-running commands have a timeout."
|
||||
Commands run in a subprocess with captured output. Long-running commands have a timeout. \
|
||||
When Docker sandbox is enabled, commands run in isolated containers for security."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@@ -265,10 +367,13 @@ impl Tool for ShellTool {
|
||||
let (output, exit_code) = self.execute_command(command, workdir, timeout).await?;
|
||||
let duration = start.elapsed();
|
||||
|
||||
let sandboxed = self.sandbox.is_some();
|
||||
|
||||
let result = serde_json::json!({
|
||||
"output": output,
|
||||
"exit_code": exit_code,
|
||||
"success": exit_code == 0
|
||||
"success": exit_code == 0,
|
||||
"sandboxed": sandboxed
|
||||
});
|
||||
|
||||
Ok(ToolOutput::success(result, duration))
|
||||
@@ -348,4 +453,14 @@ mod tests {
|
||||
|
||||
assert!(matches!(result, Err(ToolError::Timeout(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_policy_builder() {
|
||||
let tool = ShellTool::new()
|
||||
.with_sandbox_policy(SandboxPolicy::WorkspaceWrite)
|
||||
.with_timeout(Duration::from_secs(60));
|
||||
|
||||
assert_eq!(tool.sandbox_policy, SandboxPolicy::WorkspaceWrite);
|
||||
assert_eq!(tool.timeout, Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user