Files
BoosAPI/tests/agent_bash_sandbox.rs
T
boos b0ce83a937 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.
2026-06-22 10:53:45 +08:00

126 lines
4.7 KiB
Rust

//! 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}");
}