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:
Illia Polosukhin
2026-02-04 22:09:52 -08:00
co-authored by Claude Opus 4.5
parent 4ab20ff939
commit a39f5aa1a4
14 changed files with 2787 additions and 32 deletions
+145 -30
View File
@@ -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));
}
}