Sandboxing Claude Code on Ubuntu

An AI coding agent on my server runs shell commands I approve one at a time. Or, with the sandbox switched on, commands it does not need to ask about at all. That second mode is only as good as the box around it, so before trusting it I wanted to know exactly what that box is: which files a command can read, where it can connect, what it can do as root inside, and what happens when the box itself is missing.

This is the third post in my AI homelab series. The first post built a container by hand and ended with a plan to sandbox Claude Code; the second set up Ansible so that the sandbox could arrive as a role instead of another manual tweak.

This one is Lab 0f. Claude Code’s built-in sandbox on Ubuntu 24.04, enforced by settings that no repository can loosen, deployed by Ansible, and tested with a matrix of probes run from inside the sandbox by the agent itself. Almost everything held. One thing did not, and it cannot be fixed from outside.

What “done” meant

Four checks, taken from the roadmap:

  • A matrix of probes is blocked: writing outside the repository, reaching the network without the proxy, reading ~/.ssh, connecting to a host that is not allowed.
  • The session refuses to start without the sandbox.
  • A repository’s own settings cannot switch the sandbox off or widen it.
  • The whole configuration is an Ansible role, so the next node is one playbook run.

What the sandbox is

Claude Code’s sandbox wraps every shell command the agent runs, and everything that command starts, in two independent layers:

  • Filesystem (bubblewrap on Linux): writes only to the working directory and a per-user temp directory. Reads to almost the whole machine, ~/.ssh included, unless you deny them.
  • Network: the command gets its own network namespace with no route out. The only exit is a proxy Claude Code runs outside the sandbox, which checks every hostname against an allowlist that starts empty.

It is off by default, and on Linux it needs two packages, bubblewrap and socat. Equally important is what it does not wrap: Claude’s own file tools (Read, Edit, Write), web tools, hooks, MCP servers, and anything you type yourself at the ! prompt. Those follow permission rules, not the sandbox.

A pinned Claude Code first

The first surprise came before any sandboxing. Claude Code’s native installer lives in ~/.local/share/claude and updates itself in the background. The session I was working in had moved to a release from that same morning. In a repo where every version is pinned, that is not a decision anyone made.

Anthropic publishes a signed apt repository with stable and latest channels, and it keeps older versions. So the role installs Claude Code like any other package:

  • the signing key, committed to the role after checking its fingerprint against the documentation;
  • the repository as a deb822 .sources file;
  • claude-code=2.1.287-1, the stable release, two weeks old at the time;
  • an apt pin with priority 1001, so a manual apt upgrade does not move it. An upgrade is one changed line in the role.

Before deleting the native installation I looked inside. If you do the same switch, know what you are deleting:

~/.local/share/claude/versions/2.1.292
~/.local/share/claude/versions/2.1.296
~/.local/share/claude/versions/2.1.299     # 739 MB in total
~/.local/bin/claude -> ~/.local/share/claude/versions/2.1.299

Only binaries. Your settings, conversation history, memory and everything else live in ~/.claude/, which the switch does not touch; claude --continue resumes the same conversation on the new binary.

Two small traps along the way. The first --check of the role on a new host always ends with failed=1: check mode reports that it would add the repository but writes nothing, so apt has never heard of claude-code when the install task asks for it. Ansible cannot follow a chain of tasks where a later one depends on what an earlier one would have changed. And after the switch, claude doctor still remembers the old install method and suggests claude install to “repair” it. That would put the self-updating build back in front of the package in PATH. Managed settings now set DISABLE_UPDATES=1, which makes that command refuse.

Letting bwrap through AppArmor

On Ubuntu 24.04 an unprivileged process may create a user namespace, but AppArmor immediately moves it into a profile that denies every capability. bwrap needs a few of them for its own setup:

$ bwrap --ro-bind / / --unshare-all --die-with-parent id
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
apparmor="AUDIT" operation="userns_create" ... profile="unconfined" ... target="unprivileged_userns" execpath="/usr/bin/bwrap"
apparmor="DENIED" operation="capable" profile="unprivileged_userns" ... comm="bwrap" capname="net_admin"

The namespace is created; giving it a loopback interface is not allowed. That loopback matters: sandboxed commands reach the proxy through a local port inside their own namespace.

The documentation’s fix is an AppArmor profile for /usr/bin/bwrap. That would lift the restriction for every program on the host that runs bwrap. I used the same pattern as unshare in Lab 0a instead: a dedicated copy, /usr/local/bin/claude-bwrap, with its own profile, and Claude Code pointed at it through sandbox.bwrapPath, a setting only managed settings can set. The system bwrap stays restricted, and the aa-exec bypass that Lab 0a closed stays closed for the new profile too.

Settings a repository cannot loosen

Claude Code reads settings from several levels: managed (/etc/claude-code/managed-settings.json), the command line, the repository’s .claude/, and your own ~/.claude/settings.json. Managed settings win, and they are root-owned, so neither a cloned repository nor the agent can edit them. The role installs them with these choices:

  • Fail closed. enabled: true, failIfUnavailable: true (refuse to start without a sandbox, instead of the default: run commands without one), and allowUnsandboxedCommands: false (no “retry this outside the sandbox”).
  • Admin-required. That last setting, set in managed settings, makes Claude Code ignore everything in a repository’s settings that would loosen the sandbox: excluded commands, allowed domains, extra writable paths.
  • Four allowed hosts, locked to managed settings, with strictAllowlist: anything else is refused without even asking me.
  • Secrets, twice. ~/.ssh, ~/.aws, every .env under my home directory and Claude Code’s own OAuth token file are hidden from sandboxed commands (sandbox.credentials) and from the file tools (Read(...) deny rules), because the file tools run outside the sandbox.
  • Manual permission mode by default, with sandboxed commands approved automatically: the box is the boundary, and I still review every diff before a commit.
  • allowManagedHooksOnly: true and no managed hooks: hooks run outside the sandbox with my full access, so no cloned repository may bring one.

One file must stay world-readable: Claude Code silently starts without a managed settings file it cannot read.

The probe matrix

Every probe was run by Claude in its own sandbox. Commands typed at the ! prompt run outside the sandbox and prove nothing.

Probe Result Stopped by
touch ~/sandbox-probe Read-only file system sandbox
touch .git/hooks/sandbox-probe, inside the repo Read-only file system sandbox (protected path)
ls ~/.ssh 0 entries sandbox
a .env, Claude Code’s own token file Permission denied sandbox
Read tool on ~/.ssh/config refused permission rule
curl --noproxy '*' https://example.com Could not resolve host sandbox (network namespace)
curl https://example.com proxy 403, no prompt sandbox proxy
curl https://pypi.org/simple/ 200 allowed (the control)
curl localhost:11434 (Ollama on the host) connection refused sandbox (own loopback)
ssh -T git@github.com Could not resolve hostname sandbox
nvidia-smi, CUDA cuInit no devices sandbox (minimal /dev)
retry with dangerouslyDisableSandbox runs sandboxed anyway managed settings
repository settings with enabled: false and three more loosening keys no effect admin-required sandbox
git push refused before it runs permission rule
unshare --user --map-root-user root with full capabilities nothing

Inside, a sandboxed command has no effective capabilities, no_new_privs is set (so sudo cannot raise privileges) and a seccomp filter is active. Then the last line of the table.

And the two checks of the sandbox itself, done with headless claude -p from a terminal:

$ sudo chmod 0644 /usr/local/bin/claude-bwrap; claude -p 'Run: echo sandbox-ok'
Error: sandbox required but unavailable: ... bubblewrap (bwrap) not executable at /usr/local/bin/claude-bwrap ...
  sandbox.failIfUnavailable is set — refusing to start without a working sandbox.

Ansible saw the changed mode and restored it. The second check removed the AppArmor profile from the kernel while leaving its file in place. Claude Code started anyway: its start-up check looks for an executable binary, not for a working sandbox. Every command then failed closed, nothing ran outside the box, and the first version of the role reported ok because the file had not changed. The role now reads /sys/kernel/security/apparmor/profiles and reloads the profile when it is missing, the same “check the running state, not the file” pattern as the sysctl in the previous lab.

Looking inside

From another terminal, while a sandboxed sleep was running, the process tree shows how the box is built:

claude-bwrap --new-session --die-with-parent --unsetenv CLAUDE_CODE_MESSAGING_TOKEN ...
 └─ bash -c socat ...
     ├─ socat TCP-LISTEN:3128 → UNIX-CONNECT:/tmp/claude-http-<id>.sock     # HTTP proxy bridge
     ├─ socat TCP-LISTEN:1080 → UNIX-CONNECT:/tmp/claude-http-<id>.sock     # SOCKS bridge
     └─ 3   (/proc/self/fd/3 ...)                                           # apply-seccomp
         └─ 3
             └─ bash -c ...
                 └─ sleep 300

The two socat processes are the only way out: they listen on ports inside the command’s private loopback and forward to a Unix socket that leads to the proxy outside. They start before the seccomp filter, which blocks Unix sockets for the command itself. The process called 3 is Claude Code’s seccomp helper, apply-seccomp, executed straight from a file descriptor: it has no path on disk.

lsns on the sleep, compared with my own shell:

Namespace Created by
net bwrap
user, mnt apply-seccomp (nested inside bwrap’s)
pid apply-seccomp’s child
time, cgroup, uts, ipc shared with the host

The bwrap command line reads like a recipe: --ro-bind / / (the whole host, read-only), then --bind for the writable parts (the repository, the temp directory), then the protected paths mounted read-only again on top (.git/hooks, .git/config, .claude), an empty --tmpfs over ~/.ssh, and /dev/null bound over every denied file. Every protocol it can think of is pointed at the proxy, including git over SSH through a socat ProxyCommand. The proxy URL even carries credentials: its user name is the base64 of the tool call’s ID, so every request can be attributed to one command.

The gap

Back to the last line of the matrix:

$ unshare --user --map-root-user sh -c 'id -u; grep CapEff /proc/self/status'
0
CapEff:	000001ffffffffff

A sandboxed command can create a nested user namespace and be root with every capability inside it. This is not an escape on its own: the nested namespace sits inside bwrap’s mounts and network, so the home directory stays read-only and the network still ends at the proxy. But it re-opens exactly what Ubuntu’s user namespace restriction closes: kernel code that otherwise needs root, the usual route of local privilege escalation bugs.

The cause is the AppArmor exception itself. Programs started by bwrap inherit its profile, claude-bwrap (unconfined), userns permission included. The documentation says the profile “applies only to bwrap itself”; with that profile form it does not, and the documented profile for /usr/bin/bwrap behaves the same way, for every program on the host.

Ubuntu has an answer for bwrap: bwrap-userns-restrict, an experimental profile in apparmor-profiles, enabled by default from 26.04. It lets bwrap use capabilities for its setup and stacks everything bwrap runs with a second profile that has audit deny capability. I adapted it to the copy and tested it. No sandboxed command started:

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

Claude Code’s own seccomp helper creates a nested user namespace and needs CAP_SYS_ADMIN in it. The sandbox relies on precisely the thing Ubuntu’s fix takes away, and since the helper has no path on disk, AppArmor cannot make a narrow exception for it. This is known upstream: claude-code#87680 reports the same denial on Ubuntu 26.04, where the sandbox does not start at all, and sandbox-runtime#428 proposes fixes inside the runtime. The workaround in circulation gives the capability back to bwrap’s children, which re-opens the same gap for every bwrap user on the system. I kept the simple profile and documented the gap.

Is all this control worth it?

A fair question at that point: if the gap cannot be closed, why manage all of this instead of switching on the built-in sandbox and stopping there?

Because this is the built-in sandbox. Nothing sits on top of it. Around it there are three things, each with a reason:

What Why Without it
bubblewrap, socat, an AppArmor exception required on Ubuntu 24.04 the sandbox does not start at all
the built-in options in root-owned managed settings a repository or the agent cannot loosen them the agent can propose an edit to ~/.claude/settings.json, and one “Yes” opens the box
an Ansible role repeatable manual steps on every node

The gap is in the built-in sandbox itself. The probes found it; without them it would be there unnoticed.

And there is no single command to block. unshare is a thin wrapper around a system call that python3 -c 'import os; os.unshare(os.CLONE_NEWUSER)' or any small program can make. A permission rule for unshare * stops the literal command and nothing else, so it is a speed bump that looks like protection. AppArmor cannot target a helper without a path. The right tool is a seccomp rule against CLONE_NEWUSER for the command, and that rule belongs in Claude Code’s own helper.

Until then the practical defence is boring: a patched kernel. The node had 54 pending updates during the lab, AppArmor among them. Closing the gap for good is part of a later lab that compares four sandboxes for the same agent, two of them with seccomp and gVisor of my own.

What the built-in sandbox does deliver, proven: no writes outside the repository and the temp directory, protected paths read-only even inside it, secrets unreadable, no network except four hosts through the proxy, no way back out of the box, and a refusal to start without it.

Small things that bite

  • The permission mode is separate from the sandbox. Managed settings that enforce the sandbox do not set the mode; a resumed session kept auto mode until permissions.defaultMode said otherwise.
  • Auto-approval is not universal. Sandboxed commands still prompt when they name files outside the working directory, assign variables computed at run time, or ask for the escape hatch. curl to a URL does not.
  • Globs in deny rules are expanded at every command. On Linux, ~/**/.env becomes the list of .env files that exist when a command starts, and deny rules for the file tools are merged into the sandbox too. bwrap accepts at most 9000 arguments, about 3000 mounts, so a glob over /proc is out of the question.
  • The file tools see what commands cannot. A sandboxed command has its own /proc; the Read tool runs as you, and /proc/<pid>/environ of your processes holds tokens. Without an allow rule it asks first. Do not approve it.
  • git add . from inside the sandbox would try to stage the empty placeholders it mounts for protected paths (.bashrc, .gitconfig, .mcp.json), which appear as untracked files only inside it. Stage explicit paths.
  • Ansible in the sandbox fails at start-up because ~/.ansible/tmp is on the read-only home; ANSIBLE_LOCAL_TEMP in the repository’s settings fixes it without widening anything.

What surprised me

  1. The sandbox’s own seccomp helper needs root capabilities in a nested user namespace, so Ubuntu’s fix for bwrap and Claude Code’s sandbox cannot run together.
  2. Programs started by bwrap inherit its AppArmor exception, contrary to what the documentation says.
  3. failIfUnavailable checks for a binary, not for a working sandbox; a profile missing from the kernel fails closed, but only at the first command.
  4. The agent’s update channel had moved my session to a release from the same morning before I had changed anything.
  5. Half of the protection lives outside the sandbox: file tools, hooks and your own settings file follow permission rules, so managed settings matter as much as bwrap.

What’s next

The next lab is systemd as a platform: Ollama and Ghostwheel as hardened services with memory and CPU limits, and systemd-analyze security as the score. The nested user namespace stays on the list for the sandbox comparison later in the series, along with the question of how a sandboxed agent should reach a local model: through an authenticated gateway, not Ollama’s open API.

Everything from this post, in full: