feat(sandbox): add real sandbox execution backends for agent tools (#3261)

This commit is contained in:
Steven Enamakel
2026-06-02 19:27:20 -07:00
committed by GitHub
parent 65feeb28f7
commit 24c7d31290
9 changed files with 1483 additions and 0 deletions
+3
View File
@@ -186,6 +186,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
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<ControllerSchema> {
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());
+15
View File
@@ -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",
+1
View File
@@ -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;
+357
View File
@@ -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<SandboxExecResult> {
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<u32> {
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<String>> {
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);
}
}
+29
View File
@@ -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,
};
+470
View File
@@ -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<String, String>,
timeout: Duration,
) -> anyhow::Result<SandboxExecResult> {
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<String, String>,
timeout: Duration,
) -> anyhow::Result<SandboxExecResult> {
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<String, String>,
timeout: Duration,
) -> anyhow::Result<SandboxExecResult> {
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")));
}
}
+302
View File
@@ -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<ControllerSchema> {
vec![
schemas("status"),
schemas("resolve_policy"),
schemas("cleanup_orphans"),
schemas("validate_policy"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
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));
}
}
+194
View File
@@ -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<PathBuf>,
/// Whether outbound network is allowed inside the sandbox.
pub allow_network: bool,
/// Environment variables to passthrough into the sandbox.
pub env_passthrough: Vec<String>,
/// Docker-specific overrides (image, resource limits).
pub docker_overrides: Option<DockerOverrides>,
}
/// Docker-specific sandbox overrides layered on top of the global
/// `DockerRuntimeConfig`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DockerOverrides {
pub image: Option<String>,
pub network: Option<String>,
pub memory_limit_mb: Option<u64>,
pub cpu_limit: Option<f64>,
pub read_only_rootfs: Option<bool>,
pub extra_caps_drop: Vec<String>,
}
/// 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<String, String>,
/// 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<String>,
}
/// 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);
}
}
+112
View File
@@ -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()
);
}
}