Skip to content

Sandboxed agent execution via bubblewrap #78

Description

@aroff

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::ErrorRunError::SandboxUnavailable with remediation; RunUnsandboxedwarn! 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

  1. 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.
  2. P2 — strict profile: --unshare-net, drop creds, strict + gated integration tests.
  3. P3 — CLI surface: --sandbox[=lenient|strict] flag, aikit doctor readiness check, user docs with the "not a containment box" caveat.
  4. P4 — hardening (optional): seccomp, --cap-drop ALL, --new-session, synthetic /etc/passwd.

Open questions

  1. Exact config dirs/env each CLI needs — verify empirically.
  2. Any agent that re-execs a node/python interpreter at an absolute path outside /usr (must bind the real interpreter)?
  3. Per-agent caches — bind RW or redirect to tmpfs?
  4. --sandbox requested but bwrap absent: hard-error vs warn-and-run. Leaning error for strict, warn for lenient.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions