Summary
Optionally run the five child-process agents (claude, codex, gemini, opencode, agent) under bubblewrap (bwrap) to bound the blast radius of agent execution. The in-process aikit agent is out of scope (it never spawns a child).
Full design doc: docs/design/sandbox-agent-execution.md.
Problem
External agents are spawned with permission prompts disabled — --yolo (codex/opencode), --dangerously-skip-permissions (claude), --force (agent). A misbehaving or prompt-injected tool call can read/write/delete anything the invoking user can (~/.ssh, ~/.bashrc, sibling projects, all of $HOME). Today the only containment is current_dir (cwd, not a boundary) and a wall-clock timeout.
Threat model (drives the design)
These agents are network- and credential-dependent (claude/gemini/codex call cloud APIs, read auth from ~/.claude, ~/.config/..., API-key env). A single lock-down profile is not viable, so two selectable profiles:
| Profile |
Network |
Creds/config |
Writable FS |
Use |
| Lenient (blast-radius) |
allowed |
bound RO/RW per agent |
working dir only |
Default useful mode for cloud agents. Stops --dangerously-skip-permissions damaging anything outside the project. |
| Strict (full isolation) |
--unshare-net |
not bound |
working dir + tmpfs |
Offline/local-model or fully-untrusted prompts. Breaks cloud agents by design. |
Non-goal: lenient is not a containment box for untrusted code — the agent still holds your API keys and the internet. It is a filesystem/process blast-radius limiter only. This caveat must be prominent in user docs.
Insertion point
Single choke point — aikit-sdk/src/runner/mod.rs::spawn_agent_piped() (~L80). Rewrite the Command::new(program) so a sandboxed run becomes bwrap [flags] -- <agent> [args]. Stdio is untouched (bwrap inherits fds 0/1/2 and execs the target), so the reader-thread / normalize_json_line / AgentEvent pipeline is unchanged. The timeout watchdog kills the bwrap process; --die-with-parent tears down the sandboxed tree (better cleanup than today). With sandbox: None (default) the path is byte-for-byte current behavior. When sandboxed, cwd is set via bwrap --chdir (a bound path), not Command::current_dir.
API surface
RunOptions.sandbox: Option<SandboxPolicy> (additive; struct already #[non_exhaustive]; Default = None).
- New
aikit-sdk/src/sandbox/ module: SandboxPolicy { network, ro_binds, rw_binds, env_allowlist, on_unavailable }, NetworkPolicy::{Allow,Deny}, FallbackMode::{Error,RunUnsandboxed}, constructors lenient() / strict(), and wrap_command(agent_key, program, args, policy, working_dir) -> (program, argv).
- Per-agent default binds/env hung off the existing
AgentCliSpec table in argv.rs (config/auth dirs + credential env per agent — to be verified empirically).
- New
RunError::SandboxUnavailable { reason } and RunError::SandboxConfig { reason }.
Generated invocation (lenient, claude example)
bwrap
--ro-bind /usr /usr --ro-bind /bin /bin --ro-bind /lib /lib --ro-bind /lib64 /lib64
--ro-bind /etc/resolv.conf /etc/resolv.conf --ro-bind /etc/ssl /etc/ssl
--proc /proc --dev /dev --tmpfs /tmp
--ro-bind ~/.claude ~/.claude # --bind if RW required
--bind <working_dir> <working_dir> # ONLY writable real host path
--chdir <working_dir>
--unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup --die-with-parent
--clearenv --setenv PATH ... --setenv HOME ... --setenv ANTHROPIC_API_KEY ...
-- claude -p - --dangerously-skip-permissions --model <m> ...
Strict mode adds --unshare-net and drops cred/config + resolv.conf/ssl binds. Secrets: --clearenv + --setenv NAME reads values from the bwrap process env, so key values never hit argv.
Availability & fallback
- Detect once (cache):
bwrap --version succeeds and unprivileged userns usable (common failure: kernel.unprivileged_userns_clone=0 on hardened Debian/RHEL). Probe via a trivial bwrap --ro-bind / / true.
FallbackMode::Error → RunError::SandboxUnavailable with remediation; RunUnsandboxed → warn! and run native.
- Linux-only;
cfg the module so non-Linux compiles.
Testing
- Unit (no bwrap): golden-vector argv tests per agent × profile — the bulk of the value, runs everywhere.
- Integration (gated on bwrap,
#[ignore] otherwise): write inside working dir succeeds; write outside fails (EROFS/EACCES); strict blocks a network connect, lenient allows; event pipeline still parses output.
- Repo convention: per-file
[[test]] targets, no tests/unit/mod.rs.
Phasing
- P1 — core, lenient only: module +
SandboxPolicy::lenient + RunOptions.sandbox + wire into spawn_agent_piped + per-agent binds + availability/fallback + unit tests. PoC-validate claude end-to-end.
- P2 — strict profile:
--unshare-net, drop creds, strict + gated integration tests.
- P3 — CLI surface:
--sandbox[=lenient|strict] flag, aikit doctor readiness check, user docs with the "not a containment box" caveat.
- P4 — hardening (optional): seccomp,
--cap-drop ALL, --new-session, synthetic /etc/passwd.
Open questions
- Exact config dirs/env each CLI needs — verify empirically.
- Any agent that re-execs a node/python interpreter at an absolute path outside
/usr (must bind the real interpreter)?
- Per-agent caches — bind RW or redirect to tmpfs?
--sandbox requested but bwrap absent: hard-error vs warn-and-run. Leaning error for strict, warn for lenient.
Summary
Optionally run the five child-process agents (
claude,codex,gemini,opencode,agent) under bubblewrap (bwrap) to bound the blast radius of agent execution. The in-processaikitagent is out of scope (it never spawns a child).Full design doc:
docs/design/sandbox-agent-execution.md.Problem
External agents are spawned with permission prompts disabled —
--yolo(codex/opencode),--dangerously-skip-permissions(claude),--force(agent). A misbehaving or prompt-injected tool call can read/write/delete anything the invoking user can (~/.ssh,~/.bashrc, sibling projects, all of$HOME). Today the only containment iscurrent_dir(cwd, not a boundary) and a wall-clocktimeout.Threat model (drives the design)
These agents are network- and credential-dependent (claude/gemini/codex call cloud APIs, read auth from
~/.claude,~/.config/..., API-key env). A single lock-down profile is not viable, so two selectable profiles:--dangerously-skip-permissionsdamaging anything outside the project.--unshare-netNon-goal: lenient is not a containment box for untrusted code — the agent still holds your API keys and the internet. It is a filesystem/process blast-radius limiter only. This caveat must be prominent in user docs.
Insertion point
Single choke point —
aikit-sdk/src/runner/mod.rs::spawn_agent_piped()(~L80). Rewrite theCommand::new(program)so a sandboxed run becomesbwrap [flags] -- <agent> [args]. Stdio is untouched (bwrap inherits fds 0/1/2 andexecs the target), so the reader-thread /normalize_json_line/AgentEventpipeline is unchanged. The timeout watchdog kills thebwrapprocess;--die-with-parenttears down the sandboxed tree (better cleanup than today). Withsandbox: None(default) the path is byte-for-byte current behavior. When sandboxed, cwd is set via bwrap--chdir(a bound path), notCommand::current_dir.API surface
RunOptions.sandbox: Option<SandboxPolicy>(additive; struct already#[non_exhaustive];Default=None).aikit-sdk/src/sandbox/module:SandboxPolicy { network, ro_binds, rw_binds, env_allowlist, on_unavailable },NetworkPolicy::{Allow,Deny},FallbackMode::{Error,RunUnsandboxed}, constructorslenient()/strict(), andwrap_command(agent_key, program, args, policy, working_dir) -> (program, argv).AgentCliSpectable inargv.rs(config/auth dirs + credential env per agent — to be verified empirically).RunError::SandboxUnavailable { reason }andRunError::SandboxConfig { reason }.Generated invocation (lenient, claude example)
Strict mode adds
--unshare-netand drops cred/config +resolv.conf/sslbinds. Secrets:--clearenv+--setenv NAMEreads values from the bwrap process env, so key values never hit argv.Availability & fallback
bwrap --versionsucceeds and unprivileged userns usable (common failure:kernel.unprivileged_userns_clone=0on hardened Debian/RHEL). Probe via a trivialbwrap --ro-bind / / true.FallbackMode::Error→RunError::SandboxUnavailablewith remediation;RunUnsandboxed→warn!and run native.cfgthe module so non-Linux compiles.Testing
#[ignore]otherwise): write inside working dir succeeds; write outside fails (EROFS/EACCES); strict blocks a network connect, lenient allows; event pipeline still parses output.[[test]]targets, notests/unit/mod.rs.Phasing
SandboxPolicy::lenient+RunOptions.sandbox+ wire intospawn_agent_piped+ per-agent binds + availability/fallback + unit tests. PoC-validate claude end-to-end.--unshare-net, drop creds, strict + gated integration tests.--sandbox[=lenient|strict]flag,aikit doctorreadiness check, user docs with the "not a containment box" caveat.--cap-drop ALL,--new-session, synthetic/etc/passwd.Open questions
/usr(must bind the real interpreter)?--sandboxrequested but bwrap absent: hard-error vs warn-and-run. Leaning error for strict, warn for lenient.