Max Narditdata & ai systems
← Articles
11 mindev

Claude Code sandbox on Linux: what srt walls off, and what bypassPermissions opens

The built-in sandbox is a real OS boundary around shell commands, and only around them. How strong it is depends on two things the setup screen never shows: your permission mode, and what existed when the command started. A test on Ubuntu of what srt blocks and leaves open, the Linux bug that still breaks it, and when a container or VM fits better.

Contents

Anthropic’s sandbox-runtime is not new. The repository was created on 20 October 2025, and version 0.0.81 shipped on 20 October 2026. It is the engine under Claude Code’s built-in sandbox, and the two get talked about as one thing with one boundary. They are not, and the boundary is less fixed than the word suggests.

I ran both on an Ubuntu 24.04 machine: srt 0.0.81 by hand, and Claude Code 2.1.310 headless with the sandbox switched on. Two results from those runs shape this piece. In bypassPermissions mode, the network half of the sandbox let an unlisted host straight through until I set one more key. And on Linux, the list of protected paths is taken when a command starts, so a repository created inside that command is unprotected until it ends. Both say the same thing: the sandbox you get depends on conditions the /sandbox panel does not display, so the only check I trust is a probe run under your real mode.

What does the Claude Code sandbox actually wall off?

Shell commands and the processes they start, on two sides: files and network. Per the sandbox docs, a sandboxed command can write to the working directory, a per-user temp directory and directories you added, and nowhere else. It has no direct route to the network; connections go through a proxy on your machine that checks each hostname against an allowlist that starts empty. The sandbox is off by default. /sandbox turns it on for a project, sandbox.enabled in ~/.claude/settings.json for all of them.

What it leaves open by default matters as much. Reads are allowed across most of the machine, ~/.ssh and ~/.aws/credentials included. Environment variables are inherited from Claude Code, secrets and all. Both can be narrowed with filesystem.denyRead and the credentials block, and only what you list is covered: the docs say there is no built-in credential deny list.

Then there is everything the sandbox never sees. Read, Edit, Write, WebFetch and WebSearch follow permission rules instead, so a denyRead entry does not stop the Read tool and allowedDomains does not limit WebFetch. Hooks, local MCP servers, LSP servers, plugin monitors, the status line command and apiKeyHelper run with your full access. So does anything a mod starts.

How is Claude Code’s sandbox different from sandbox-runtime (srt)?

Same engine, different scope. Claude Code builds its sandbox on the @anthropic-ai/sandbox-runtime package and wraps each Bash, PowerShell and Monitor command in a fresh one. The package also ships a CLI, srt, that wraps any process: one command, a local MCP server, or Claude Code itself, in which case file tools, hooks and MCP servers end up inside the boundary too. The README still calls it a beta research preview whose configuration format may change.

On Linux the boundary is bubblewrap. The sandboxed process gets read-only binds over everything it may not write, and a network namespace with no route out. Two proxies run on the host, one for HTTP and one SOCKS5, and reach the sandbox over Unix sockets that socat bridges in. A bundled seccomp helper blocks creating new Unix sockets, and blocks the system calls that would let a command create a user namespace of its own, since inside one it could unmount the read-only binds. On macOS the same policy is a generated Seatbelt profile. On native Windows, srt has an alpha that runs commands under a dedicated local account; Claude Code itself runs commands unsandboxed there unless you move it into WSL2.

What does bypassPermissions do to the Claude Code sandbox network wall?

It answers the network question for you, and the answer is yes. When a sandboxed command connects to a host outside the allowlist, Claude Code lets your permission mode decide. Manual and acceptEdits prompt you, dontAsk refuses, and auto mode refuses unless Claude listed the host on the command beforehand and the classifier approved the list. In bypassPermissions, per the docs, the connection is allowed without a prompt. I ran the same curl to example.com in four headless sessions with the sandbox on, the unsandboxed retry off and no allowed domains at all:

Permission modestrictAllowlistResult
defaultoffblocked: deny network-outbound example.com:443 (user denied)
defaultonblocked: host is not on the allow list
bypassPermissionsoff200
bypassPermissionsonblocked: host is not on the allow list

The first row has a caveat. A headless claude -p session has nobody to answer a prompt, so “user denied” is the prompt failing closed; in an interactive session you would have been asked. I did not run auto mode.

The third row is the one I came for. In the same mode, touch ~/sandbox-probe still failed with Read-only file system, so with the retry off the filesystem half held while the network half let an unlisted host through. With the retry left at its default, the docs add that a failed command’s unsandboxed retry also runs in bypassPermissions without a prompt, and then neither half holds for that command. strictAllowlist (v2.1.219 and later) is the key that closes the network side: it refuses unlisted hosts in every mode, and it has no effect when set in a repository’s .claude/settings.json. Two more keys harden the rest:

json
{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false,
    "network": { "strictAllowlist": true, "allowedDomains": ["github.com", "registry.npmjs.org"] }
  }
}

allowUnsandboxedCommands: false makes Claude Code ignore the retry. failIfUnavailable stops it at startup when the sandbox cannot run, instead of the default, which is to run commands without one. Where you put this matters. Passed with --settings or delivered as managed settings, turning the retry off makes the sandbox admin-required, and Claude Code then ignores a repository’s excludedCommands and other loosening entries. In plain user settings it does not, and a repository’s excludedCommands can still take matching commands out of the sandbox, so check what is excluded either way.

Even locked down, the allowlist works on hostnames and does not inspect TLS. The docs warn that a broad entry such as github.com can be an exfiltration path and that domain fronting may reach hosts outside the list. Every domain you add is a channel, which is the inventory problem from every way out of the sandbox, shrunk to one machine.

How do you run sandbox-runtime (srt) on Linux?

You need bubblewrap, socat and ripgrep, Node for the package, and a settings file. Check the kernel side first:

shell
bwrap --version
sysctl kernel.apparmor_restrict_unprivileged_userns

If the sysctl prints 1, the default AppArmor policy on Ubuntu 24.04 and later, bubblewrap gets user namespaces without the capabilities it needs. The sandbox docs give an AppArmor profile for /usr/bin/bwrap; the README gives the blunter fix of setting the sysctl to 0. On the test machine it already read 0.

With root, the dependencies are one line each:

shell
sudo apt-get install bubblewrap socat ripgrep
npm install -g @anthropic-ai/sandbox-runtime

The test machine had bubblewrap but neither socat nor ripgrep, which the standalone runtime looks for on PATH. Without root you can unpack the Debian packages into a directory you own. Keep that directory and the package outside every path you will allow writes to. The README warns that a copy in a writable node_modules can be rewritten from inside the sandbox and then run unsandboxed next time, and the same goes for a writable rg or socat on the path.

shell
mkdir -p ~/opt/srt-deps && cd ~/opt/srt-deps
apt-get download socat ripgrep
for f in *.deb; do dpkg-deb -x "$f" root; done
npm install --prefix ~/opt/srt @anthropic-ai/sandbox-runtime@0.0.81
export PATH="$HOME/opt/srt-deps/root/usr/bin:$HOME/opt/srt/node_modules/.bin:$PATH"

Then work from a separate scratch directory, which is what "allowWrite": ["."] will open, and keep the settings there or in ~/.srt-settings.json. This is the file I used for the network and credential tests:

shell
mkdir -p ~/srt-lab && cd ~/srt-lab && git init -q
cat > srt-test.json <<'EOF'
{
  "network": { "allowedDomains": ["example.com"], "deniedDomains": [] },
  "filesystem": { "denyRead": ["~/.ssh"], "allowWrite": ["."], "denyWrite": [] },
  "credentials": { "envVars": [{ "name": "DEMO_TOKEN", "mode": "deny" }] }
}
EOF

One trap from the README’s own examples. srt "curl example.com" with the command in quotes failed for me with command not found, because 0.0.81 took the whole string as the program name. A command string goes after -c:

shell
srt --settings ./srt-test.json -c 'curl -sS -o /dev/null -w "%{http_code}\n" https://example.com'

What did sandbox-runtime block on Ubuntu, and what did it let through?

Every write and connection I aimed outside the policy failed, and the defaults let through exactly what the docs say they let through. Each line is one srt -c run from the scratch directory:

ProbeResult under srt 0.0.81
touch ~/sandbox-probeRead-only file system
write a file in the working directoryallowed
append to .bashrc in the working directoryPermission denied
write .git/hooks/pre-commitRead-only file system
write to /tmpRead-only file system
write to $TMPDIR (/tmp/claude)No such file or directory until the directory existed on the host, then allowed
list ~/.ssh with no denyReadallowed, 15 entries
list ~/.ssh with denyReadallowed, 0 entries
curl a host with an empty allowlistCONNECT tunnel failed, response 403
curl an allowed host200
curl --noproxy '*' to an allowed hostCould not resolve host
send a UDP packet to 1.1.1.1:53Network is unreachable
curl a server on the host’s 127.0.0.1Couldn't connect to server
start a server inside the sandbox and curl it200
connect to the D-Bus system socketOperation not permitted
unshare -U trueOperation not permitted
DEMO_TOKEN (listed) and OTHER_TOKEN (not listed)DEMO_TOKEN unset, OTHER_TOKEN visible

A read-denied directory does not fail to open, it appears empty: bubblewrap mounts an empty tmpfs over it, so a tool looking for a key file finds none rather than an error. The token row is the credentials rule in miniature: whatever you forget to list is not withheld. The temp-directory row matches a line in the environments page: on Linux, write grants apply only to paths that already exist.

The localhost pair is the Linux behavior the docs describe. The sandbox has its own loopback, so a direct connection to a dev server or a database on the host fails, and allowLocalBinding does nothing on Linux. Through the proxy, a host service is reachable only if you put it on the allowlist, as localhost or as an address and port such as 127.0.0.1:3000; any other hostname that resolves to a loopback address is refused. Inside the sandbox, env also showed the plumbing: HTTP_PROXY, ALL_PROXY and their relatives, a GIT_SSH_COMMAND that tunnels git’s SSH through the proxy, and a JAVA_TOOL_OPTIONS agent for JVM tools that ignore proxy variables.

Where does the sandbox-runtime deny list miss on Linux?

On protected paths that appear inside a running sandbox. The environments page says it directly: on Linux the runtime builds its deny list once at launch, covers the project root, scans shallowly for nested copies that already exist, and does not cover what the session creates later. One run shows it:

shell
srt --settings ./srt-test.json -c 'git init -q sub && echo "echo hi" > sub/.git/hooks/pre-commit && echo wrote-hook'
# wrote-hook
srt --settings ./srt-test.json -c 'echo "echo hi" > sub/.git/hooks/post-commit'
# /usr/bin/bash: line 1: sub/.git/hooks/post-commit: Read-only file system

The repository created inside the first command took a hook. The next srt run built a fresh deny list, found the repository and protected it. A .bashrc in a subdirectory made inside one command went through the same way. On macOS the denies are checked at each write, so this gap is Linux-only. How big it is depends on what the sandbox wraps. Claude Code’s built-in sandbox starts a new one per command, so the window is one command long. srt claude wraps the whole session in one sandbox, so a repository the session clones or scaffolds stays outside the list until you restart.

The same mechanism leaves something behind. To protect a file that does not exist yet, the Linux sandbox puts a placeholder at its path while the command runs. In an empty directory, a four-second sleep under srt created twelve of them, from .bash_profile and .bashrc to .mcp.json, .vscode and .idea, visible from the host the whole time. A clean exit removed them. After a kill -9 they stayed: empty, read-only, owned by me. The sandbox docs describe the same leftovers at .claude settings paths and point to claude doctor to find them. In a project directory that is clutter. With your home directory as the working directory, an empty ~/.bash_profile is a real change, because a login bash reads it and then skips ~/.profile.

Why does the Claude Code sandbox fail on some Linux machines?

One regression is still open. Issue 43454: since Claude Code 2.1.92 shipped the apply-seccomp helper, every sandboxed Bash command can fail during setup on systems where user namespaces come without capabilities, which includes the default AppArmor policy on newer Ubuntu. The original report:

apply-seccomp: write /proc/self/setgroups (nested userns is capability-restricted; caller must provide CAP_SYS_ADMIN): Permission denied

It is labeled regression and reproduced. A maintainer’s comment from August confirms it on 2.1.233 and lists workarounds: set kernel.apparmor_restrict_unprivileged_userns=0, which weakens system hardening, and note that the documented AppArmor profile covers bubblewrap only, so it may not be enough on its own. Put together with the setup section, that is the trap: on a stock Ubuntu 24.04 or later, the documented fix gets bubblewrap working and can still leave the helper failing. One commenter’s root-cause analysis puts the failure on the helper writing setgroups inside a namespace where bubblewrap has already made that write impossible, and reports it failing with the sysctl at 0. The issue was still open on 24 October 2026. It did not reproduce on my test machine, and I can’t say why from one box, so a clean run here says little about yours.

The other complaints in that area are mostly documented behavior. A command with a variable in front, FOO=bar npm test, is not auto-approved in auto-allow mode; issue 50509 reported it as a bug, and the docs now explain it as intended and give the fix, an allow rule that includes the assignment. docker does not work inside the sandbox. Plain ssh and most database drivers ignore the proxy variables and cannot connect even to an allowed host, while git over SSH works on Linux once the host is allowed. Chromium’s own sandbox and rootless Podman fail because they need user namespaces. Each fix in the troubleshooting section moves a command out of the sandbox or lifts a restriction, and says which.

When is a VM or a container better than the Claude Code sandbox?

When the thing to contain is more than shell commands, which covers any unattended run. The environments page says the Bash sandbox alone is not sufficient for fully unattended work, and that --dangerously-skip-permissions sessions should always run inside a container, a VM or the sandbox runtime. It also calls the auto mode classifier a per-action control rather than an isolation boundary, which is the argument of the approval gate is a classifier: put the unattended limit on the reach the harness grants.

The options trade scope against setup. The built-in sandbox is for attended work with fewer prompts. srt claude puts the whole process inside one bubblewrap boundary without Docker; per the docs it needs write access to the project, ~/.claude, ~/.claude.json and /tmp, network access to api.anthropic.com and, for OAuth, claude.ai and platform.claude.com, plus a denyWrite over the configuration paths those grants expose, or a session can leave hooks and MCP servers that run unsandboxed next time. A dev container, such as the reference one with its default-deny firewall, does the same with Docker, and how strong it is depends on what you mount, which credentials sit inside it and what the firewall allows. A VM adds its own kernel, and the docs recommend one, or a cloud session, for untrusted repositories.

My read on where the line falls: the built-in sandbox is a good wall around commands someone is watching. Once nobody is, the boundary should hold the whole process and its network, and the check that matters most is the one the setup screen never shows, what your permission mode does with a host that is not on the list. Ask Claude to run the probes under the mode you use day to day, with the retry turned off, and decline any offer to rerun them unsandboxed: touch ~/sandbox-probe for the write wall, curl --noproxy '*' to any site for the direct route, and a plain curl to a host you never allowed for the allowlist. Believe their output over the panel.

01 / Discussion

No comment section here — all discussions happen on X.

Max Nardit@mnardit

02 / More articles