26. Reimplement the sandbox in Python that execs bubblewrap#
Date: 2026-10-06
Status#
Accepted
Implemented in 5.0.0 (2026-10-07): the Python implementation is the only one, and the bash shadow, installer steps, CLI and launcher are removed.
Amended 2026-10-06: the CLI uses argparse, and the package has no runtime dependencies. Typer would have put rich, pygments and click, about 15 MB (measured in the phase 0 spike on issue #72), into the trusted set of helpers that handle PATs and run as root.
Supersedes ADR 8 (bash-only). Amends ADR 23: the wheel stops bundling bash verbatim and becomes the implementation. Leaves ADR 9 (shadow on PATH) and ADR 18 (one shadow, many agents) in force; the shadow keeps its names and its place on PATH, and only what it hands off to changes.
Context#
ADR 8 chose bash because the tool was “one bash function building a bwrap argv”, and the security surface was “two short bash files … that you can read top to bottom”. At the time each file was about 80 lines.
That premise no longer holds. The implementation is now about 3,900 lines of bash across several files, plus about 3,300 lines of bash tests:
File |
Lines |
|---|---|
|
1356 |
|
947 |
|
620 |
|
412 |
battery, |
about 600 |
Most of that code builds argument lists, parses configuration and validates
ports and paths: string and list work that Python does plainly and bash does
with namerefs, ${arr[@]+"${arr[@]}"} guards, printf %q re-quoting through
script -c, and export -f to carry seven functions into an
unshare … bash -c holder. At this size bash is the less auditable choice,
which is the opposite of what ADR 8 intended.
There are also two CLIs that overlap: the host launcher
(container/claude-container, run through uvx claude-sandbox) and the
in-container claude-sandbox helper. The launcher forwards a hand-maintained
list of verbs into the container.
The Python era ended by bf65407 (issue #14) failed because it spread a small
security core across many modules. This ADR has to avoid repeating that, and
it adds hazards that bash does not have: an interpreter can be redirected by
PATH, PYTHONPATH, sitecustomize, usercustomize and .pth files.
Options considered:
Keep bash and split it into sourced files. Readability improves a little, but the hard constructs remain, and the argv tests stay a bash harness that sources functions.
Keep the shadow in bash and port only the CLIs. The shadow is the largest file and holds nearly all of the security-critical code (the bwrap argv builder and the egress jail), so this ports the easy part and leaves the hard part.
Port everything to Python, installed as a pinned, root-owned interpreter and venv. Chosen.
Decision#
The sandbox is a Python package, claude_sandbox, that builds argument lists
and execs bwrap, unshare, pasta and script. It ships as the existing
PyPI wheel. A root pyproject.toml and src/claude_sandbox/ replace
packaging/pypi/. Development uses uv, pytest, ruff and pyright, with a
development lockfile.
One package, one CLI, three contexts. context.py detects once whether
the process is on the HOST, in the CONTAINER (outside the jail) or in the
JAIL. Commands declare where they run, for example
@requires(CONTAINER, forward_from=HOST) for verify. The host launcher and
the in-container helper become one CLI, claude-sandbox, built on argparse;
the package has no runtime dependencies. A command called on the host that
belongs in the container is forwarded by
podman exec or docker exec. A command that has no meaning in the current
context refuses with a clear message. A forwarded command runs the version
installed in the container, as it does today.
The shadow becomes a three-line bash shim. /usr/local/bin/claude,
codex and pi remain one root-owned file installed before the vendor
installers can claim the names (Invariant 1). It is:
#!/bin/bash
# /usr/local/bin/claude (and codex, pi): hand off to the root-owned install.
exec /usr/libexec/claude-sandbox/venv/bin/python -I -m claude_sandbox _shadow "${0##*/}" -- "$@"
Callers (the VS Code extension, hooks, claude -p in scripts) keep calling
claude --resume; the shim inserts the --, so arguments reach the real
agent unchanged. claude_sandbox/__main__.py checks for _shadow before
importing the CLI, so the launch path imports only what it needs and adds
little startup time.
The audit core stays in a few files you can read top to bottom.
bwrap.py: a pure function from (agent profile, config, environment) to the bwrap argv. Standard library only, no I/O beyond existence checks.jail.py: the network namespace holder, pasta, DNS forwarding and relays.shadow.py: the recursion guard, launch preparation and the exec ofscriptandbwrap.
Profiles, configuration parsing, the host launcher, the installer and the
helper commands live in their own modules around that core. No helper module
contributes binds or environment to the argv except through bwrap.py.
The interpreter cannot be redirected.
installplaces a pinned, uv-managed CPython and a venv under/usr/libexec/claude-sandbox/, root-owned and byte-compiled at install. Never run from uv’s cache:~/.cacheis writable from inside the jail.Every entry point names that interpreter by absolute path and runs it with
-I(isolated mode), which ignoresPYTHON*variables, the user site-packages directory and the current directory. No#!/usr/bin/env python3: in the published imagePATHstarts with/opt/venv/bin, which resolves into/cache, which the jail can write.A test plants
sitecustomize.py,usercustomize.py, a.pthfile, aPYTHONPATHand a fakepythonearlier onPATH, then launches, and asserts that none of them ran.
Installation. uvx claude-sandbox install, already the documented route,
installs the wheel into the root-owned venv. The clone route keeps a short
bash bootstrap that fetches uv and hands over to the same Python installer.
Guest devcontainers therefore need network access at install time, which they
already need to fetch the agent binaries.
What stays in bash. The integrity battery
(verify-sandbox-battery.sh), which probes the jail from inside with shell
commands, and the small in-jail exec wrappers (codex-launch, pi-run) and
container/entrypoint.sh, until there is a reason to change them. The
end-to-end bash tests stay as black-box tests of the new implementation.
Migration is incremental. The Python argv builder must produce the same argv as the bash one across a matrix of configurations before it ships, and the Python shadow ships behind an opt-in switch before it becomes the default. The plan is tracked in a GitHub issue.
Consequences#
CLAUDE.md’s Python rules, theclaude-sandboxskill’s “Reversal 1” and theclaude-sandbox-containerskill are rewritten when this ADR is accepted. The refuse-list changes from “no Python” to: no runtime dependencies (the package is the standard library only), no interpreter found throughPATH, no running without-I, and no bind or environment added outsidebwrap.py.Each guest gains a pinned CPython and venv: 56 MB once pruned, against 124 MB as uv installs it (measured 2026-10-06, issue #72 phase 4), plus the pinned uv the installer keeps to provision it (46 MB). Since issue #85 the pruning keeps the C headers and
ensurepip(60 MB), because the published image’s projects share the interpreter: their venvs link to it, root-owned and outside everyallow-write, instead of to a second uv-managed copy under/opt/uv/python(104 MB), and the image drops the installer’s uv once it has provisioned, keeping its base image’s for projects. The image shrank by 146 MB, from 1,964 MB (5.0.0-beta.1) to 1,818 MB, measured 2026-10-07 with the same agent releases.The trusted set grows from bash and coreutils to CPython.
The argv tests become pytest on a pure function.
tests/bwrap_argv.shis removed once the Python builder is the only one.The Dockerfile’s
source install.shreuse is replaced by calling the Python installer.The first release with the Python shadow as the default is 5.0.0.
Risks specific to the port: Python’s SIGINT handling while a child owns the terminal, keeping the netns holder’s stdin on the terminal, and replacing the shadow’s
trap EXITcleanup withtry/finallyand signal handlers. Each needs a test before the switch flips.