~/satyajit

Claude Code's sandbox from the source: the bwrap argv, the seccomp program, and the proxy

mdjsonmcp

2026-10-26 · 14 min · agents · security · agentic-coding · developer-tools · performance

A 1:48 narrated explainer, drawn in code. Every number and picture in it is this article's own; the sources are below.

› transcript

Hi, I'm Gimlet! Today, how the sandbox behind Claude Code's shell boxes a command. The whole Linux sandbox is one command line. Mounts decide every file. The network is empty, except one socket to a proxy. First, the whole host disk is mounted read only. Reads are open; writes are refused. The project folder is bound back on top, writable. Writes land only here. Then the files that run code later, shell startup files and git hooks, are frozen on top. A secret folder gets an empty mount over it. Its files are not hidden. They are gone. The network is removed; a helper runs the command one level down. A relay starts inside the box, the only path to the proxy. It waits until the relay is listening, so the command never races it. A helper nests fresh namespaces, so the command cannot see the processes around it. A kernel filter blocks local sockets, so the command cannot slip past the network fence. A second filter refuses unsharing and mounting, so the command cannot undo its denials. Then it runs. A write outside the project and a denied host both fail; a write to the project is allowed. Every connection leaves through a socket to the proxy, which allows a known host and asks about a new one. On my machine, the sandbox added about eighty six milliseconds a command, against six without it. You can read the whole boundary in one command line: mounts for the files, one socket for the network. Mounts for the files. One socket for the network. A filter for the side doors. Every source is in the full article. I'm Gimlet. Bye!

Why read this

Essentialtop 10%

Read srt 0.0.81 and ran the Linux path: the real bwrap argv annotated, both seccomp filters, the proxy mux, per-stage overhead, against the code.

  • Checked against the source
  • Interactive explanations
  • Runs on a laptop CPU

Security & safetyApache-2.0Practitioner teardown

How this was scored
Is it new?
1 of 3: An incremental tweak
Can I trust it?
3 of 3: Reproduces the headline result, or shows from primary files it is wrong
Can I run it?
2 of 3: Open code or weights with real limits
Will I understand it?
3 of 3: Mechanism carried by interactives built from real code or data
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
2 of 3: A reference for a year or more
Does it affect many?
2 of 3: A widely used model, tool or lab release
Only here?
2 of 3: A teardown or measurement few others did

Score 76 of 100, ranked 50 of 613 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

I already wrote up @anthropic-ai/sandbox-runtime once, from the outside: what srt does when you run it — a denied read becomes an empty directory, a write outside the project gets EROFS, a non-allowlisted host gets a 403, and each command pays roughly 80-110 ms. That piece measured the behaviour. This one opens the hood. I read the whole backend at commit 98a4016 (package version 0.0.81, Apache-2.0) and ran the Linux path again, this time to dump and annotate the exact artifacts the code generates: the bwrap command line, the seccomp BPF program, the Seatbelt profile. If you only want to know what the sandbox does, read the first article. This is how it is built.

anthropics/sandbox-runtime@c66fb77 · snapshot 2026-10-24
tracked files
378
license
Apache-2.0
branch
main
tests
264 files
source
4.0 MB
commit date
2026-10-23
source by language
TypeScript4.0 MB(189)JavaScript26.3 kB(3)Java2.6 kB(1)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-10-24 at c66fb77 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, testFileCount

The backend is not small. wc -l across src/sandbox/ gives 3,937 lines for linux-sandbox-utils.ts alone, 3,581 for sandbox-manager.ts, 1,725 for macOS and 4,610 for the alpha Windows backend (measured). But the Linux design fits in one command line, so let us start there.

The whole box is a bwrap command line

On Linux, srt does not run a daemon or a container. It turns your policy into a single bwrap invocation and execs it. I captured the real string through SandboxManager.wrapWithSandbox for a sample policy — allow example.com, deny-read a secret/ directory, allow-write a project/ directory — on a shared 4-core cloud VM (Ubuntu 24.04, kernel 6.18, bwrap 0.9.0, as root). I abbreviated the scratch path to $S and the proxy socket id; everything else is verbatim. Click any flag:

Read it as three concerns stacked into one argv. The filesystem is a mount table and nothing else: --ro-bind / / makes the whole host tree readable, --bind $S/run/project re-mounts the one writable root on top, and then every denied thing is a mount that masks it. The network is a subtraction: --unshare-net leaves the namespace with only lo, and the single hole is a Unix socket bind-mounted in. The command runs one level deeper, after a wrapper starts the bridge and a helper installs seccomp.

There are no path checks in user space. The kernel's mount table is the policy, which is why the mandatory denies matter so much: each one is a real mount, decided once when the command is wrapped.

The deny mounts, and why each file

A denyRead directory gets --tmpfs — an empty filesystem mounted over it, so the files are not hidden, they are not there (and the tmpfs is writable, but its contents evaporate with it). A denyWrite path, and every mandatory deny, gets a read-only bind over it: --ro-bind /dev/null <file> for a file, an empty read-only directory for a directory. For my one-git-project sample, the wrap produced eleven /dev/null placeholders and one empty-directory mask (.claude) — twelve caps — plus the secret/ tmpfs and read-only re-binds of .git/hooks and .git/config (measured from the dumped argv).

The mandatory-deny list, from generateFilesystemArgs in linux-sandbox-utils.ts, is write-denied even inside an allowed directory:

.bashrc .bash_profile .zshrc .zprofile .profile   # shells source these at startup
.gitconfig .gitmodules                            # git runs commands from these
.ripgreprc .mcp.json                              # tool config that executes/connects
.vscode/ .idea/                                   # editor tasks run outside the sandbox
.claude/commands/ .claude/agents/                 # Claude Code config that runs later
.git/hooks/ .git/config                           # git executes these on commit/checkout

The common thread is: every one of these files runs code later, outside the sandbox. A shell rc file runs on the next login; a git hook runs on the next commit; .mcp.json names servers Claude Code will spawn with full access. Writing them is how a sandboxed command would escape into an unsandboxed context, so they are closed regardless of what allowWrite opens. A ripgrep scan, three levels deep by default (mandatoryDenySearchDepth), finds them in subdirectories too; .gitignore does not hide anything from it.

The precedence is asymmetric, and the resolver below is a port of the real rules — reads open by default and carved by denyRead, writes closed by default and opened by allowWrite but closed again by the mandatory denies, and the five-step network decision from shouldAllowConnection:

The network is a missing namespace plus one socket

--unshare-net is the fence. Inside, there is no interface but lo, no route, no resolver. I confirmed it from the first article: /proc/net/dev lists only lo with zero bytes. A program that does not honour HTTP_PROXY does not sneak around this — it simply cannot connect.

The one way out is a filesystem object. The host proxy listens on a Unix socket; bwrap bind-mounts that socket into the sandbox; inside, the wrapper starts two socat listeners on localhost:3128 (HTTP) and localhost:1080 (SOCKS) that forward to it. In 0.0.81 a single mux socket serves both: getLinuxHttpSocketPath() and getLinuxSocksSocketPath() returned the same path on my run, and bwrap rejects a duplicate --bind, so it is bound once. The mux peeks at the first byte and routes 0x04/0x05 to the SOCKS handler and everything else to HTTP.

The wrapper is careful about ordering. Before it hands off to the command, it polls /proc/net/sockstat until two TCP sockets are listening (up to 30 s), so a command never races its own network bridge. You can see the whole bash -c payload — the two socat background jobs, the wait loop, the trap that kills them on exit — in the last token of the argv explorer above.

Every outbound connection is decided host-side by five ordered checks, and after an allow the proxy resolves the name once and drops any address that is loopback, link-local, a cloud-metadata endpoint, one of this host's own interfaces, or in deniedResolvedAddresses — so an allowed name whose DNS you do not control cannot be pointed at 127.0.0.1. Private ranges such as 10.0.0.0/8 are not denied by default; you list them.

apply-seccomp: two filters and an observer

--unshare-net stops IP traffic, but socket(AF_UNIX, …) would let a command talk to local services, and socket(AF_VSOCK, …) could reach a hypervisor past the net namespace entirely. The mount denies stop writes, but a command that could make its own user namespace would get a private copy of the mount tree and rearrange it. Both holes are closed by apply-seccomp, a static binary (shipped prebuilt for x64 and arm64) that the wrapper runs last.

It does three things, visible in vendor/seccomp-src/apply-seccomp.c and the BPF generator seccomp-unix-block.c:

The process layout is the point. apply-seccomp nests a second PID + mount + user namespace below bwrap's, acts as a non-dumpable PID 1 there, then forks the worker and applies the filters. The header comment lays out the tree: bwrap init, then bash/socat in bwrap's PID namespace, then apply-seccomp as the inner init, then the user command as inner PID 2.

How violations come back

A block is only useful if the model learns what was blocked, or it just tries another way. I turned the monitor on (the third argument of SandboxManager.initialize), wrapped a command that writes outside the project and calls a non-allowlisted host, and called annotateStderrWithSandboxFailures on its stderr. The appended block (measured):

<sandbox_violations>
deny openat $S/run/outside/x.txt
deny network-outbound example.org:443 (host is not on the allow list)
</sandbox_violations>

That is what a model reads. The filesystem line comes from the seccomp observer; the network line comes from the proxy, which decides and so can give a reason. deniedDomainReasons lets you write that reason yourself — "SSH pushes to GitHub are blocked; use an https:// remote" — so the model learns the sanctioned alternative, not just the refusal. On a plain-HTTP request the proxy answers with a real 403 whose body is Connection blocked by network allowlist and an X-Proxy-Error: blocked-by-allowlist header.

macOS: a Seatbelt profile instead of a namespace

There is no namespace to build on macOS; srt writes a Seatbelt profile and runs sandbox-exec -p <profile>. I read this from macos-sandbox-utils.ts — I have no Mac to run it on. The profile opens with:

(deny default (with message "CMD64_<base64-of-command>_END_<random-suffix>"))

That log tag is how violations come back on macOS: srt runs log stream filtered on the suffix, decodes the command from each Sandbox: … deny … line, and files it. From there the profile adds back only what a shell needs — (allow process-exec), (allow process-fork), (allow process-info* (target same-sandbox)), a named list of Mach services with no wildcard (com.apple.logd, com.apple.fonts, launchservices, …), some IOKit and sysctl reads. Reads are (allow file-read*) then the denyRead subpaths then the allowRead re-allows; writes allow the allowWrite roots and deny the mandatory paths as globs.

The network section is the macOS equivalent of the missing namespace. With restrictions on, outbound is allowed only to the two proxy ports:

(allow network-outbound (remote ip "localhost:35343"))   ; the host HTTP/mux proxy

and nothing else. The code notes a real subtlety: it uses (remote ip "localhost:*"), not (local ip …), because a (local ip …) filter on network-outbound is evaluated against the source address, which is INADDR_ANY for an unbound socket, and Seatbelt's localhost matches the any-address — so a local filter would admit every outbound connection. Two opt-ins open real holes, and the README says so: enableWeakerNetworkIsolation re-allows com.apple.trustd.agent for Go TLS ("a potential data exfiltration vector"), and allowAppleEvents lets a command open or osascript other apps, which run outside the sandbox — "removes code-execution isolation, not just weakens it."

How Claude Code wires it in

This part is from the Claude Code docs, not its source, which I did not read (reported). The docs say Claude Code "builds the sandbox on the open source @anthropic-ai/sandbox-runtime package", and wraps the Bash tool only. Read, Edit, Write and WebFetch follow permission rules; hooks, MCP servers, LSP servers and the status-line command run "with your full access". The default read scope is "most of the machine, including credential files such as ~/.ssh and ~/.aws/credentials" — which is tolerable only because the network fence is there. If you widen the network, narrow the reads.

Diagram in three columns: the user, Claude Code and the sandbox. A bash command runs inside the sandbox; a write to ~/.bad_file and HTTPS to a denied host are denied, a write to ./README.md is allowed. Network calls leave the sandbox through socat to a domain socket and on to the SOCKS or HTTP proxy inside Claude Code; allowed hosts pass, denied hosts are refused, and unknown hosts on port 80 or 443 trigger a permission prompt.
Claude Code's sandbox: socat and a domain socket are the only way out, the proxies decide per host, and an unknown host becomes a permission prompt. (Anthropic engineering blog, 'Beyond permission prompts', also in the sandbox-runtime README.)

With autoAllowBashIfSandboxed (default true) a sandboxed command is approved without a prompt — the mechanical form of the blog's "84% fewer permission prompts" claim (reported; I cannot verify it). The escape hatch is dangerouslyDisableSandbox: when a command fails sandboxed, Claude can retry it unsandboxed, gated by your permission mode. failIfUnavailable: true stops Claude Code from silently running unsandboxed when bwrap is missing — which, by default, the docs say it will.

Claude Code on the web takes the heavier route: the whole session runs in a gVisor sandbox with no git credentials inside, and a git proxy holds the token and checks the branch and destination before attaching it.

Three panels: a gVisor sandbox containing Claude Code where git add is auto-allowed and git commit/push send requests carrying a JWT; a git proxy that validates the JWT, the branch name and the repo destination; and github.com, which receives the push only after those checks.
Claude Code on the web keeps credentials out of the sandbox: the in-sandbox git client holds a scoped JWT, and the proxy attaches the real token only after checking branch and destination. (Anthropic engineering blog, 'Beyond permission prompts'.)

What each stage costs

Every figure below is from the same shared VM (4 cores, CPU only), measured through the library harness; the load average was around 8-10 while they ran, so treat them as upper bounds. Medians over 20-25 runs:

StageMedian
bare bash -c true, from a shell loop3.9 ms
bash -c true through the Node execSync harness6.2 ms
SandboxManager.initialize (proxies + bridge), once per session26.8 ms
wrapWithSandbox("true"): build the argv + ripgrep scan27.9 ms
run the wrapped true, proxies already up58.0 ms
whole srt -c true CLI: Node start, init, wrap, run447.9 ms

For an embedder like Claude Code, which initialises once per session, the per-command price is the wrap plus the wrapped run: about 86 ms here against 6 ms unwrapped (reasoned, from the rows above). Part of that is fixed by design — the wrapper waits for both socat listeners before starting your command. For a command that takes seconds, it is noise; for a loop of a thousand greps, wrap once or batch them.

Where the design is thin

The honest edges are the same ones the README lists, now with the mechanism attached:

This is a design that chose OS primitives over a VM, and the choice shows in both directions. Compare NVX, which builds a micro-VM from nothing, and OpenShell, which puts Landlock and a seccomp supervisor between the agent and every connection; Strands Box uses the same macOS Seatbelt primitive with a policy language on top, and Octop's bubblewrap backend is a mount-namespace jail with no network fence. The isometric style of the cutaway above is borrowed from the Anatomy machine skill. srt is the lightest of these: nothing is started, the command runs on your kernel, and you pay about 86 ms a command for a boundary you can read in one argv.

What I ran, and what I did not

I ran the Linux path end to end on one VM: dumped and annotated the real bwrap argv, reproduced the reads/writes/network denials from the first article, captured a live violation block, and measured the per-stage overhead above. I read — but did not run — the macOS Seatbelt generator, the TLS-terminating proxy, credential masking, the alpha Windows backend, and Claude Code's own wiring. The 84% prompt-reduction figure is Anthropic's; I have no way to check it. Everything labelled measured came off this machine; treat the timings as upper bounds on a busy host.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Claude Code's sandbox from the source: the bwrap argv, the seccomp program, and the proxy", ai.thesatyajit.com, October 2026.

bibtex
@misc{ghana2026claudecodesandboxinternals,
  author = {Satyajit Ghana},
  title  = {Claude Code's sandbox from the source: the bwrap argv, the seccomp program, and the proxy},
  url    = {https://ai.thesatyajit.com/articles/claude-code-sandbox-internals},
  year   = {2026}
}
share