mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 17:09:31 +00:00
* 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]>
234 lines
8.6 KiB
Rust
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()));
|
|
}
|
|
}
|