# Architecture
## At a glance
The recommended entry point is `uv tool install claude-sandbox`, then
`claude-sandbox` from a host project directory. The PyPI launcher starts a
prebuilt container; the wrapper described below runs **inside** that container.
Your own devcontainer can use the same wrapper via `uvx claude-sandbox install`.
`claude-sandbox` is a launch-time wrapper. A **shadow** `claude` sits first
on `$PATH` at `/usr/local/bin/claude`; the real Anthropic binary is
**relocated off-PATH** to `/usr/libexec/claude-sandbox/claude`. Every plain
`claude` invocation therefore resolves to the shadow, which re-execs the real
binary inside a `bwrap` jail. Inside that jail the filesystem is mounted
read-only and `$HOME` is wiped to a `tmpfs`, so host credentials, IDE bridges,
and the shell environment are unreachable — while the current workspace stays
read-write and the internet stays reachable, because Claude needs both to work.
By default Claude's egress is also *jailed* ({ref}`adr-network-egress-jail`): a
per-process network namespace blackholes the internal RFC1918 network so a
compromised session cannot pivot sideways to internal hosts, while
`api.anthropic.com`, the forges, DNS, and configured `allow-ip` devices stay
reachable. The wrapper refuses to launch if its isolation prerequisites fail.
Use explicit verification to inspect the running sandbox.
## Design philosophy
Four ideas run through the whole system, and every file below is an expression
of one of them:
- **Small enough to audit in one read.** The security implementation is Bash;
the PyPI package bundles and launches it. The shadow
is a single file you can read top-to-bottom; the `bwrap` argv builder is
inlined, not sourced from elsewhere.
- **Default-deny by inversion.** The base mount is `--ro-bind / /` and `$HOME`
is a `tmpfs`; access is granted by an explicit short bind-back list. Anything
not enumerated stays masked.
- **`/etc`, never the workspace.** Config and policy that govern the *next*
launch live under `/etc` (outside the read-write workspace), so a compromised
session cannot rewrite them to widen its own binds.
- **Protected launch path.** Real agent binaries live off PATH; updater
controls keep the wrapper as the normal entry point.
Two protections live here: **credential isolation** (the `bwrap` bind model) and
**lateral / sideways network isolation** (the egress jail,
{ref}`adr-network-egress-jail`, on by default). A third, complementary surface —
internet *domain* allow-listing — is Claude Code's *native* sandbox; this tool
*meshes* with it rather than replacing it, so you can run both layers together
(see [the egress jail and the native sandbox](threat-model.md#the-egress-jail-and-the-native-sandbox)).
For the full inventory of what is locked down vs. deliberately exposed, see the
[threat model](threat-model.md). This page is the *map* — how the pieces fit.
## 1. System context
The trust boundary is the edge of the `bwrap` jail. Outside it sit everything an
LLM-driven attack would want: host dotfiles and the host gitconfig, the host
environment, `/run/secrets`, the VS Code IPC sockets in `/tmp`, X11/runtime
sockets. The shadow on `$PATH` is the only doorway, and it constructs the jail
so those things land on the outside. Two exposures are deliberately *inside*:
the workspace (`$PWD`, read-write) and the network — but the network is jailed:
by default the internet, DNS, and `allow-ip` devices are reachable while the
internal RFC1918 network is blackholed ({ref}`adr-network-egress-jail`).
```{mermaid}
graph TB
user["developer shell
types: claude"]
subgraph host["devcontainer host (trust boundary)"]
direction TB
subgraph creds["OUTSIDE the jail — masked"]
dotfiles["$HOME dotfiles
.ssh .aws .gnupg .netrc"]
hostenv["host env
GH_TOKEN ANTHROPIC_API_KEY"]
secrets["/run/secrets"]
ipc["VS Code IPC sockets
/tmp"]
x11["X11 / /run/user"]
end
shadow["/usr/local/bin/claude
(shadow, first on PATH)"]
real["/usr/libexec/claude-sandbox/claude
(real binary, off-PATH)"]
end
subgraph jail["bwrap jail in the holder netns (IS_SANDBOX=1)"]
direction TB
ro["--ro-bind / /
--tmpfs $HOME"]
ws["workspace $PWD (rw)
deliberate exposure"]
net["egress jail (default)
internet + DNS + forges + allow-ip
RFC1918 blackholed"]
end
user --> shadow
shadow -->|holder netns + pasta attach,
then bwrap wrapped in script pty| jail
real -->|exec'd in-jail as
~/.local/bin/claude| jail
creds -.->|excluded| jail
```
The shadow never lets the real binary run unwrapped from a normal shell: because
the real binary is relocated off `$HOME/.local/bin`, plain `claude` cannot
resolve past the shadow. (Anthropic's installer drops the binary at
`~/.local/bin/claude` and prepends that dir to your shell rc; relocating the
binary makes that rc-mutation inert.) The dashed line shows the credential set
being *excluded* from the jail, not bound into it.
The `bwrap` jail nests inside the holder network namespace: the shadow forks an
`unshare -rn` holder, `pasta` attaches to it from outside by PID and locks the
routing allowlist, and `bwrap` inherits that netns (it keeps omitting
`--unshare-net`). Setting `CLAUDE_SANDBOX_EGRESS_JAIL=0` skips the holder and
restores ADR 0005's shared-host-netns world. See
{ref}`adr-network-egress-jail` and
[Configure the network egress jail](../how-to/network-egress-jail.md).
## 2. Launch sequence
A plain `claude` triggers the shadow, which does three host-side reads
(read the `/etc` config, regenerate the gitconfig from your live git identity,
build the argv). By default it then sets up the egress jail (`netns_launch`): it
forks an `unshare -rn` holder that owns a fresh user+network namespace, attaches
`pasta` from outside by PID, and has the holder lock the routing allowlist
before exec-ing `bwrap` (wrapped in `script(1)`) — which inherits the holder's
netns. With `CLAUDE_SANDBOX_EGRESS_JAIL=0` it skips the holder and execs `bwrap`
wrapped in `script(1)` directly (ADR 0005's open-egress world). The `script(1)`
wrap allocates a fresh pseudo-terminal — that is the TIOCSTI defence: an `ioctl`
inside the sandbox lands in `script`'s pty, which reads it back as bytes, not
keystrokes, to the host terminal.
A nested `claude` invocation (a hook or skill spawning `claude` *inside* the
jail, where `IS_SANDBOX=1` is already set) must not re-wrap. The recursion guard
at the top of the shadow handles this: it execs the real binary directly.
```{mermaid}
sequenceDiagram
autonumber
participant U as shell
participant S as shadow
/usr/local/bin/claude
participant FS as host /etc
participant H as holder
unshare -rn (netns)
participant P as pasta
(attaches by PID)
participant B as bwrap + script(1)
participant R as real claude
~/.local/bin/claude (in jail)
U->>S: claude [args]
alt IS_SANDBOX=1 (nested call inside jail)
S->>R: exec ~/.local/bin/claude --no-chrome (no re-wrap)
else normal launch from host shell
S->>S: touch ~/.claude.json (bind-back target)
S->>FS: parse_config /etc/claude-sandbox.conf
S->>FS: regenerate /etc/claude-gitconfig
from host user.name / user.email
S->>S: resolve_workspace_root ($PWD or override)
S->>S: bwrap_argv_build(argv, workspace, real, args)
alt egress jail enabled (default) — netns_launch
S->>H: fork unshare -rn holder
S->>P: pasta attach to holder by PID
H->>H: netns_holder locks routing allowlist
(RFC1918 blackholed, gw/DNS/allow-ip punched back)
H->>B: exec script -q -c [bwrap argv] /dev/null
(inherits holder netns)
else CLAUDE_SANDBOX_EGRESS_JAIL=0 — open egress
S->>B: exec script -q -c [bwrap argv] /dev/null
end
B->>R: exec --no-chrome [args] with IS_SANDBOX=1
end
```
The gitconfig is regenerated on *every* launch, not just at install: VS Code's
`copyGitConfig` runs after `postCreate`, so an install-time render could capture
an empty `user.name`. Re-rendering at launch means a host gitconfig edit takes
effect on the next `claude` with nothing to re-run. The shadow also injects
`--no-chrome` and strips any user `--chrome` on both paths, so Claude Code never
writes its browser-extension native-messaging manifest.
## 3. Filesystem inversion
This is the core security idea. The sandbox does not enumerate what to hide; it
hides everything by default and enumerates the short list of what to *show*. The
base is `--ro-bind / /`. On top of that, `$HOME`, `/tmp`, and (when the host has
them) `/run/user` and `/run/secrets` become `tmpfs` — wiped, empty. Then a
single bind-back list restores exactly what Claude legitimately needs under
`$HOME`, split by XDG category.
```{mermaid}
graph TD
base["--ro-bind / /
read-only base"]
base --> tmpfs
subgraph tmpfs["tmpfs masks (wiped)"]
h["$HOME"]
t["/tmp"]
ru["/run/user *"]
rs["/run/secrets *"]
end
h --> bindback
subgraph bindback["bind-back under $HOME (allow-list)"]
direction TB
claude[".claude · .claude.json"]
cache[".cache"]
forge[".config/gh · .config/glab-cli"]
share[".local/share
helm · krew · uv"]
uv[".local/bin/uv · uvx"]
realbin["real claude"]
end
share --> masked
subgraph masked["re-masked (ephemeral)"]
apps[".local/share/applications"]
ccache[".local/share/claude"]
end
subgraph nullmask["bound to /dev/null"]
netrc[".netrc
.Xauthority
.ICEauthority"]
end
h -.-> nullmask
```
`*` `/run/user` and `/run/secrets` masks are emitted only when the host has the
source directory — `bwrap` cannot `mkdir` into a read-only `/run` that lacks the
subdir (the GitHub Actions `ubuntu-24.04` runner case).
The split is the subtle part. `$HOME/.config/` stays a **strict allow-list**
(`gh`, `glab-cli`, nothing else) because by XDG contract credentials live there
— a new credentialed tool dropping files under `.config//` is masked for
free. `$HOME/.local/share/` and `.cache/` go the other way and are **bulk-bound**
so host-installed plugin and data trees just work; two sub-dirs Claude Code
writes itself (`applications/` for a `.desktop` URL handler, `claude/` for its
versioned binary cache) are re-masked with `tmpfs` so those writes stay
ephemeral. The `.netrc` / `.Xauthority` / `.ICEauthority` masks are
belt-and-braces: `--tmpfs $HOME` already hides them, but binding `/dev/null`
over them survives if that baseline ever regresses. The rationale for the XDG
polarity split is in the [threat model](threat-model.md); the exact bind list is
in the [reference](../explanations/sandbox-internals.md).
## 4. Launch isolation and updates
The wrapper establishes isolation before launching an agent. Vendor binaries
live off PATH, and updater settings keep their installers from replacing the
wrapper. Direct vendor-binary invocations are outside this launch path.
See [launch isolation](launch-isolation.md) and
[explicit verification](../how-to/verify-the-sandbox.md).
## 5. Config trust flow
The sandbox config (`workspace-root`, `no-forge`, `allow-write`, `pass-env`,
`egress-jail`, `allow-ip`) lives outside the writable workspace. `install.sh` seeds `/etc` with the
shipped defaults from the package or clone; you edit
`/etc/claude-sandbox.conf` directly (an unsandboxed root shell — your
container terminal); the shadow reads it from `/etc` at launch — never
from `$PWD`. Like `allow-write`, the `allow-ip` device allowlist is read
only from `/etc`, so a session cannot widen its own network reach.
```{mermaid}
graph LR
clone[".devcontainer/claude-sandbox.conf
(shipped defaults in the clone)"]
etc["/etc/claude-sandbox.conf
(outside the rw workspace — you edit here, as root)"]
shadow["shadow at launch
parse_config()"]
argv["bwrap argv + netns routes
WORKSPACE_ROOT, ALLOW_WRITE, NO_FORGE,
EGRESS_JAIL, ALLOW_IP"]
clone -->|install.sh install_conf
seeds defaults at install| etc
etc -->|read at launch| shadow
shadow --> argv
pwd["$PWD (rw workspace)
compromised session can write here"]
pwd -. NOT read for config .-> shadow
```
This closes a cross-session bind-escalation vector. If the shadow read its
config from the workspace, a compromised session could append
`allow-write = /some/sensitive/path` and the *next* launch would widen its own
binds. Reading exclusively from `/etc` — which the in-jail Claude sees only
read-only — means a session can never escalate the binds of a future session.
The config keys themselves are documented in
[configuration](../reference/configuration.md).
## 6. One auditable home; consumers reference it, never embed it
Every way of consuming the sandbox runs the **same** `install.sh` from
the **same** repo — the machinery is never copied into consuming
projects:
- **The PyPI host launcher (recommended)** — `uv tool install claude-sandbox`,
then `claude-sandbox`, starts the published image at the package's version.
- **The PyPI wheel** (guest) — `uvx claude-sandbox install` inside any
devcontainer (or a clone + `./install` without `uv`); a team wires the
same line into their project's `postCreate` at a pinned version
([Sandbox a team devcontainer](../how-to/sandbox-a-team-devcontainer.md)).
- **The published container image** — the image build sources
`install.sh` through the same seam.
- **This repo's own devcontainer** — `postCreate` runs the installer for
development on the sandbox itself.
An earlier mechanism, `just promote`, copied the install machinery *by
value* into target workspaces. It was removed ({ref}`adr-remove-promote`,
superseding {ref}`adr-promote-by-value`): frozen per-project copies of
security-critical code have no update channel, and proliferating them
defeats the single-auditable-repo principle the repo was extracted for
({ref}`adr-standalone-repo`). Wiring a target's `devcontainer.json` stays
manual either way — it is JSONC in the wild, and only you know whether a
`postCreateCommand` needs combining with an existing one.
## Where the code lives
| Concern | File |
|---|---|
| Shadow + inlined `bwrap` argv builder, recursion guard, gitconfig render, `script(1)` wrap, egress-jail orchestration (`egress_jail_enabled` / `netns_launch` / `netns_holder`) | `.devcontainer/claude-sandbox/claude-shadow` |
| Relocate real binary off-PATH; wire shadow; disable auto-updater; place `/etc` config | `.devcontainer/claude-sandbox/install.sh` |
| Integrity-battery spec (21 checks + 10 adversarial probes) | `skills/verify-sandbox/SKILL.md` |
| Tests CI runs (argv builder, smoke) | `tests/bwrap_argv.sh`, `tests/smoke.sh` |
### See also
- [Threat model](threat-model.md) — what is locked down, what is deliberately exposed, and why.
- [Launch isolation](launch-isolation.md) — wrapper entry points and updater controls.
- [Sandbox internals](../explanations/sandbox-internals.md) — the exact `bwrap` flags and bind list.
- [Configuration](../reference/configuration.md) — `/etc/claude-sandbox.conf` keys and env-var overrides.
- [Configure the network egress jail](../how-to/network-egress-jail.md) — turn the lateral-isolation jail on/off and allow-list device IPs.
- The four sections: [tutorials](../tutorials.md), [how-to](../how-to.md), [reference](../reference.md), [explanations](../explanations.md).