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::services::agent_workbench::{
approval::{self, ApprovalOutcome, SettledState},
bash_sandbox::SandboxedBashTool,
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
sandbox,
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);
// 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 {
provider: Some(provider),
model: Some(model.clone()),
@@ -325,6 +333,7 @@ async fn chat_handler(
system_prompt: Some(SYSTEM_PROMPT.to_string()),
max_turns: Some(MAX_TURNS),
can_use_tool: Some(can_use_tool),
custom_tools: vec![sandboxed_bash],
session_id: Some(session_id.clone()),
..Default::default()
})