diff --git a/src/core/all.rs b/src/core/all.rs index 89a6fd8e2..672c37056 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -186,6 +186,8 @@ fn build_registered_controllers() -> Vec { controllers.extend( crate::openhuman::screen_intelligence::all_screen_intelligence_registered_controllers(), ); + // Sandbox execution backends (Docker, local jail, policy, cleanup) + controllers.extend(crate::openhuman::sandbox::all_sandbox_registered_controllers()); // Backend Socket.IO bridge + related runtime plumbing controllers.extend(crate::openhuman::socket::all_socket_registered_controllers()); // Managed Node.js runtime bridge (tool listing + dispatch) @@ -342,6 +344,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend( crate::openhuman::screen_intelligence::all_screen_intelligence_controller_schemas(), ); + schemas.extend(crate::openhuman::sandbox::all_sandbox_controller_schemas()); schemas.extend(crate::openhuman::socket::all_socket_controller_schemas()); schemas.extend(crate::openhuman::javascript::all_javascript_controller_schemas()); schemas.extend(crate::openhuman::skills::all_skills_controller_schemas()); diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 5fb7e5afe..f1d1dd066 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -1554,6 +1554,21 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Stable, privacy: None, }, + Capability { + id: "security.sandbox_backends", + name: "Sandbox Execution Backends", + domain: "security", + category: CapabilityCategory::Settings, + description: "Route agent tool execution (shell, filesystem, process) through sandbox \ + backends — Docker containers or OS-level jails (Landlock/Seatbelt) — for \ + reduced blast radius on remote, channel, cron, or background sessions. \ + Configurable per agent/session/channel with safe defaults for non-main sessions.", + how_to: "Set sandbox_mode = \"sandboxed\" in agent.toml, or configure runtime.kind = \ + \"docker\" in the TOML config. Use openhuman.sandbox_status / \ + openhuman.sandbox_resolve_policy RPC to inspect.", + status: CapabilityStatus::Beta, + privacy: None, + }, Capability { id: "intelligence.remember_preferences", name: "Remember Preferences", diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 52a6c53d8..a8a8c944e 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -86,6 +86,7 @@ pub mod referral; pub mod routing; pub mod runtime_node; pub mod runtime_python; +pub mod sandbox; pub mod scheduler_gate; pub mod screen_intelligence; pub mod search; diff --git a/src/openhuman/sandbox/docker.rs b/src/openhuman/sandbox/docker.rs new file mode 100644 index 000000000..28f447142 --- /dev/null +++ b/src/openhuman/sandbox/docker.rs @@ -0,0 +1,357 @@ +//! Docker-backed sandbox execution backend. +//! +//! Runs agent tool commands inside ephemeral Docker containers with: +//! - Controlled workspace mounts (host `action_dir` → `/workspace`) +//! - Network isolation (default: `none`) +//! - Resource limits (memory, CPU) +//! - Capability dropping (`--cap-drop ALL`) +//! - Read-only rootfs (configurable) +//! - Environment passthrough (explicit allowlist only) +//! - Automatic container cleanup on completion +//! +//! The host core process is never inside the container — only the +//! spawned command runs sandboxed. + +use super::types::{ + DockerOverrides, SandboxBackendHandle, SandboxBackendKind, SandboxExecRequest, + SandboxExecResult, SandboxPolicy, SandboxStatus, +}; +use std::process::Stdio; +use tokio::process::Command; + +/// Label applied to all sandbox containers for orphan cleanup. +const CONTAINER_LABEL: &str = "openhuman.sandbox=true"; + +/// Maximum output size in bytes (1MB), matching shell tool limit. +const MAX_OUTPUT_BYTES: usize = 1_048_576; + +/// Check whether Docker is available and responsive. +pub async fn is_docker_available() -> bool { + let result = Command::new("docker") + .args(["info", "--format", "{{.ServerVersion}}"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await; + match result { + Ok(output) => output.status.success(), + Err(_) => false, + } +} + +/// Execute a command inside an ephemeral Docker container. +/// +/// The container is created with `docker run --rm` so it self-cleans on +/// exit. Resource limits, network policy, and mounts are derived from +/// the `SandboxPolicy`. +pub async fn docker_exec( + policy: &SandboxPolicy, + request: &SandboxExecRequest, +) -> anyhow::Result { + let overrides = policy.docker_overrides.as_ref(); + let image = overrides + .and_then(|o| o.image.as_deref()) + .unwrap_or("alpine:3.20"); + let network = overrides + .and_then(|o| o.network.as_deref()) + .unwrap_or("none"); + + let mut cmd = Command::new("docker"); + cmd.arg("run").arg("--rm"); + + // Container identification for orphan cleanup. + cmd.arg("--label").arg(CONTAINER_LABEL); + + // Network isolation. + cmd.arg("--network").arg(network); + + // Drop all capabilities by default. + cmd.arg("--cap-drop").arg("ALL"); + + // Additional capability drops. + if let Some(ov) = overrides { + for cap in &ov.extra_caps_drop { + cmd.arg("--cap-drop").arg(cap); + } + } + + // Resource limits. + let memory_mb = overrides.and_then(|o| o.memory_limit_mb).unwrap_or(512); + cmd.arg("-m").arg(format!("{memory_mb}m")); + + let cpu = overrides.and_then(|o| o.cpu_limit).unwrap_or(1.0); + cmd.arg("--cpus").arg(cpu.to_string()); + + // Read-only rootfs. + let read_only = overrides.and_then(|o| o.read_only_rootfs).unwrap_or(true); + if read_only { + cmd.arg("--read-only"); + // tmpfs mounts so the container can still write to /tmp and /var/tmp. + cmd.arg("--tmpfs").arg("/tmp:rw,noexec,nosuid,size=64m"); + cmd.arg("--tmpfs").arg("/var/tmp:rw,noexec,nosuid,size=64m"); + } + + // No new privileges (prevent setuid/setgid escalation inside container). + cmd.arg("--security-opt").arg("no-new-privileges"); + + // Workspace mount. + let workspace = policy + .workspace_root + .canonicalize() + .unwrap_or_else(|_| policy.workspace_root.clone()); + let mount = format!("{}:/workspace", workspace.display()); + cmd.arg("-v").arg(mount); + cmd.arg("-w").arg("/workspace"); + + // Read-only mounts. + for ro_path in &policy.read_only_mounts { + let canonical = ro_path.canonicalize().unwrap_or_else(|_| ro_path.clone()); + let ro_mount = format!("{}:{}:ro", canonical.display(), canonical.display()); + cmd.arg("-v").arg(ro_mount); + } + + // Environment passthrough. + for var_name in &policy.env_passthrough { + if let Ok(val) = std::env::var(var_name) { + cmd.arg("-e").arg(format!("{var_name}={val}")); + } + } + // Inject request-specific environment. + for (k, v) in &request.env { + cmd.arg("-e").arg(format!("{k}={v}")); + } + + cmd.arg(image); + cmd.arg("sh").arg("-c").arg(&request.command); + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + tracing::debug!( + image = image, + network = network, + memory_mb = memory_mb, + cpu = cpu, + workspace = %workspace.display(), + command = %request.command, + "[sandbox:docker] launching container" + ); + + let result = tokio::time::timeout(request.timeout, cmd.output()).await; + + match result { + Ok(Ok(output)) => { + let mut stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let mut stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if stdout.len() > MAX_OUTPUT_BYTES { + stdout.truncate(MAX_OUTPUT_BYTES); + stdout.push_str("\n... [output truncated at 1MB]"); + } + if stderr.len() > MAX_OUTPUT_BYTES { + stderr.truncate(MAX_OUTPUT_BYTES); + stderr.push_str("\n... [stderr truncated at 1MB]"); + } + + let exit_code = output.status.code().unwrap_or(-1); + tracing::debug!( + exit_code = exit_code, + stdout_len = stdout.len(), + stderr_len = stderr.len(), + "[sandbox:docker] container exited" + ); + + Ok(SandboxExecResult { + exit_code, + stdout, + stderr, + timed_out: false, + }) + } + Ok(Err(e)) => { + tracing::error!(error = %e, "[sandbox:docker] failed to spawn container"); + anyhow::bail!("Docker execution failed: {e}") + } + Err(_) => { + tracing::warn!( + timeout_secs = request.timeout.as_secs(), + "[sandbox:docker] container timed out, killing" + ); + Ok(SandboxExecResult { + exit_code: -1, + stdout: String::new(), + stderr: format!( + "Command timed out after {}s and was killed", + request.timeout.as_secs() + ), + timed_out: true, + }) + } + } +} + +/// Clean up orphaned sandbox containers (those labeled with +/// `openhuman.sandbox=true` that are still running). +pub async fn cleanup_orphaned_containers() -> anyhow::Result { + let output = Command::new("docker") + .args(["ps", "-q", "--filter", &format!("label={CONTAINER_LABEL}")]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await?; + + let ids: Vec<&str> = std::str::from_utf8(&output.stdout) + .unwrap_or("") + .lines() + .filter(|l| !l.is_empty()) + .collect(); + + if ids.is_empty() { + tracing::debug!("[sandbox:docker] no orphaned containers found"); + return Ok(0); + } + + let count = ids.len() as u32; + tracing::info!( + count = count, + "[sandbox:docker] cleaning up orphaned containers" + ); + + let mut kill_cmd = Command::new("docker"); + kill_cmd.arg("kill"); + for id in &ids { + kill_cmd.arg(id); + } + let _ = kill_cmd + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + Ok(count) +} + +/// Create a handle representing the Docker backend state. +pub async fn docker_backend_handle() -> SandboxBackendHandle { + let available = is_docker_available().await; + SandboxBackendHandle { + kind: SandboxBackendKind::Docker, + status: if available { + SandboxStatus::Ready + } else { + SandboxStatus::Error + }, + backend_id: None, + } +} + +/// Validate that a sandbox policy's Docker configuration doesn't have +/// dangerous settings (host network, privileged mounts, etc.). +pub fn validate_docker_policy(policy: &SandboxPolicy) -> Result<(), Vec> { + let mut issues = Vec::new(); + + if let Some(overrides) = &policy.docker_overrides { + if overrides.network.as_deref() == Some("host") { + issues.push("Docker sandbox uses host network — defeats network isolation".into()); + } + } + + // Check for dangerous mount paths. + let dangerous_mounts = ["/", "/etc", "/var/run/docker.sock", "/proc", "/sys"]; + for mount in &policy.read_only_mounts { + let path_str = mount.to_string_lossy(); + for &dangerous in &dangerous_mounts { + if path_str == dangerous { + issues.push(format!( + "Dangerous read-only mount: {path_str} — could leak host secrets" + )); + } + } + } + + let workspace_str = policy.workspace_root.to_string_lossy(); + for &dangerous in &dangerous_mounts { + if workspace_str == dangerous { + issues.push(format!( + "Workspace root is a dangerous path: {workspace_str}" + )); + } + } + + if issues.is_empty() { + Ok(()) + } else { + Err(issues) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn test_policy() -> SandboxPolicy { + SandboxPolicy { + backend: SandboxBackendKind::Docker, + workspace_root: PathBuf::from("/tmp/test-workspace"), + read_only_mounts: vec![], + allow_network: false, + env_passthrough: vec!["PATH".into(), "HOME".into()], + docker_overrides: None, + } + } + + #[test] + fn validate_docker_policy_accepts_safe_config() { + let policy = test_policy(); + assert!(validate_docker_policy(&policy).is_ok()); + } + + #[test] + fn validate_docker_policy_rejects_host_network() { + let mut policy = test_policy(); + policy.docker_overrides = Some(DockerOverrides { + network: Some("host".into()), + ..DockerOverrides::default() + }); + let issues = validate_docker_policy(&policy).unwrap_err(); + assert!(issues.iter().any(|i| i.contains("host network"))); + } + + #[test] + fn validate_docker_policy_rejects_dangerous_mounts() { + let mut policy = test_policy(); + policy.read_only_mounts = vec![PathBuf::from("/var/run/docker.sock")]; + let issues = validate_docker_policy(&policy).unwrap_err(); + assert!(issues.iter().any(|i| i.contains("docker.sock"))); + } + + #[test] + fn validate_docker_policy_rejects_root_workspace() { + let mut policy = test_policy(); + policy.workspace_root = PathBuf::from("/"); + let issues = validate_docker_policy(&policy).unwrap_err(); + assert!(issues.iter().any(|i| i.contains("dangerous path"))); + } + + #[test] + fn validate_docker_policy_multiple_issues() { + let mut policy = test_policy(); + policy.workspace_root = PathBuf::from("/etc"); + policy.read_only_mounts = vec![PathBuf::from("/proc")]; + policy.docker_overrides = Some(DockerOverrides { + network: Some("host".into()), + ..DockerOverrides::default() + }); + let issues = validate_docker_policy(&policy).unwrap_err(); + assert!(issues.len() >= 3); + } + + #[tokio::test] + async fn docker_backend_handle_reports_status() { + let handle = docker_backend_handle().await; + assert_eq!(handle.kind, SandboxBackendKind::Docker); + // Status depends on whether Docker is installed in the test env. + assert!(handle.status == SandboxStatus::Ready || handle.status == SandboxStatus::Error); + } +} diff --git a/src/openhuman/sandbox/mod.rs b/src/openhuman/sandbox/mod.rs new file mode 100644 index 000000000..d45efde55 --- /dev/null +++ b/src/openhuman/sandbox/mod.rs @@ -0,0 +1,29 @@ +//! Sandbox execution backends for agent tool isolation. +//! +//! Separates three concerns: +//! - **Where** tools run (this module — sandbox backend selection) +//! - **Which** tools are allowed (security policy / tool policy) +//! - **Whether** a tool needs host access (elevated ops) +//! +//! The gateway/core always runs on the host. Selected tool families +//! (shell, filesystem, process) execute inside a sandbox with controlled +//! workspace mounts, network policy, environment passthrough, and +//! explicit elevated escape paths. + +pub mod docker; +pub mod ops; +pub mod schemas; +pub mod types; + +pub use ops::{ + build_elevated_op, create_sandbox_backend, execute_in_sandbox, is_elevated_op, + resolve_sandbox_policy, +}; +pub use schemas::{ + all_controller_schemas as all_sandbox_controller_schemas, + all_registered_controllers as all_sandbox_registered_controllers, +}; +pub use types::{ + DockerOverrides, ElevatedOp, SandboxBackendHandle, SandboxBackendKind, SandboxExecRequest, + SandboxExecResult, SandboxPolicy, SandboxStatus, +}; diff --git a/src/openhuman/sandbox/ops.rs b/src/openhuman/sandbox/ops.rs new file mode 100644 index 000000000..65e1a1ac4 --- /dev/null +++ b/src/openhuman/sandbox/ops.rs @@ -0,0 +1,470 @@ +//! Sandbox backend operations — policy resolution, backend creation, and +//! routed execution. + +use super::docker; +use super::types::{ + ElevatedOp, SandboxBackendHandle, SandboxBackendKind, SandboxExecRequest, SandboxExecResult, + SandboxPolicy, SandboxStatus, ELEVATED_TOOLS, +}; +use crate::openhuman::agent::harness::definition::SandboxMode; +use crate::openhuman::config::{DockerRuntimeConfig, RuntimeConfig}; +use crate::openhuman::cwd_jail::{self, Jail, NoopBackend}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +/// Safe environment variables forwarded into sandboxed execution. +const SANDBOX_ENV_PASSTHROUGH: &[&str] = &[ + "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR", +]; + +/// Resolve a `SandboxPolicy` from the agent's `SandboxMode`, the +/// session origin, and the global runtime config. +/// +/// Non-main sessions (channel, cron, remote) default to `Docker` when +/// the mode is `Sandboxed` and Docker is configured. Local interactive +/// sessions default to `Local` (OS-level jail via `cwd_jail`). +pub fn resolve_sandbox_policy( + mode: SandboxMode, + action_dir: &Path, + runtime_config: &RuntimeConfig, + is_remote_session: bool, +) -> SandboxPolicy { + let backend = match mode { + SandboxMode::None => SandboxBackendKind::None, + SandboxMode::ReadOnly => SandboxBackendKind::None, + SandboxMode::Sandboxed => { + if runtime_config.kind == "docker" || is_remote_session { + SandboxBackendKind::Docker + } else { + SandboxBackendKind::Local + } + } + }; + + let docker_overrides = if backend == SandboxBackendKind::Docker { + let dc = &runtime_config.docker; + Some(super::types::DockerOverrides { + image: Some(dc.image.clone()), + network: Some(dc.network.clone()), + memory_limit_mb: dc.memory_limit_mb, + cpu_limit: dc.cpu_limit, + read_only_rootfs: Some(dc.read_only_rootfs), + extra_caps_drop: vec![], + }) + } else { + None + }; + + let allow_network = match mode { + SandboxMode::Sandboxed => !is_remote_session, + _ => true, + }; + + tracing::debug!( + mode = ?mode, + backend = ?backend, + is_remote = is_remote_session, + action_dir = %action_dir.display(), + "[sandbox] resolved policy" + ); + + SandboxPolicy { + backend, + workspace_root: action_dir.to_path_buf(), + read_only_mounts: vec![], + allow_network, + env_passthrough: SANDBOX_ENV_PASSTHROUGH + .iter() + .map(|s| s.to_string()) + .collect(), + docker_overrides, + } +} + +/// Create a backend handle for the resolved policy. For Docker this +/// checks availability; for Local it checks the OS backend. +pub async fn create_sandbox_backend(policy: &SandboxPolicy) -> SandboxBackendHandle { + match policy.backend { + SandboxBackendKind::None => SandboxBackendHandle { + kind: SandboxBackendKind::None, + status: SandboxStatus::Ready, + backend_id: None, + }, + SandboxBackendKind::Local => { + let os_backend = cwd_jail::default_backend(); + SandboxBackendHandle { + kind: SandboxBackendKind::Local, + status: if os_backend.is_available() { + SandboxStatus::Ready + } else { + tracing::warn!( + backend = os_backend.name(), + "[sandbox:local] OS jail backend not available, falling back to noop" + ); + SandboxStatus::Ready + }, + backend_id: Some(os_backend.name().to_string()), + } + } + SandboxBackendKind::Docker => docker::docker_backend_handle().await, + } +} + +/// Execute a command through the appropriate sandbox backend. +/// +/// Returns the sandboxed execution result. The caller (typically the +/// shell tool) is responsible for converting this into a `ToolResult`. +pub async fn execute_in_sandbox( + policy: &SandboxPolicy, + command: &str, + working_dir: &Path, + extra_env: HashMap, + timeout: Duration, +) -> anyhow::Result { + match policy.backend { + SandboxBackendKind::None => { + execute_unsandboxed(command, working_dir, &extra_env, timeout).await + } + SandboxBackendKind::Local => { + execute_local_jail(policy, command, working_dir, &extra_env, timeout).await + } + SandboxBackendKind::Docker => { + let request = SandboxExecRequest { + command: command.to_string(), + working_dir: working_dir.to_path_buf(), + env: extra_env, + timeout, + }; + docker::docker_exec(policy, &request).await + } + } +} + +/// Passthrough execution with no sandbox (for `SandboxBackendKind::None`). +async fn execute_unsandboxed( + command: &str, + working_dir: &Path, + extra_env: &HashMap, + timeout: Duration, +) -> anyhow::Result { + let mut cmd = tokio::process::Command::new("sh"); + cmd.arg("-c").arg(command); + cmd.current_dir(working_dir); + cmd.env_clear(); + for var in SANDBOX_ENV_PASSTHROUGH { + if let Ok(val) = std::env::var(var) { + cmd.env(var, val); + } + } + for (k, v) in extra_env { + cmd.env(k, v); + } + + let result = tokio::time::timeout(timeout, cmd.output()).await; + match result { + Ok(Ok(output)) => Ok(SandboxExecResult { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + timed_out: false, + }), + Ok(Err(e)) => anyhow::bail!("Failed to execute command: {e}"), + Err(_) => Ok(SandboxExecResult { + exit_code: -1, + stdout: String::new(), + stderr: format!("Command timed out after {}s", timeout.as_secs()), + timed_out: true, + }), + } +} + +/// Execute via the OS-level `cwd_jail` backend (Landlock/Seatbelt/AppContainer). +/// +/// Output capture: some OS backends (macOS Seatbelt) rebuild the command +/// internally and don't forward piped stdio settings. We capture output +/// by wrapping the command to redirect stdout/stderr to temp files inside +/// the jail root, then reading them back after exit. +async fn execute_local_jail( + policy: &SandboxPolicy, + command: &str, + working_dir: &Path, + extra_env: &HashMap, + timeout: Duration, +) -> anyhow::Result { + let mut jail = Jail::new(&policy.workspace_root, "sandbox.agent"); + if !policy.allow_network { + jail = jail.deny_net(); + } + for ro in &policy.read_only_mounts { + jail = jail.add_read_only(ro); + } + + let stdout_file = policy.workspace_root.join(".sandbox_stdout"); + let stderr_file = policy.workspace_root.join(".sandbox_stderr"); + let wrapped = format!( + "{{ {command} ; }} > '{}' 2> '{}'", + stdout_file.display(), + stderr_file.display() + ); + + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(&wrapped); + cmd.current_dir(working_dir); + cmd.env_clear(); + for var in SANDBOX_ENV_PASSTHROUGH { + if let Ok(val) = std::env::var(var) { + cmd.env(var, val); + } + } + for (k, v) in extra_env { + cmd.env(k, v); + } + + let os_backend = cwd_jail::default_backend(); + let spawn_result = if os_backend.is_available() { + cwd_jail::spawn(&jail, cmd) + } else { + tracing::debug!("[sandbox:local] OS backend unavailable, using noop"); + cwd_jail::spawn_with(&NoopBackend, &jail, cmd) + }; + + let stdout_path = stdout_file.clone(); + let stderr_path = stderr_file.clone(); + + match spawn_result { + Ok(child) => { + let wait_result = tokio::task::spawn_blocking(move || { + let start = std::time::Instant::now(); + let mut child = child; + loop { + match child.try_wait() { + Ok(Some(status)) => { + return Ok((status.code().unwrap_or(-1), false)); + } + Ok(None) => { + if start.elapsed() > timeout { + let _ = child.kill(); + return Ok((-1, true)); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(e), + } + } + }) + .await??; + + let stdout = std::fs::read_to_string(&stdout_path).unwrap_or_default(); + let stderr_content = std::fs::read_to_string(&stderr_path).unwrap_or_default(); + let _ = std::fs::remove_file(&stdout_path); + let _ = std::fs::remove_file(&stderr_path); + + Ok(SandboxExecResult { + exit_code: wait_result.0, + stdout, + stderr: if wait_result.1 { + format!("Command timed out after {}s", timeout.as_secs()) + } else { + stderr_content + }, + timed_out: wait_result.1, + }) + } + Err(e) => { + let _ = std::fs::remove_file(&stdout_path); + let _ = std::fs::remove_file(&stderr_path); + anyhow::bail!("Failed to spawn jailed process: {e}") + } + } +} + +/// Check whether a tool operation is an elevated op that must run on the +/// host even when the session is sandboxed. +pub fn is_elevated_op(tool_name: &str) -> bool { + ELEVATED_TOOLS.contains(&tool_name) +} + +/// Build an `ElevatedOp` for audit logging when a tool bypasses the sandbox. +pub fn build_elevated_op(tool_name: &str, command: &str, reason: &str) -> ElevatedOp { + tracing::info!( + tool = tool_name, + reason = reason, + "[sandbox] elevated host operation" + ); + ElevatedOp { + tool_name: tool_name.to_string(), + reason: reason.to_string(), + command: command.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::RuntimeConfig; + + #[test] + fn resolve_sandbox_policy_none_mode() { + let policy = resolve_sandbox_policy( + SandboxMode::None, + Path::new("/tmp/action"), + &RuntimeConfig::default(), + false, + ); + assert_eq!(policy.backend, SandboxBackendKind::None); + } + + #[test] + fn resolve_sandbox_policy_read_only_mode() { + let policy = resolve_sandbox_policy( + SandboxMode::ReadOnly, + Path::new("/tmp/action"), + &RuntimeConfig::default(), + false, + ); + assert_eq!(policy.backend, SandboxBackendKind::None); + } + + #[test] + fn resolve_sandbox_policy_sandboxed_local() { + let policy = resolve_sandbox_policy( + SandboxMode::Sandboxed, + Path::new("/tmp/action"), + &RuntimeConfig::default(), + false, + ); + assert_eq!(policy.backend, SandboxBackendKind::Local); + assert!(policy.allow_network); + } + + #[test] + fn resolve_sandbox_policy_sandboxed_remote_uses_docker() { + let policy = resolve_sandbox_policy( + SandboxMode::Sandboxed, + Path::new("/tmp/action"), + &RuntimeConfig::default(), + true, + ); + assert_eq!(policy.backend, SandboxBackendKind::Docker); + assert!(!policy.allow_network); + assert!(policy.docker_overrides.is_some()); + } + + #[test] + fn resolve_sandbox_policy_docker_runtime_forces_docker() { + let config = RuntimeConfig { + kind: "docker".into(), + ..RuntimeConfig::default() + }; + let policy = resolve_sandbox_policy( + SandboxMode::Sandboxed, + Path::new("/tmp/action"), + &config, + false, + ); + assert_eq!(policy.backend, SandboxBackendKind::Docker); + assert!(policy.allow_network); + } + + #[test] + fn is_elevated_op_known_tools() { + assert!(is_elevated_op("git_operations")); + assert!(is_elevated_op("install_tool")); + assert!(!is_elevated_op("shell")); + assert!(!is_elevated_op("file_read")); + } + + #[test] + fn build_elevated_op_creates_record() { + let op = build_elevated_op("git_operations", "git push", "VCS requires host access"); + assert_eq!(op.tool_name, "git_operations"); + assert_eq!(op.command, "git push"); + assert!(op.reason.contains("VCS")); + } + + #[tokio::test] + async fn create_sandbox_backend_none() { + let policy = resolve_sandbox_policy( + SandboxMode::None, + Path::new("/tmp"), + &RuntimeConfig::default(), + false, + ); + let handle = create_sandbox_backend(&policy).await; + assert_eq!(handle.kind, SandboxBackendKind::None); + assert_eq!(handle.status, SandboxStatus::Ready); + } + + #[tokio::test] + async fn create_sandbox_backend_local() { + let policy = resolve_sandbox_policy( + SandboxMode::Sandboxed, + Path::new("/tmp"), + &RuntimeConfig::default(), + false, + ); + let handle = create_sandbox_backend(&policy).await; + assert_eq!(handle.kind, SandboxBackendKind::Local); + assert_eq!(handle.status, SandboxStatus::Ready); + } + + #[tokio::test] + async fn execute_unsandboxed_echo() { + let result = execute_unsandboxed( + "echo hello", + Path::new("/tmp"), + &HashMap::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("hello")); + assert!(!result.timed_out); + } + + #[tokio::test] + async fn execute_unsandboxed_failure() { + let result = execute_unsandboxed( + "false", + Path::new("/tmp"), + &HashMap::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert_ne!(result.exit_code, 0); + } + + #[tokio::test] + async fn execute_in_sandbox_none_backend() { + let policy = resolve_sandbox_policy( + SandboxMode::None, + Path::new("/tmp"), + &RuntimeConfig::default(), + false, + ); + let result = execute_in_sandbox( + &policy, + "echo sandbox-test", + Path::new("/tmp"), + HashMap::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert!(result.success()); + assert!(result.stdout.contains("sandbox-test")); + } + + #[test] + fn env_passthrough_includes_safe_vars() { + assert!(SANDBOX_ENV_PASSTHROUGH.contains(&"PATH")); + assert!(SANDBOX_ENV_PASSTHROUGH.contains(&"HOME")); + assert!(!SANDBOX_ENV_PASSTHROUGH + .iter() + .any(|v| v.contains("KEY") || v.contains("SECRET"))); + } +} diff --git a/src/openhuman/sandbox/schemas.rs b/src/openhuman/sandbox/schemas.rs new file mode 100644 index 000000000..74c37db86 --- /dev/null +++ b/src/openhuman/sandbox/schemas.rs @@ -0,0 +1,302 @@ +//! Sandbox domain controller schemas and RPC handlers. + +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::rpc::RpcOutcome; + +pub fn all_controller_schemas() -> Vec { + vec![ + schemas("status"), + schemas("resolve_policy"), + schemas("cleanup_orphans"), + schemas("validate_policy"), + ] +} + +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("status"), + handler: handle_status, + }, + RegisteredController { + schema: schemas("resolve_policy"), + handler: handle_resolve_policy, + }, + RegisteredController { + schema: schemas("cleanup_orphans"), + handler: handle_cleanup_orphans, + }, + RegisteredController { + schema: schemas("validate_policy"), + handler: handle_validate_policy, + }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "status" => ControllerSchema { + namespace: "sandbox", + function: "status", + description: "Return sandbox backend status and availability.", + inputs: vec![FieldSchema { + name: "backend", + ty: TypeSchema::String, + comment: "Backend kind to check: 'docker', 'local', or 'none'.", + required: false, + }], + outputs: vec![FieldSchema { + name: "status", + ty: TypeSchema::Json, + comment: "Backend handle with kind, status, and backend_id.", + required: true, + }], + }, + "resolve_policy" => ControllerSchema { + namespace: "sandbox", + function: "resolve_policy", + description: "Resolve sandbox policy for a given sandbox mode and session context.", + inputs: vec![ + FieldSchema { + name: "sandbox_mode", + ty: TypeSchema::String, + comment: "Agent sandbox mode: 'none', 'read_only', or 'sandboxed'.", + required: true, + }, + FieldSchema { + name: "is_remote", + ty: TypeSchema::Bool, + comment: "Whether this is a remote/channel session.", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "policy", + ty: TypeSchema::Json, + comment: "Resolved SandboxPolicy.", + required: true, + }], + }, + "cleanup_orphans" => ControllerSchema { + namespace: "sandbox", + function: "cleanup_orphans", + description: "Clean up orphaned sandbox containers.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "cleaned", + ty: TypeSchema::U64, + comment: "Number of orphaned containers cleaned up.", + required: true, + }], + }, + "validate_policy" => ControllerSchema { + namespace: "sandbox", + function: "validate_policy", + description: "Validate a sandbox policy for dangerous configurations.", + inputs: vec![FieldSchema { + name: "policy", + ty: TypeSchema::Json, + comment: "SandboxPolicy to validate.", + required: true, + }], + outputs: vec![ + FieldSchema { + name: "valid", + ty: TypeSchema::Bool, + comment: "Whether the policy is safe.", + required: true, + }, + FieldSchema { + name: "issues", + ty: TypeSchema::Json, + comment: "List of security issues found (empty if valid).", + required: true, + }, + ], + }, + _ => ControllerSchema { + namespace: "sandbox", + function: "unknown", + description: "Unknown sandbox controller function.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +fn handle_status(params: Map) -> ControllerFuture { + Box::pin(async move { + let backend_str = params + .get("backend") + .and_then(|v| v.as_str()) + .unwrap_or("none"); + + let mode = match backend_str { + "docker" | "local" => { + crate::openhuman::agent::harness::definition::SandboxMode::Sandboxed + } + _ => crate::openhuman::agent::harness::definition::SandboxMode::None, + }; + + let config = crate::openhuman::config::RuntimeConfig::default(); + let is_remote = backend_str == "docker"; + let policy = super::ops::resolve_sandbox_policy( + mode, + std::path::Path::new("/tmp"), + &config, + is_remote, + ); + let handle = super::ops::create_sandbox_backend(&policy).await; + to_json(RpcOutcome::new(handle, vec![])) + }) +} + +fn handle_resolve_policy(params: Map) -> ControllerFuture { + Box::pin(async move { + let mode_str = params + .get("sandbox_mode") + .and_then(|v| v.as_str()) + .unwrap_or("none"); + let is_remote = params + .get("is_remote") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let mode = match mode_str { + "sandboxed" => crate::openhuman::agent::harness::definition::SandboxMode::Sandboxed, + "read_only" => crate::openhuman::agent::harness::definition::SandboxMode::ReadOnly, + _ => crate::openhuman::agent::harness::definition::SandboxMode::None, + }; + + let config = crate::openhuman::config::RuntimeConfig::default(); + let action_dir = crate::openhuman::config::default_action_dir(); + let policy = super::ops::resolve_sandbox_policy(mode, &action_dir, &config, is_remote); + to_json(RpcOutcome::new(policy, vec![])) + }) +} + +fn handle_cleanup_orphans(_params: Map) -> ControllerFuture { + Box::pin(async { + match super::docker::cleanup_orphaned_containers().await { + Ok(count) => to_json(RpcOutcome::new( + serde_json::json!({ "cleaned": count }), + vec![], + )), + Err(e) => Err(format!("Cleanup failed: {e}")), + } + }) +} + +fn handle_validate_policy(params: Map) -> ControllerFuture { + Box::pin(async move { + let policy_val = params.get("policy").cloned().unwrap_or(Value::Null); + let policy: super::types::SandboxPolicy = match serde_json::from_value(policy_val) { + Ok(p) => p, + Err(e) => return Err(format!("Invalid policy: {e}")), + }; + let result = match super::docker::validate_docker_policy(&policy) { + Ok(()) => serde_json::json!({ "valid": true, "issues": [] }), + Err(issues) => serde_json::json!({ "valid": false, "issues": issues }), + }; + to_json(RpcOutcome::new(result, vec![])) + }) +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_are_in_sandbox_namespace() { + for schema in all_controller_schemas() { + assert_eq!(schema.namespace, "sandbox"); + } + } + + #[test] + fn registered_controllers_match_schemas() { + let schemas = all_controller_schemas(); + let controllers = all_registered_controllers(); + assert_eq!(schemas.len(), controllers.len()); + for (s, c) in schemas.iter().zip(controllers.iter()) { + assert_eq!(s.function, c.schema.function); + } + } + + #[tokio::test] + async fn handle_status_returns_json() { + let result = handle_status(Map::new()).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn handle_resolve_policy_none() { + let mut params = Map::new(); + params.insert("sandbox_mode".into(), Value::String("none".into())); + let result = handle_resolve_policy(params).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn handle_resolve_policy_sandboxed_remote() { + let mut params = Map::new(); + params.insert("sandbox_mode".into(), Value::String("sandboxed".into())); + params.insert("is_remote".into(), Value::Bool(true)); + let result = handle_resolve_policy(params).await; + assert!(result.is_ok()); + let val = result.unwrap(); + let backend = val.get("backend").and_then(|b| b.as_str()); + assert_eq!(backend, Some("docker")); + } + + #[tokio::test] + async fn handle_validate_policy_valid() { + let policy = super::super::types::SandboxPolicy { + backend: super::super::types::SandboxBackendKind::Docker, + workspace_root: std::path::PathBuf::from("/tmp/safe"), + read_only_mounts: vec![], + allow_network: false, + env_passthrough: vec![], + docker_overrides: None, + }; + let mut params = Map::new(); + params.insert("policy".into(), serde_json::to_value(&policy).unwrap()); + let result = handle_validate_policy(params).await; + assert!(result.is_ok()); + let val = result.unwrap(); + assert_eq!(val.get("valid").and_then(|v| v.as_bool()), Some(true)); + } + + #[tokio::test] + async fn handle_validate_policy_dangerous() { + let policy = super::super::types::SandboxPolicy { + backend: super::super::types::SandboxBackendKind::Docker, + workspace_root: std::path::PathBuf::from("/"), + read_only_mounts: vec![], + allow_network: false, + env_passthrough: vec![], + docker_overrides: Some(super::super::types::DockerOverrides { + network: Some("host".into()), + ..Default::default() + }), + }; + let mut params = Map::new(); + params.insert("policy".into(), serde_json::to_value(&policy).unwrap()); + let result = handle_validate_policy(params).await; + assert!(result.is_ok()); + let val = result.unwrap(); + assert_eq!(val.get("valid").and_then(|v| v.as_bool()), Some(false)); + } +} diff --git a/src/openhuman/sandbox/types.rs b/src/openhuman/sandbox/types.rs new file mode 100644 index 000000000..d23d51f74 --- /dev/null +++ b/src/openhuman/sandbox/types.rs @@ -0,0 +1,194 @@ +//! Sandbox domain types. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Which sandbox backend to use for a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SandboxBackendKind { + /// No sandbox — commands execute directly on the host. + #[default] + None, + /// OS-level process jail via `cwd_jail` (Landlock/Seatbelt/AppContainer). + Local, + /// Docker container isolation. + Docker, +} + +/// Per-session sandbox policy resolved from agent definition, session +/// origin, and global config. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxPolicy { + /// Which backend to use. + pub backend: SandboxBackendKind, + /// Workspace root mounted into the sandbox (read/write). + pub workspace_root: PathBuf, + /// Additional read-only mounts (e.g. `/usr/lib`, managed node). + pub read_only_mounts: Vec, + /// Whether outbound network is allowed inside the sandbox. + pub allow_network: bool, + /// Environment variables to passthrough into the sandbox. + pub env_passthrough: Vec, + /// Docker-specific overrides (image, resource limits). + pub docker_overrides: Option, +} + +/// Docker-specific sandbox overrides layered on top of the global +/// `DockerRuntimeConfig`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DockerOverrides { + pub image: Option, + pub network: Option, + pub memory_limit_mb: Option, + pub cpu_limit: Option, + pub read_only_rootfs: Option, + pub extra_caps_drop: Vec, +} + +/// A request to execute a command inside a sandbox. +#[derive(Debug, Clone)] +pub struct SandboxExecRequest { + /// Shell command string to execute. + pub command: String, + /// Working directory inside the sandbox. For Docker this is the + /// container-side path (e.g. `/workspace`). + pub working_dir: PathBuf, + /// Environment variables to inject. + pub env: HashMap, + /// Execution timeout. + pub timeout: std::time::Duration, +} + +/// Result of a sandboxed command execution. +#[derive(Debug, Clone)] +pub struct SandboxExecResult { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, + /// Whether the command was killed due to timeout. + pub timed_out: bool, +} + +impl SandboxExecResult { + pub fn success(&self) -> bool { + self.exit_code == 0 && !self.timed_out + } +} + +/// Handle to an active sandbox backend. Callers use this to execute +/// commands and query status without knowing the backend implementation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxBackendHandle { + pub kind: SandboxBackendKind, + pub status: SandboxStatus, + /// Container ID for Docker backends; jail label for local. + pub backend_id: Option, +} + +/// Health/lifecycle status of a sandbox backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SandboxStatus { + /// Backend not initialized. + Inactive, + /// Backend ready to accept commands. + Ready, + /// Backend is executing a command. + Busy, + /// Backend encountered an error and needs restart/cleanup. + Error, +} + +/// Operations that explicitly require host-level access and cannot run +/// inside a sandbox. The elevated path is audited and requires the +/// caller to declare the reason. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElevatedOp { + /// The tool or operation name requesting elevation. + pub tool_name: String, + /// Human-readable reason this operation needs host access. + pub reason: String, + /// The command or action to execute on the host. + pub command: String, +} + +/// Well-known tool names that always require host access. +pub const ELEVATED_TOOLS: &[&str] = &[ + "git_operations", + "install_tool", + "docker_management", + "process_management", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_exec_result_success_checks() { + let ok = SandboxExecResult { + exit_code: 0, + stdout: "hello".into(), + stderr: String::new(), + timed_out: false, + }; + assert!(ok.success()); + + let failed = SandboxExecResult { + exit_code: 1, + stdout: String::new(), + stderr: "error".into(), + timed_out: false, + }; + assert!(!failed.success()); + + let timeout = SandboxExecResult { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + timed_out: true, + }; + assert!(!timeout.success()); + } + + #[test] + fn sandbox_backend_kind_default_is_none() { + assert_eq!(SandboxBackendKind::default(), SandboxBackendKind::None); + } + + #[test] + fn sandbox_policy_serializes_roundtrip() { + let policy = SandboxPolicy { + backend: SandboxBackendKind::Docker, + workspace_root: PathBuf::from("/workspace"), + read_only_mounts: vec![PathBuf::from("/usr/lib")], + allow_network: false, + env_passthrough: vec!["PATH".into()], + docker_overrides: Some(DockerOverrides { + image: Some("node:20-slim".into()), + memory_limit_mb: Some(256), + ..DockerOverrides::default() + }), + }; + let json = serde_json::to_string(&policy).unwrap(); + let back: SandboxPolicy = serde_json::from_str(&json).unwrap(); + assert_eq!(back.backend, SandboxBackendKind::Docker); + assert!(!back.allow_network); + } + + #[test] + fn elevated_tools_contains_expected_entries() { + assert!(ELEVATED_TOOLS.contains(&"git_operations")); + assert!(ELEVATED_TOOLS.contains(&"install_tool")); + assert!(!ELEVATED_TOOLS.contains(&"shell")); + } + + #[test] + fn sandbox_status_variants() { + assert_ne!(SandboxStatus::Inactive, SandboxStatus::Ready); + assert_ne!(SandboxStatus::Ready, SandboxStatus::Busy); + assert_ne!(SandboxStatus::Busy, SandboxStatus::Error); + } +} diff --git a/src/openhuman/tools/impl/system/shell.rs b/src/openhuman/tools/impl/system/shell.rs index 09f8e6b6c..2a2ca53f7 100644 --- a/src/openhuman/tools/impl/system/shell.rs +++ b/src/openhuman/tools/impl/system/shell.rs @@ -231,6 +231,16 @@ impl ShellTool { ); } + // When the agent's sandbox mode is `Sandboxed`, route execution + // through the sandbox backend (Docker or OS-level jail) instead + // of the normal runtime. Security checks above still apply. + if matches!( + crate::openhuman::agent::harness::current_sandbox_mode(), + Some(crate::openhuman::agent::harness::definition::SandboxMode::Sandboxed) + ) { + return self.run_sandboxed(command).await; + } + // Execute with timeout to prevent hanging commands. // Clear the environment to prevent leaking API keys and other secrets // (CWE-200), then re-add only safe, functional variables. @@ -319,6 +329,79 @@ impl ShellTool { }; (true, tool_result) } + + /// Execute a command through the sandbox backend. Called when the + /// agent's `SandboxMode` is `Sandboxed`. + async fn run_sandboxed(&self, command: &str) -> (bool, ToolResult) { + use crate::openhuman::sandbox; + + let config = crate::openhuman::config::RuntimeConfig::default(); + let policy = sandbox::resolve_sandbox_policy( + crate::openhuman::agent::harness::definition::SandboxMode::Sandboxed, + &self.security.action_dir, + &config, + false, + ); + + tracing::debug!( + backend = ?policy.backend, + command = command, + "[shell] routing to sandbox backend" + ); + + let mut extra_env = std::collections::HashMap::new(); + if let Some(bootstrap) = self.node_bootstrap.as_ref() { + if let Some(resolved) = bootstrap.try_cached() { + let host_path = std::env::var("PATH").unwrap_or_default(); + let sep = if cfg!(windows) { ";" } else { ":" }; + let prepended = if host_path.is_empty() { + resolved.bin_dir.to_string_lossy().into_owned() + } else { + format!("{}{}{}", resolved.bin_dir.display(), sep, host_path) + }; + extra_env.insert("PATH".to_string(), prepended); + } + } + + match sandbox::execute_in_sandbox( + &policy, + command, + &self.security.action_dir, + extra_env, + Duration::from_secs(SHELL_TIMEOUT_SECS), + ) + .await + { + Ok(result) => { + let tool_result = if result.timed_out { + ToolResult::error(format!( + "Command timed out after {SHELL_TIMEOUT_SECS}s and was killed" + )) + } else if result.success() { + if result.stderr.is_empty() { + ToolResult::success(result.stdout) + } else { + ToolResult::success(format!( + "{}\n[stderr]\n{}", + result.stdout, result.stderr + )) + } + } else { + let err_msg = if result.stderr.is_empty() { + result.stdout + } else { + result.stderr + }; + ToolResult::error(err_msg) + }; + (true, tool_result) + } + Err(e) => ( + true, + ToolResult::error(format!("Sandbox execution failed: {e}")), + ), + } + } } #[cfg(test)] @@ -825,4 +908,33 @@ mod tests { assert!(result.is_error); assert!(result.output().contains("Rate limit")); } + + #[cfg(not(windows))] + #[tokio::test] + async fn shell_sandboxed_mode_routes_through_sandbox_backend() { + use crate::openhuman::agent::harness::definition::SandboxMode; + use crate::openhuman::agent::harness::with_current_sandbox_mode; + + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + test_audit(), + ); + let result = with_current_sandbox_mode(SandboxMode::Sandboxed, async { + tool.execute(json!({"command": "echo sandboxed-output"})) + .await + .unwrap() + }) + .await; + assert!( + !result.is_error, + "sandboxed echo should succeed: {}", + result.output() + ); + assert!( + result.output().contains("sandboxed-output"), + "expected 'sandboxed-output' in result, got: {:?}", + result.output() + ); + } }