Bubblewrap Sandbox
Bubblewrap (bwrap) is a daemonless Linux sandbox built on user namespaces. It isolates tool subprocesses without root, a container runtime, or a background service. On Linux, backend: auto picks it.
For the shared config reference and migration guide, see Runtime Sandbox. For the container-based alternative, see Docker Sandbox.
Why bubblewrap
- No daemon, no root.
bwrapis a setuid binary that creates unprivileged user namespaces. Nothing runs in the background. No Docker Desktop, no socket. - Fast startup. A sandbox costs roughly one
fork+execveplus namespace setup. No image pull, no container runtime, no layered filesystem. - Minimal surface. The binary does one thing: assemble a namespace and exec the command.
Requirements
Bubblewrap is Linux-only and needs unprivileged user namespaces enabled in the kernel.
| Distro | Command |
|---|---|
| Debian/Ubuntu | apt install bubblewrap |
| Fedora | dnf install bubblewrap |
| Arch | pacman -S bubblewrap |
| Alpine | apk add bubblewrap |
If the preflight probe fails, one of two sysctls is usually the cause. InitRunner's error reads both and tells you which.
The kernel disables user namespaces
Some older or hardened kernels ship with user namespaces off:
sudo sysctl -w kernel.unprivileged_userns_clone=1
# persistent:
echo 'kernel.unprivileged_userns_clone=1' | sudo tee /etc/sysctl.d/00-local-userns.confAppArmor blocks user namespaces (Ubuntu 24.04+, Debian 13)
Ubuntu 24.04 shipped kernel.apparmor_restrict_unprivileged_userns=1 in April 2024. User namespaces have been a recurring source of kernel privilege-escalation CVEs, so the hardening limits userns to processes covered by an AppArmor profile that grants the userns capability.
The symptom is a probe failure like bwrap: setting up uid map: Permission denied. Pick one of three fixes:
-
Install an AppArmor profile for bwrap (recommended; keeps the system-wide hardening):
sudo apt install --reinstall bubblewrap apparmor sudo systemctl reload apparmorThe Debian/Ubuntu
bubblewrappackage ships a profile on recent releases. Reinstalling ensures it's loaded. -
Relax the global restriction (reduces hardening for every app on the host):
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 # persistent: echo 'kernel.apparmor_restrict_unprivileged_userns=0' | \ sudo tee /etc/sysctl.d/60-apparmor-userns.conf -
Switch the role to
backend: dockerorbackend: auto.autotries bwrap and falls back to Docker when bwrap can't run.
For bundles that must run on mixed hosts, backend: auto is the safest default: bwrap on Linux hosts where it works, Docker everywhere else, no sysctl edits required.
Enabling it
security:
sandbox:
backend: bwrap # or: auto (prefers bwrap on Linux, falls back to Docker)
network: none # unshare-net: no routes, no sockets
memory_limit: 256m
cpu_limit: 1.0
allowed_read_paths: []
allowed_write_paths: []
bind_mounts: []
env_passthrough: []The schema does not expose a bwrap.extra_args field. Raw bwrap flags can defeat the sandbox (--bind / / undoes the mount namespace), so they stay out of reach.
Isolation model
Every bwrap invocation creates fresh namespaces and mounts before executing the tool command:
--unshare-user— new user namespace; the tool runs as a fake root with no host privileges.--unshare-pid— new PID namespace; the tool cannot see or signal host processes.--unshare-uts--unshare-ipc--unshare-cgroup— isolates hostname, SysV IPC, and cgroup view.--die-with-parent— if initrunner exits, the sandboxed process dies with it (no orphans).--new-session— attached only when stdin is not a TTY, to avoid breaking interactive sessions.
Filesystem layout
| Mount | Source | Mode |
|---|---|---|
/usr, /bin, /lib, /lib64 | Host | read-only |
/etc/resolv.conf, /etc/ssl/certs, /etc/alternatives | Host | read-only |
/work | Tool's cwd | read-write |
/role | Role directory | read-only (when a role is loaded) |
/tmp | tmpfs | read-write |
/proc | new proc namespace | read-only |
/dev | minimal devtmpfs (null, zero, random, urandom, tty, full) | read-only |
allowed_read_paths | Host paths | read-only |
allowed_write_paths | Host paths | read-write |
bind_mounts | Host paths (per entry) | per read_only flag |
InitRunner creates paths under allowed_* and bind_mounts on the host if they don't exist, so bind-mounting never fails on a missing source.
Network
network: | Behavior |
|---|---|
none | Adds --unshare-net. The sandbox has no interfaces beyond loopback, no routes, no DNS. |
host | No network namespace. The sandbox shares the host's network; useful for tools that need your normal DNS or proxy setup. |
bridge | Not supported. bwrap has no bridge-networking mode. Raises SandboxConfigError at runtime. Use backend: docker if you need bridge networking. |
Environment
The sandbox starts with --clearenv. No host environment leaks in. Only these keys pass through:
- The always-on allowlist:
PATH,HOME,LANG,TERM. - Anything listed in
env_passthrough. - Whatever the tool sets explicitly via its
envarg (e.g.PYTHONDONTWRITEBYTECODE).
scrub_env() filters the whole set first, dropping entries that match sensitive_env_prefixes (OPENAI_API_KEY, AWS_SECRET, DATABASE_URL, …). Docker behaves the same, so presets carry over across backends.
Resource limits
InitRunner wraps the command in systemd-run --user --scope to enforce memory_limit and cpu_limit:
systemd-run --user --scope --quiet \
-p MemoryMax=256m \
-p CPUQuota=100% \
-- bwrap ... -- /bin/python /work/_run.pyIf systemd-run --user fails (non-systemd distros, CI without a user instance, or inside some containers), InitRunner logs one warning per role load and skips limit enforcement. The sandbox itself still runs. There is no prlimit/ulimit fallback; the warning surfaces the gap instead of silently half-enforcing.
Preflight
backend: bwrap (and auto on Linux) runs a functional probe before launching any tool:
bwrap --ro-bind /usr /usr -- /bin/trueThis catches kernel-disabled user namespaces, AppArmor restrictions, and broken installs that a bare which bwrap check misses. On failure, initrunner raises SandboxUnavailableError with install and sysctl remediation.
initrunner doctor --role <file> runs the same probe and reports readiness without executing the agent.
Example: code interpreter
apiVersion: initrunner/v1
kind: Agent
metadata:
name: bwrap-python-runner
spec:
role: |
You are a code execution assistant running in a bubblewrap sandbox.
No network access, read-only root filesystem, 256m memory, 1 CPU.
model:
provider: openai
name: gpt-5-mini
tools:
- type: shell
blocked_commands: []
- type: python
security:
sandbox:
backend: bwrap
network: none
memory_limit: 256m
cpu_limit: 1.0
allowed_read_paths:
- /usr/share/dict
allowed_write_paths:
- /srv/workspaceInside the sandbox:
python -c "open('/etc/shadow').read()"→PermissionError./etc/shadowis not mounted.python -c "import urllib.request; urllib.request.urlopen('https://example.com')"→OSError: Network is unreachable. The network namespace is empty.python -c "open('/srv/workspace/out.txt','w').write('ok')"→ succeeds. That path is bind-mounted read-write.python -c "import os; print(os.environ.get('OPENAI_API_KEY'))"→None. The host env was cleared.
Audit
Each call emits a sandbox.exec security event:
backend=bwrap argv0=/usr/bin/python rc=0 duration_ms=48Query with:
initrunner audit security-events --event-type sandbox.execWhen to pick bwrap vs docker
| You want… | Use |
|---|---|
| No daemon, no root, minimal setup on Linux | bwrap |
| Fastest per-call startup | bwrap |
| macOS, Windows, or non-Linux hosts | docker |
| Bridge networking with a custom Docker network | docker |
A specific OS or runtime image (e.g. python:3.12-slim, node:20) | docker |
| Cross-host reproducibility of the sandbox environment | docker |
| Auto-detect at runtime | auto |
backend: auto is the recommended default for published bundles. It picks bwrap on Linux where user namespaces work and falls back to docker elsewhere. It never falls to none.
Limitations
- Linux only.
sandbox.backend: bwrapon macOS or Windows raises at load time. Usedockerthere. - No seccomp profile. bwrap ships without a seccomp filter in v1. A determined tool can still make any syscall the kernel allows from inside its namespaces. Rely on filesystem and network isolation as the primary boundary.
- No image pinning. The sandbox inherits the host's
/usrtree. Upgrading the host upgrades the sandbox. For reproducibility across hosts, usedockerwith a pinned image. - systemd-run dependency for limits. Without it,
memory_limitandcpu_limitare advisory. The sandbox still isolates the filesystem and network.