mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(sandbox): add real sandbox execution backends for agent tools (#3261)
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user