Files
optimclaw/src/sandbox/config.rs
T
8bbb43da52 fix(security): require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy (#967)
* fix(security): require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy

FullAccess policy bypasses Docker entirely and runs commands via sh -c
directly on the host. Previously, setting SANDBOX_POLICY=full_access
alone was sufficient to enable this, which could be triggered
accidentally or via prompt injection if tool approval is bypassed.

This adds a double opt-in guard:

- New SANDBOX_ALLOW_FULL_ACCESS=true env var must ALSO be set for
  FullAccess to take effect. Without it, the policy is downgraded to
  WorkspaceWrite with a tracing::error! log.

- At execution time, every FullAccess command emits a tracing::warn!
  with the command and working directory for audit visibility.

- The FullAccess variant now documents its blast radius (host shell,
  unrestricted filesystem/network/environment).

- SandboxConfig and SandboxModeConfig gain an allow_full_access field,
  wired through from_env() and the builder.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(sandbox): address review feedback on FullAccess double opt-in

- Add doc comment on builder .policy() warning that FullAccess requires
  .allow_full_access(true) or execution will return SandboxError::Config
- Sanitize audit log: log only binary name instead of full command to
  prevent secret leakage; add [FullAccess] prefix for grep-ability
- Add test_builder_full_access_without_allow_returns_error test covering
  the builder path without explicit allow_full_access(true)
- Fix doc comment mismatch: config.rs and SandboxPolicy::FullAccess docs
  said "will downgrade to WorkspaceWrite" but runtime returns
  SandboxError::Config -- aligned docs with actual behavior

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: merge duplicate mod tests; add allow_full_access to struct initializers

After upstream merge, src/config/sandbox.rs had two issues:
- Duplicate mod tests block (upstream's original tests at line 271 + our
  new FullAccess guard tests at line 478) caused E0428 compile error
- Upstream test struct literals for SandboxModeConfig were missing the
  new allow_full_access field (E0063)

Fixes: merge the two mod tests into one; add allow_full_access: false to
the sandbox_mode_config_custom_values and sandbox_mode_to_sandbox_config
test struct initializers.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Gabe Hamilton <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-12 00:57:05 +00:00

234 lines
8.6 KiB
Rust

//! Configuration for the Docker execution sandbox.
use std::time::Duration;
/// Configuration for the sandbox system.
#[derive(Debug, Clone)]
pub struct SandboxConfig {
/// Whether the sandbox is enabled.
pub enabled: bool,
/// Security policy for sandbox execution.
pub policy: SandboxPolicy,
/// Whether `FullAccess` policy is explicitly allowed.
///
/// When `policy` is `FullAccess` but this field is `false`, the manager
/// will return `SandboxError::Config` and refuse to execute. This is an
/// intentional double opt-in to prevent accidental host execution.
/// Set via `SANDBOX_ALLOW_FULL_ACCESS=true` env var.
pub allow_full_access: bool,
/// Default timeout for command execution.
pub timeout: Duration,
/// Memory limit in megabytes.
pub memory_limit_mb: u64,
/// CPU shares (relative weight, default 1024).
pub cpu_shares: u32,
/// Network allowlist for proxied requests.
pub network_allowlist: Vec<String>,
/// Docker image to use for the sandbox.
pub image: String,
/// Whether to auto-pull the image if not found.
pub auto_pull_image: bool,
/// Port for the HTTP proxy (0 = auto-assign).
pub proxy_port: u16,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
enabled: true, // Startup check disables gracefully if Docker unavailable
policy: SandboxPolicy::ReadOnly,
allow_full_access: false,
timeout: Duration::from_secs(120),
memory_limit_mb: 2048,
cpu_shares: 1024,
network_allowlist: default_allowlist(),
image: "ironclaw-worker:latest".to_string(),
auto_pull_image: true,
proxy_port: 0,
}
}
}
/// Security policy for sandbox execution.
///
/// ```text
/// ┌─────────────────────────────────────────────────────────────────────┐
/// │ Sandbox Policies │
/// ├─────────────────┬──────────────────┬────────────────────────────────┤
/// │ Policy │ Filesystem │ Network │
/// ├─────────────────┼──────────────────┼────────────────────────────────┤
/// │ ReadOnly │ /workspace (ro) │ Proxied (allowlist only) │
/// │ WorkspaceWrite │ /workspace (rw) │ Proxied (allowlist only) │
/// │ FullAccess │ Full host │ Full network (DANGER) │
/// └─────────────────┴──────────────────┴────────────────────────────────┘
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxPolicy {
/// Read-only access to workspace, proxied network.
/// Use for: exploring code, fetching docs, read-only operations.
#[default]
ReadOnly,
/// Read/write access to workspace, proxied network.
/// Use for: building software, running tests, generating files.
WorkspaceWrite,
/// Full access (no sandbox). Use with extreme caution.
///
/// **BLAST RADIUS**: This bypasses Docker entirely and executes commands
/// via `sh -c` directly on the host with the agent process's full
/// privileges. If prompt injection bypasses tool approval, arbitrary
/// host shell commands can run. File system, network, and environment
/// are completely unrestricted.
///
/// Requires `SANDBOX_ALLOW_FULL_ACCESS=true` as a second opt-in.
/// Without it, the sandbox manager will return `SandboxError::Config`
/// and refuse to execute.
FullAccess,
}
impl SandboxPolicy {
/// Returns true if filesystem writes are allowed.
pub fn allows_writes(&self) -> bool {
matches!(
self,
SandboxPolicy::WorkspaceWrite | SandboxPolicy::FullAccess
)
}
/// Returns true if network requests bypass the proxy.
pub fn has_full_network(&self) -> bool {
matches!(self, SandboxPolicy::FullAccess)
}
/// Returns true if running in a container.
pub fn is_sandboxed(&self) -> bool {
!matches!(self, SandboxPolicy::FullAccess)
}
}
impl std::str::FromStr for SandboxPolicy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"readonly" | "read_only" | "ro" => Ok(SandboxPolicy::ReadOnly),
"workspacewrite" | "workspace_write" | "rw" => Ok(SandboxPolicy::WorkspaceWrite),
"fullaccess" | "full_access" | "full" | "none" => Ok(SandboxPolicy::FullAccess),
_ => Err(format!(
"invalid sandbox policy '{}', expected 'readonly', 'workspace_write', or 'full_access'",
s
)),
}
}
}
/// Resource limits for container execution.
#[derive(Debug, Clone)]
pub struct ResourceLimits {
/// Maximum memory in bytes.
pub memory_bytes: u64,
/// CPU shares (relative weight).
pub cpu_shares: u32,
/// Maximum execution time.
pub timeout: Duration,
/// Maximum output size in bytes.
pub max_output_bytes: usize,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
memory_bytes: 2 * 1024 * 1024 * 1024, // 2 GB
cpu_shares: 1024,
timeout: Duration::from_secs(120),
max_output_bytes: 64 * 1024, // 64 KB
}
}
}
/// Default network allowlist for common development operations.
pub fn default_allowlist() -> Vec<String> {
vec![
// Package registries
"crates.io".to_string(),
"static.crates.io".to_string(),
"index.crates.io".to_string(),
"registry.npmjs.org".to_string(),
"proxy.golang.org".to_string(),
"pypi.org".to_string(),
"files.pythonhosted.org".to_string(),
// Documentation
"docs.rs".to_string(),
"doc.rust-lang.org".to_string(),
"nodejs.org".to_string(),
"go.dev".to_string(),
"docs.python.org".to_string(),
// Version control (read-only)
"github.com".to_string(),
"raw.githubusercontent.com".to_string(),
"api.github.com".to_string(),
"codeload.github.com".to_string(),
// Common APIs (credentials will be injected by proxy)
"api.openai.com".to_string(),
"api.anthropic.com".to_string(),
"api.near.ai".to_string(),
]
}
/// Default credential mappings for common APIs.
pub fn default_credential_mappings() -> Vec<crate::secrets::CredentialMapping> {
use crate::secrets::CredentialMapping;
vec![
CredentialMapping::bearer("OPENAI_API_KEY", "api.openai.com"),
CredentialMapping::header("ANTHROPIC_API_KEY", "x-api-key", "api.anthropic.com"),
CredentialMapping::bearer("NEARAI_API_KEY", "api.near.ai"),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_parsing() {
assert_eq!(
"readonly".parse::<SandboxPolicy>().unwrap(),
SandboxPolicy::ReadOnly
);
assert_eq!(
"workspace_write".parse::<SandboxPolicy>().unwrap(),
SandboxPolicy::WorkspaceWrite
);
assert_eq!(
"full_access".parse::<SandboxPolicy>().unwrap(),
SandboxPolicy::FullAccess
);
assert!("invalid".parse::<SandboxPolicy>().is_err());
}
#[test]
fn test_policy_properties() {
assert!(!SandboxPolicy::ReadOnly.allows_writes());
assert!(SandboxPolicy::WorkspaceWrite.allows_writes());
assert!(SandboxPolicy::FullAccess.allows_writes());
assert!(!SandboxPolicy::ReadOnly.has_full_network());
assert!(!SandboxPolicy::WorkspaceWrite.has_full_network());
assert!(SandboxPolicy::FullAccess.has_full_network());
assert!(SandboxPolicy::ReadOnly.is_sandboxed());
assert!(SandboxPolicy::WorkspaceWrite.is_sandboxed());
assert!(!SandboxPolicy::FullAccess.is_sandboxed());
}
#[test]
fn test_default_allowlist_has_common_registries() {
let allowlist = default_allowlist();
assert!(allowlist.contains(&"crates.io".to_string()));
assert!(allowlist.contains(&"registry.npmjs.org".to_string()));
assert!(allowlist.contains(&"github.com".to_string()));
}
}