feat(agent): confine agent bash in an OS sandbox (bubblewrap), mode-gated

The SDK's built-in Bash tool ran commands as a bare subprocess with only
current_dir set — no OS boundary. On a server that let an agent shell read
/etc/shadow, cd /, write anywhere the process uid could, and reach the
network; the phase-1 path validation only guards the file tools, not bash.
(The SDK's SandboxSettings is dead config: Agent::new never reads it.)

Replace it with SandboxedBashTool — same name and input schema, registered
via custom_tools so it overrides the built-in by name — that confines the
command with bubblewrap, gated by permission mode:

- Default / AcceptEdits (review modes): run inside a bwrap namespace with the
  system mounted read-only, the session sandbox dir bind-mounted rw at its
  real path and made the cwd, /etc //root //home and all other host paths
  absent, and --unshare-all (network included). Filesystem escape and network
  egress are blocked by the kernel, not by convention.
- FullAccess (explicitly dangerous): unconfined, same as before, but every
  command is audit-logged with a warning.

Fail-closed: in a review mode, if bwrap is unavailable the command is refused
rather than silently run unconfined — no fabricated boundary.

Verified end-to-end in tests/agent_bash_sandbox.rs by invoking the tool:
under Default mode `cat /etc/passwd` sees no host passwd, `ls /` exposes no
etc//root//home, a write to /etc/cron.d/x fails (and the host file is never
created), and the network is unreachable; in-sandbox work succeeds and lands
in the real session dir; FullAccess can see the host. bwrap 0.11.2 confirmed
functional on this host (root, CAP_SYS_ADMIN, user namespaces enabled).
Phase-1 suites stay green.
This commit is contained in:
boos
2026-06-22 09:12:36 +08:00
parent 3bb1272c6d
commit b0ce83a937
4 changed files with 443 additions and 0 deletions
+9
View File
@@ -24,6 +24,7 @@
use crate::auth::jwt::validate_jwt; use crate::auth::jwt::validate_jwt;
use crate::services::agent_workbench::{ use crate::services::agent_workbench::{
approval::{self, ApprovalOutcome, SettledState}, approval::{self, ApprovalOutcome, SettledState},
bash_sandbox::SandboxedBashTool,
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode}, permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
sandbox, sandbox,
session_store::{self, UsageDelta}, session_store::{self, UsageDelta},
@@ -318,6 +319,13 @@ async fn chat_handler(
let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode); let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode);
// Replace the SDK's built-in Bash (a bare subprocess) with an
// OS-sandboxed one confined to this session's directory. Registered as
// a custom tool, it overrides the built-in by name; review modes run it
// under bwrap, FullAccess runs it unconfined with audit logging.
let sandboxed_bash: Arc<dyn open_agent_sdk::types::Tool> =
Arc::new(SandboxedBashTool::new(cwd.clone(), mode));
let agent = Agent::new(AgentOptions { let agent = Agent::new(AgentOptions {
provider: Some(provider), provider: Some(provider),
model: Some(model.clone()), model: Some(model.clone()),
@@ -325,6 +333,7 @@ async fn chat_handler(
system_prompt: Some(SYSTEM_PROMPT.to_string()), system_prompt: Some(SYSTEM_PROMPT.to_string()),
max_turns: Some(MAX_TURNS), max_turns: Some(MAX_TURNS),
can_use_tool: Some(can_use_tool), can_use_tool: Some(can_use_tool),
custom_tools: vec![sandboxed_bash],
session_id: Some(session_id.clone()), session_id: Some(session_id.clone()),
..Default::default() ..Default::default()
}) })
@@ -0,0 +1,307 @@
//! OS-level sandboxed Bash tool for the Agent Workbench.
//!
//! The open-agent-sdk's built-in `Bash` tool runs commands as a plain
//! subprocess with only `current_dir` set — no OS boundary. On a server that
//! means an agent's shell can `cat /etc/shadow`, `cd /`, write anywhere the
//! process uid can, and reach the network. This tool replaces it (same name +
//! schema, registered via `custom_tools` so it overrides the built-in) and
//! confines the command with [bubblewrap](https://github.com/containers/bubblewrap):
//!
//! - **Default / AcceptEdits** (review modes): the command runs inside a
//! bwrap namespace with the system mounted read-only, the session sandbox
//! directory bind-mounted read-write at its real path, `/etc`, `/root`,
//! `/home` and every other host path *absent*, and the network unshared.
//! Filesystem reads/writes outside the session dir and all network egress are
//! therefore impossible at the kernel level, not merely discouraged.
//! - **FullAccess** (explicitly dangerous): the command runs unconfined, the
//! same behaviour the bare SDK had, but every command is audit-logged with a
//! loud warning.
//!
//! **Fail-closed:** in a review mode, if `bwrap` is unavailable the command is
//! refused rather than silently run unconfined — we never fabricate a boundary.
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
use crate::services::agent_workbench::permission::PermissionMode;
use open_agent_sdk::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
const MAX_OUTPUT_SIZE: usize = 100_000;
/// Read-only host paths exposed inside the sandbox so common tooling works.
/// Deliberately excludes `/etc`, `/root`, `/home`, `/var`, `/proc`-host, etc.
const RO_SYSTEM_PATHS: &[&str] = &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt"];
/// A Bash tool that confines commands to the session sandbox via bubblewrap.
pub struct SandboxedBashTool {
/// Canonical session sandbox directory (bind-mounted rw inside bwrap).
session_dir: PathBuf,
/// Session permission mode — decides confined vs unconfined.
mode: PermissionMode,
/// Resolved `bwrap` binary path, if present on this host.
bwrap: Option<PathBuf>,
}
impl SandboxedBashTool {
pub fn new(session_dir: PathBuf, mode: PermissionMode) -> Self {
Self {
session_dir,
mode,
bwrap: find_bwrap(),
}
}
/// Whether this mode confines bash (everything except FullAccess).
fn is_confined(&self) -> bool {
!matches!(self.mode, PermissionMode::FullAccess)
}
}
/// Locate the `bwrap` binary (PATH + well-known locations).
fn find_bwrap() -> Option<PathBuf> {
for cand in ["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"] {
let p = Path::new(cand);
if p.is_file() {
return Some(p.to_path_buf());
}
}
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|d| d.join("bwrap"))
.find(|p| p.is_file())
})
}
/// Build the bwrap argument vector that confines `command` to `session_dir`.
///
/// Pure and unit-testable. The session directory is bound read-write at its own
/// real absolute path and becomes the working directory; the rest of the
/// filesystem is either read-only (system paths) or absent. `--unshare-all`
/// (which includes the network namespace) plus `--die-with-parent` and
/// `--new-session` complete the isolation.
pub fn build_bwrap_args(session_dir: &Path, command: &str) -> Vec<String> {
let sd = session_dir.to_string_lossy().to_string();
let mut args: Vec<String> = Vec::new();
for p in RO_SYSTEM_PATHS {
// `--ro-bind-try` tolerates paths that don't exist on this host.
args.push("--ro-bind-try".into());
args.push((*p).into());
args.push((*p).into());
}
// Minimal virtual filesystems.
args.push("--dev".into());
args.push("/dev".into());
args.push("--proc".into());
args.push("/proc".into());
args.push("--tmpfs".into());
args.push("/tmp".into());
// The session sandbox, bound rw at its real path, then made the cwd.
args.push("--bind".into());
args.push(sd.clone());
args.push(sd.clone());
args.push("--chdir".into());
args.push(sd);
// Full namespace isolation incl. network; tear down with the parent.
args.push("--unshare-all".into());
args.push("--die-with-parent".into());
args.push("--new-session".into());
// Run the command under a shell inside the sandbox.
args.push("/bin/bash".into());
args.push("-c".into());
args.push(command.into());
args
}
#[async_trait]
impl Tool for SandboxedBashTool {
fn name(&self) -> &str {
"Bash"
}
fn description(&self) -> &str {
"Executes a bash command inside an OS-level sandbox confined to the \
session working directory (read-only system, no network) unless the \
session is in full-access mode. Use for shell operations."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"command".to_string(),
json!({ "type": "string", "description": "The command to execute" }),
),
(
"timeout".to_string(),
json!({ "type": "number", "description": "Optional timeout in milliseconds (max 600000)" }),
),
(
"description".to_string(),
json!({ "type": "string", "description": "Clear description of what this command does" }),
),
]),
required: vec!["command".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let command = input
.get("command")
.and_then(|c| c.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'command' field".to_string()))?;
let timeout_ms = input
.get("timeout")
.and_then(|t| t.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_MS)
.min(MAX_TIMEOUT_MS);
// Build the runner per mode.
let (program, args): (String, Vec<String>) = if self.is_confined() {
let Some(bwrap) = &self.bwrap else {
// Fail closed: never downgrade a review-mode session to an
// unconfined shell just because the sandbox tool is missing.
return Ok(ToolResult::error(
"Bash is disabled: the OS sandbox (bwrap) is not available on \
this host, and this session runs in a review mode that requires \
confinement. Re-run in full-access mode (explicitly unsafe) or \
install bubblewrap."
.to_string(),
));
};
(
bwrap.to_string_lossy().to_string(),
build_bwrap_args(&self.session_dir, command),
)
} else {
// FullAccess: unconfined, but audit every command loudly.
tracing::warn!(
target: "boosapi::agent::bash",
mode = "full_access",
command = command,
"UNCONFINED agent bash command (full-access mode)"
);
(
"/bin/bash".to_string(),
vec!["-c".to_string(), command.to_string()],
)
};
// Unconfined mode honours the SDK's working dir; confined mode's cwd is
// set inside bwrap via --chdir, so the outer cwd is irrelevant.
let outer_cwd = if self.is_confined() {
self.session_dir.clone()
} else {
PathBuf::from(&context.working_dir)
};
let fut = run_capture(&program, &args, &outer_cwd);
match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), fut).await {
Ok(Ok((stdout, stderr, code))) => Ok(format_result(stdout, stderr, code)),
Ok(Err(e)) => Ok(ToolResult::error(format!("Command failed: {e}"))),
Err(_) => Ok(ToolResult::error(format!(
"Command timed out after {timeout_ms}ms"
))),
}
}
}
async fn run_capture(
program: &str,
args: &[String],
cwd: &Path,
) -> Result<(String, String, i32), std::io::Error> {
let output = Command::new(program)
.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
Ok((
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
output.status.code().unwrap_or(-1),
))
}
fn format_result(stdout: String, stderr: String, exit_code: i32) -> ToolResult {
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("STDERR:\n");
result.push_str(&stderr);
}
if result.len() > MAX_OUTPUT_SIZE {
result.truncate(MAX_OUTPUT_SIZE);
result.push_str("\n... (output truncated)");
}
if exit_code != 0 {
result.push_str(&format!("\n\nExit code: {exit_code}"));
}
if result.is_empty() {
result = "(no output)".to_string();
}
if exit_code != 0 {
ToolResult::error(result)
} else {
ToolResult::text(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn args_bind_session_dir_and_isolate() {
let sd = Path::new("/srv/data/agent_sessions/abc");
let args = build_bwrap_args(sd, "echo hi");
let joined = args.join(" ");
// Session dir is bound rw and made the cwd.
assert!(joined.contains("--bind /srv/data/agent_sessions/abc /srv/data/agent_sessions/abc"));
assert!(joined.contains("--chdir /srv/data/agent_sessions/abc"));
// Network + everything else unshared, parent-bound.
assert!(args.iter().any(|a| a == "--unshare-all"));
assert!(args.iter().any(|a| a == "--die-with-parent"));
// /etc is never bound.
assert!(!joined.contains("/etc"));
// System paths are read-only.
assert!(joined.contains("--ro-bind-try /usr /usr"));
// The command is the last argument, run under bash -c.
assert_eq!(args.last().unwrap(), "echo hi");
let n = args.len();
assert_eq!(args[n - 3], "/bin/bash");
assert_eq!(args[n - 2], "-c");
}
#[test]
fn confinement_follows_mode() {
let sd = PathBuf::from("/tmp/x");
assert!(SandboxedBashTool::new(sd.clone(), PermissionMode::Default).is_confined());
assert!(SandboxedBashTool::new(sd.clone(), PermissionMode::AcceptEdits).is_confined());
assert!(!SandboxedBashTool::new(sd, PermissionMode::FullAccess).is_confined());
}
#[test]
fn name_overrides_builtin_bash() {
let t = SandboxedBashTool::new(PathBuf::from("/tmp/x"), PermissionMode::Default);
assert_eq!(t.name(), "Bash");
}
}
+2
View File
@@ -5,6 +5,7 @@
//! //!
//! - [`permission`]: tool risk classification + the per-mode decision matrix. //! - [`permission`]: tool risk classification + the per-mode decision matrix.
//! - [`sandbox`]: per-session isolated working directory + path validation. //! - [`sandbox`]: per-session isolated working directory + path validation.
//! - [`bash_sandbox`]: OS-level (bubblewrap) confinement for the Bash tool.
//! - [`approval`]: process-global registry bridging the SSE approval round-trip. //! - [`approval`]: process-global registry bridging the SSE approval round-trip.
//! - [`session_store`]: persistence of per-session token/cost accounting. //! - [`session_store`]: persistence of per-session token/cost accounting.
//! //!
@@ -12,6 +13,7 @@
//! `crate::routes::agent`. //! `crate::routes::agent`.
pub mod approval; pub mod approval;
pub mod bash_sandbox;
pub mod permission; pub mod permission;
pub mod sandbox; pub mod sandbox;
pub mod session_store; pub mod session_store;
+125
View File
@@ -0,0 +1,125 @@
//! Integration tests for OS-level (bubblewrap) confinement of the agent Bash
//! tool. These actually invoke `SandboxedBashTool::call` and assert that, in a
//! review mode, the kernel boundary blocks filesystem and network escapes while
//! legitimate in-sandbox work succeeds.
//!
//! The escape tests require a working `bwrap`; when it is absent the confined
//! path fails closed (asserted separately) and the live-escape tests skip with
//! a printed notice rather than producing a false pass.
#![cfg(unix)]
use boosapi::services::agent_workbench::bash_sandbox::{build_bwrap_args, SandboxedBashTool};
use boosapi::services::agent_workbench::permission::PermissionMode;
use open_agent_sdk::types::{Tool, ToolUseContext};
use serde_json::json;
use std::path::{Path, PathBuf};
fn bwrap_available() -> bool {
["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"]
.iter()
.any(|p| Path::new(p).is_file())
}
fn tmp_session() -> PathBuf {
let base =
std::env::temp_dir().join(format!("agent_bash_it_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&base).unwrap();
base.canonicalize().unwrap()
}
async fn run(tool: &SandboxedBashTool, command: &str) -> (String, bool) {
let ctx = ToolUseContext::new(std::env::temp_dir().to_string_lossy().to_string());
let res = tool
.call(json!({ "command": command }), &ctx)
.await
.expect("tool call ok");
(res.get_text(), res.is_error)
}
#[test]
fn build_args_pin_session_dir_and_isolation() {
let args = build_bwrap_args(Path::new("/srv/sess/abc"), "id");
let j = args.join(" ");
assert!(j.contains("--bind /srv/sess/abc /srv/sess/abc"));
assert!(j.contains("--chdir /srv/sess/abc"));
assert!(args.iter().any(|a| a == "--unshare-all"));
assert!(!j.contains("/etc"));
}
#[tokio::test]
async fn default_mode_blocks_reading_etc_passwd() {
if !bwrap_available() {
eprintln!("SKIP: bwrap not available");
return;
}
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
let (out, _is_err) = run(&tool, "cat /etc/passwd").await;
// The host's /etc is never mounted, so the read cannot succeed.
assert!(
!out.contains("root:") && !out.contains(":/bin/"),
"should not see host passwd contents, got: {out}"
);
}
#[tokio::test]
async fn default_mode_blocks_listing_root() {
if !bwrap_available() {
eprintln!("SKIP: bwrap not available");
return;
}
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
let (out, _) = run(&tool, "ls /").await;
// /etc, /root, /home must be absent from the sandbox root.
assert!(!out.contains("etc"), "sandbox root must not expose /etc: {out}");
assert!(!out.contains("root"), "sandbox root must not expose /root: {out}");
assert!(!out.contains("home"), "sandbox root must not expose /home: {out}");
}
#[tokio::test]
async fn default_mode_blocks_writing_outside_sandbox() {
if !bwrap_available() {
eprintln!("SKIP: bwrap not available");
return;
}
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
let (_out, is_err) = run(&tool, "echo pwned > /etc/cron.d/x").await;
assert!(is_err, "writing outside the sandbox must fail");
// And the host file must not exist.
assert!(!Path::new("/etc/cron.d/x").exists());
}
#[tokio::test]
async fn default_mode_blocks_network() {
if !bwrap_available() {
eprintln!("SKIP: bwrap not available");
return;
}
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
let (out, _) = run(&tool, "getent hosts example.com || echo NET-BLOCKED").await;
assert!(out.contains("NET-BLOCKED"), "network should be unshared: {out}");
}
#[tokio::test]
async fn default_mode_allows_work_inside_sandbox() {
if !bwrap_available() {
eprintln!("SKIP: bwrap not available");
return;
}
let sd = tmp_session();
let tool = SandboxedBashTool::new(sd.clone(), PermissionMode::Default);
let (out, is_err) = run(&tool, "echo hello > note.txt && cat note.txt").await;
assert!(!is_err, "in-sandbox work should succeed: {out}");
assert!(out.contains("hello"));
// The file lands in the real session dir (bound rw).
assert!(sd.join("note.txt").exists());
}
#[tokio::test]
async fn full_access_mode_is_unconfined() {
// FullAccess is the explicitly-dangerous mode: it should NOT be sandboxed,
// so it can read the host (this documents the intended escape hatch).
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::FullAccess);
let (out, _) = run(&tool, "test -e /etc && echo HOST-VISIBLE || echo no").await;
assert!(out.contains("HOST-VISIBLE"), "full access should see host: {out}");
}