b0ce83a937
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.
126 lines
4.7 KiB
Rust
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}");
|
|
}
|