← Default view
containment league · linux sandboxing for claude code · season 2026 · 9 mechanisms scored · 52 sources
A split-flap departure board, rows of mechanically flipped characters
Tux, the Linux mascot Home side · Linux
Expedition angle · 25 min read · scored 30 jul 2026

Containment vs Friction

Nine ways to wrap claude on a Linux workstation, ranked by how much the kernel actually holds against how much of your day it costs. Two make the team. Four are relegated. One is not a sandbox at all.

Cover artwork for the Linux-native sandboxing research angle
angle cover · canonical page
◉ full time · the settings that win the match
sandbox.enabled = true
allowUnsandboxedCommands = false
failIfUnavailable = true
network.strictAllowlist = true
◉ why it wins

It is bubblewrap plus seccomp — enforced by the kernel rather than by the model — and it is the highest containment per minute of setup available on Linux [1]. It covers Bash only, so add an outer boundary: sandbox-runtime ⭐ 4.8k or nono ⭐ 3.3k this week [2].

And none of it contains a kernel exploit. For a repository you don't trust, Anthropic's own answer is a dedicated virtual machine [2].

CONTAINMENT 0–10 · what the kernel enforces FRICTION 0–10 · setup plus daily cost, higher is worse yellow card · documented leak red card · documented escape or CVE
Promotion zone run these · positions 1–3
1POS

Claude Code built-in sandbox hardened

bubblewrap for filesystem and namespace isolation, socat plumbing a loopback port to a host-side allowlisting proxy, plus a seccomp filter whose one job is blocking Unix domain sockets. Confines every Bash command and all of its children.[1]

needs Linux or WSL2 · bubblewrap + socat
gate Ubuntu 24.04+ needs an AppArmor profile for bwrap[1]
setup ~5 minutes

containment6/10
friction1/10
Do this first, today

Writes land in cwd and the session temp dir; egress goes through a hostname allowlist. Everything else on this board is additive.

CVE-2026-25725 — a nonexistent .claude/settings.json could not be read-only bound, so sandboxed code created it, injected a SessionStart hook and got host execution. CVSS v4.0 4.0, fixed 2.1.2[16]
denylist bypass via /proc/self/root/…, then the agent disabling its own sandbox, then ld-linux.so loading a blocked binary[17]
reads the whole disk by default, ~/.ssh and ~/.aws included[1]
hooks, MCP servers, Read/Edit/WebFetch all run outside it[2]
the proxy never terminates TLS → domain fronting[1]
2POS

sandbox-runtime srt · ⭐ 4.8k

The same bubblewrap and seccomp primitives, wrapped around the whole process instead of one Bash call — so the file tools, hooks and MCP servers land inside the boundary too. Default-deny on both writes and network.[2]

run npx @anthropic-ai/sandbox-runtime claude
config ~/.srt-settings.json — must allow ~/.claude, ~/.claude.json, /tmp, the provider endpoint
setup ~15 minutes

containment8/10
friction2/10
Best containment per minute

The only thing on this board that closes the hooks-and-MCP gap without you writing a wrapper. Nest the built-in sandbox inside it and you also keep the egress proxy for Bash.

labelled a beta research preview; the config format may change[2]
same TLS-blind proxy; a proxy-timing leak of initial bytes was observed[15]
inherits every bubblewrap escape — anything bound in is an escalation primitive[9]
2POS · EQUAL

nono landlock · ⭐ 3.3k

The only entry that needs no user namespace at all. Landlock is the one Linux LSM designed for unprivileged self-restriction: a process applies a ruleset to itself and the kernel enforces it on every descendant. nono targets the highest ABI available, degrades to PartiallyEnforced on older kernels, and layers a command blocklist, delete/truncate blocks and a credential proxy on top.[31][32]

run nono run --profile claude-code -- claude
kernel 5.13+ filesystem · 6.7+ for any network control · 6.10+ ioctl[30]
check dmesg | grep landlockUp and running[33]

containment7/10
friction2/10
Take this if your kernel is ≥ 6.7

It sidesteps the entire user-namespace attack surface that every bubblewrap row pays for. Also Codex CLI's default Linux backend.[34] Use it through the tool — hand-rolling Landlock is a project.

UDP — DNS on 53 included — plus raw sockets and ICMP are unmediated below ABI 10[30]
chroot(2) is not denied; chdir, stat, chmod, chown, setxattr, utime, fcntl, access unsupported; pipes and sockets via /proc/<pid>/fd/* unrestrictable[5]
a file-level grant goes stale when the file is replaced by an atomic write from outside — exactly what editors and package managers do[30]
“a kernel-level exploit is out of scope for Landlock or Seatbelt to contain”[32]
3POS

Hand-rolled bwrap wrapper claude-jail · ⭐ 8.2k

Exactly what you bind and nothing else: mount, PID, IPC, UTS and user namespaces, capabilities dropped, network optional. Highest ceiling on this board and the only row where you own the audit.

needs unprivileged CLONE_NEWUSER + mount namespaces[9]
setup 1–3 hours the first time, then near zero
startup under 50 ms per invocation[48]

containment8/10
friction6/10
Worth it to understand the boundary

Not worth it as a shortcut. “The level of protection … is entirely determined by the arguments passed to bubblewrap”[9] — the script is below.

“if you bind a D-Bus socket into the sandbox, it can be used to execute commands via systemd”[9]
TIOCSTI injection unless you filter it or pass --new-session — which breaks the TUI[9][11]
no egress policy at all: with --share-net, Claude can reach any host you can reach[19]
no memory, CPU or PID limits — neither Claude Code nor Codex implements them[14]
Play-off line marginal gain · take it for one thing only
4POS

systemd-run --user + hardening directives

Read-only rootfs via ProtectSystem=strict, $HOME hidden, private /tmp, no-new-privs, a syscall filter and address-family restrictions. Genuinely a sandbox on paper.[4][28]

needs systemd + unprivileged user namespaces (ProtectHome implies PrivateUsers in a user manager)[4]
score it systemd-analyze security · exposure 0.0–10.0, under 3 is good, over 7 means almost no sandboxing[27][52]
setup ~30 minutes, and you fight it

containment3/10
friction5/10
Optional — for the resource limits

Wrap position 3 in a transient user unit purely to gain CapabilityBoundingSet=, SystemCallFilter=@system-service, MemoryMax= and TasksMax=. Take it for those, never for the filesystem or network story.

IPAddressAllow/Deny filter by IP only, never by hostname, and “will have no effect” without eBPF cgroup support — a silent no-op is the worst failure mode a control can have[26]
ProtectProc= is “only available for system services” — the per-user manager cannot have it[4]
NoNewPrivileges “has no effect on processes … invoked … through tools such as at(1), crontab(1), systemd-run(1)” — and an agent with a shell can call systemd-run[4]
a good exposure score neither proves effective sandboxing nor invulnerability[27]
Relegation zone skip · positions 5–8
5POS

AppArmor profile for claude

Path-based rules per binary: which paths it may read, write and exec. Readable and writable by humans, which is why Ubuntu picked it.[36]

where Ubuntu/Debian default; 26.04 LTS ships 108 profiles, none for an agent[37]
setup days of iteration

containment2/10
friction9/10
Skip

It confines claude the binary, not the interpreter. A node profile permissive enough to run npm, tsc, git and your test runner confines nothing meaningful.

Qualys published three bypasses of Ubuntu's userns restriction using aa-exec, busybox and LD_PRELOAD against permissive default profiles[22]
the recommended extra knob, kernel.apparmor_restrict_unprivileged_unconfined=1, was never enabled by default[22]
6POS

SELinux policy for claude

Label-based domains and transitions decided on xattrs. audit2allow makes generating policy easy while understanding it stays hard.[35]

where Fedora/RHEL default; community-maintained and incomplete on Ubuntu[36]
setup weeks — writing a domain for an interactive dev tool is a policy-authoring project

containment1/10
friction10/10
Skip

On a workstation SELinux is simply not in the picture. The historical sandbox utility does confine an app tightly, but it is a niche RHEL-era tool, not a daily driver.[38]

Fedora's targeted policy “leaves interactive sessions unconfined” — your shell, and claude inside it, is unconfined_t[39]
7POS

firejail ⭐ 7.6k

Namespaces, seccomp-bpf and capability dropping driven by roughly a thousand shipped profiles, under 50 ms startup, and one trick bubblewrap cannot do: join an existing network namespace, which is how you pin a sandbox to a VPN or Tor.[40]

needs a SUID-root install[7]
setup ~10 minutes
2026 CVEs zero tracked, so this is a design objection, not a live-fire one[43]

containment6/10
friction2/10
Skip — sent off for the SUID bit

Bubblewrap does the same job with no privileged code added to your system: unless it is setuid, “it is not a security boundary between the user and the OS, because anything bubblewrap could do, a malicious user could equally well do”.[9] For a browser the trade is defensible; for one CLI whose upstream already ships a bubblewrap config, it isn't.

“a bug in Firejail itself is a path to privilege escalation, and Firejail has had such CVEs in its history”[41]
CVE-2022-31214 — a crafted join target lands you in the initial user namespace with NO_NEW_PRIVS off and an attacker-controlled mount namespace, then root via su/sudo[42]
8POS

Flatpak sandbox

Strong on paper — no network, no host filesystem beyond ~/.var/app/$FLATPAK_ID, no devices, own D-Bus name only. Wrong shape for an npm-installed CLI, and there is no packaged agent CLI to run.[8]

indirect value Flatpak is the reason bubblewrap exists and is packaged everywhere[3]

containmentn/a
frictionn/a
Not applicable

And the ecosystem's revealed practice is to grant the sandbox away: --filesystem=host mounts the complete host at /run/host/root, so “all it takes to escape the sandbox is echo download_and_execute_evil >> ~/.bashrc”.[44]

CVE-2026-34078 — a complete sandbox escape via app-controlled symlinks in the portal's sandbox-expose options, plus CVE-2026-34079 arbitrary host file deletion; fixed in 1.16.4, April 2026[45][46]
~42% of studied packages override or misconfigure their own isolation[47]
Not a player a component, not a sandbox
INELIGIBLE

seccomp-bpf

A tiny BPF program inspecting every syscall's number and register arguments, returning allow, errno or kill. Irrevocable and inherited by children. It is a layer inside positions 1, 2, 3 and 7 — not something you deploy on its own.

the hard limit “BPF programs may not dereference pointers which constrains all filters to solely evaluating the system call arguments directly” — so it cannot see file paths. “Block open() on ~/.ssh” is not a seccomp rule[6]
needs no_new_privs when unprivileged

containmentn/a
frictionn/a
You don't write this by hand

The kernel's own documentation says it plainly: “System call filtering isn't a sandbox”. Path policy comes from mount namespaces or from an LSM.[6]

an over-broad filter breaks a child's own sandboxing[9]
◉ numbers that decided the table
< 50 ms
bubblewrap and firejail startup. Podman is 100–500 ms — and this tool spawns a subprocess per Bash call, which is why Anthropic and OpenAI both picked bubblewrap over containers[48][34]
8 → 27
of 40 catalogued kernel operations an unprivileged process can reach, without vs with user namespaces — the feature every bubblewrap row depends on[23]
43%
of 2020–2025 kernel CVEs in that analysis concentrate in netfilter / nf_tables[23]
1 char
CVE-2026-23111, an nf_tables use-after-free giving an unprivileged local user root and container escape, was fixed on 5 February 2026 by deleting a single ![25]
~42%
of studied Flatpak/Snap packages override the intended isolation or misconfigure sandboxing[47]
0.0–10.0
systemd-analyze security exposure scale. Under ~3 is good, over 7.0 is “almost no sandboxing”, and most stock services land at 8–9[27][52]

Four own goals in the default config

position 1 · change these before you trust it
OG 01

Reads are wide open

The default is “read access to the entire computer, except certain denied directories” — and that default “still allows reading credential files such as ~/.aws/credentials and ~/.ssh/”. There is no built-in credential deny list, only what you list.[1]

credentials.files → mode: "deny"
OG 02

The escape hatch is on

When a command fails, Claude may retry it with dangerouslyDisableSandbox. Ona watched the agent do exactly that, unprompted: “Let me try disabling the sandbox and using the full binary path to bypass the deny rule pattern.”[17]

allowUnsandboxedCommands: false
OG 03

Failures are silent

If bubblewrap is missing, Claude Code warns and then runs unsandboxed.[1]

failIfUnavailable: true
OG 04

Domain prompts are not a lock

By default a non-allowlisted host prompts. Since v2.1.219 you can make it deny instead.[1]

network.strictAllowlist: true

Team sheet

~/.claude/settings.json
{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false,
    "network": {
      "strictAllowlist": true,
      "allowLocalBinding": true,
      "allowedDomains": [
        "api.anthropic.com",
        "registry.npmjs.org",
        "*.github.com",
        "nuget.org", "*.nuget.org"
      ]
    },
    "credentials": {
      "files": [
        { "path": "~/.ssh",       "mode": "deny" },
        { "path": "~/.aws",       "mode": "deny" },
        { "path": "~/.gnupg",     "mode": "deny" },
        { "path": "~/.config/gh", "mode": "deny" }
      ],
      "envVars": [
        { "name": "GITHUB_TOKEN", "mode": "deny" },
        { "name": "NPM_TOKEN",    "mode": "deny" }
      ]
    },
    "excludedCommands": ["docker *"]
  }
}

Never put /var/run/docker.sock in allowUnixSockets — “allowing access to /var/run/docker.sock effectively grants access to the host system through the Docker socket”.[1] The full key list, including allowLocalBinding and enableWeakerNestedSandbox, is in the settings reference.[51]

VAR check · Ubuntu 24.04+

bwrap cannot create a user namespace

The check

sysctl kernel.apparmor_restrict_unprivileged_userns returning 1 means AppArmor is blocking bwrap from creating user namespaces.[1][21]

The profile below is a compatibility shim, not confinement — it “applies only to bwrap itself, not to the commands it runs inside the sandbox”.[1][19] And turning the restriction off is exactly the mitigation for CVE-2026-23111.[24]

The shim

sudo tee /etc/apparmor.d/bwrap > /dev/null <<'EOF'
abi <abi/4.0>,
include <tunables/global>

profile bwrap /usr/bin/bwrap flags=(unconfined) {
  userns,
  include if exists <local/bwrap>
}
EOF
sudo systemctl reload apparmor

The kit

the three things you'd actually install
anthropic-experimental/sandbox-runtime repository card

sandbox-runtime

⭐ 4.8k · beta research preview

Host-side HTTP and SOCKS5 proxies filtering all network requests, bubblewrap plus seccomp around the whole process.[13]

npx @anthropic-ai/sandbox-runtime claude
always-further/nono repository card

nono

⭐ 3.3k · Landlock, no user namespace

“Sandbox any AI agent in seconds — zero setup, zero latency”, with a shipped claude-code profile.[31]

nono run --profile claude-code -- claude
containers/bubblewrap repository card

bubblewrap

⭐ 8.2k · the primitive under positions 1, 2 and 3

“Low-level unprivileged sandboxing tool used by Flatpak and similar projects” — the caller defines the security model.[3]

bwrap --unshare-all --share-net …

Position 3, in full

~/.local/bin/claude-jail
#!/usr/bin/env bash
# ~/.local/bin/claude-jail — run `claude` with an ambient filesystem boundary.
# Usage: claude-jail [project-dir] [claude args...]
set -euo pipefail

PROJECT="$(realpath "${1:-$PWD}")"; [[ $# -gt 0 ]] && shift
UID_N="$(id -u)"
RESOLV="$(readlink -f /etc/resolv.conf)"   # follow systemd-resolved symlink

# Refuse to run if the kernel can't give us an unprivileged user namespace.
bwrap --unshare-user --dev /dev true 2>/dev/null || {
  echo "bwrap cannot create a user namespace. On Ubuntu 24.04+ install the" >&2
  echo "AppArmor profile for /usr/bin/bwrap (see notes)." >&2; exit 1; }

exec bwrap \
  --unshare-all --share-net \
  --die-with-parent \
  --hostname claude-jail \
  --clearenv \
    --setenv HOME "$HOME" \
    --setenv USER "${USER:-dev}" \
    --setenv PATH /usr/local/bin:/usr/bin:/bin \
    --setenv TERM "${TERM:-xterm-256color}" \
    --setenv LANG "${LANG:-C.UTF-8}" \
    --setenv TMPDIR /tmp \
    --setenv XDG_RUNTIME_DIR "/run/user/$UID_N" \
    --setenv SSH_AUTH_SOCK "${SSH_AUTH_SOCK:-}" \
  --proc /proc \
  --dev /dev \
  --tmpfs /tmp \
  --tmpfs /run \
  --tmpfs "/run/user/$UID_N" \
  --ro-bind /usr /usr \
    --symlink usr/bin /bin \
    --symlink usr/sbin /sbin \
    --symlink usr/lib /lib \
    --symlink usr/lib64 /lib64 \
  --ro-bind /etc/ssl /etc/ssl \
  --ro-bind-try /etc/ca-certificates /etc/ca-certificates \
  --ro-bind-try /etc/pki /etc/pki \
  --ro-bind /etc/passwd /etc/passwd \
  --ro-bind /etc/group /etc/group \
  --ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \
  --ro-bind /etc/hosts /etc/hosts \
  --ro-bind "$RESOLV" /etc/resolv.conf \
  --ro-bind-try "$HOME/.gitconfig" "$HOME/.gitconfig" \
  --ro-bind-try "$HOME/.nvm" "$HOME/.nvm" \
  --bind-try "$HOME/.npm" "$HOME/.npm" \
  --bind "$HOME/.claude" "$HOME/.claude" \
  --bind-try "$HOME/.claude.json" "$HOME/.claude.json" \
  --bind-try "${SSH_AUTH_SOCK:-/nonexistent}" "${SSH_AUTH_SOCK:-/nonexistent}" \
  --bind "$PROJECT" "$PROJECT" \
  --chdir "$PROJECT" \
  --cap-drop ALL \
  claude "$@"
  # Add --new-session ONLY if `sysctl dev.tty.legacy_tiocsti` is not 0.

Four design decisions that matter

Allowlist, never denylist. Every 2026 escape was a denylist failure — /proc/self/root/usr/bin/npx resolved to a blocked binary without matching the deny pattern; “an allowlist would have refused the access regardless of how it was named”.[17][18]

Forward SSH_AUTH_SOCK, never ~/.ssh. Programs inside can then ask the agent to sign “without having access to secret keys”. If the socket lives under /tmp, --tmpfs /tmp hides it — start the agent with a socket path outside /tmp.[20]

Order is semantics. bwrap executes filesystem operations sequentially, so --ro-bind /usr /usr must precede anything nested under it, and --share-net must come after --unshare-all to override it.[10]

Bind only the resolver target. If /etc/resolv.conf is a symlink into /run, resolve it and bind that one file rather than all of /run.[19]

Pre-match checks

claude-jail . -- true          # starts at all?
# then inside `claude-jail . /bin/sh`:
ls ~/.ssh                      # must fail
touch "$HOME/canary"          # must fail
env | grep -Ei 'token|key|secret'
#                              ^ must be empty
ls /dev/pts                    # must exist, or the TUI dies
curl -sS -o /dev/null -w '%{http_code}\n' \
  https://api.anthropic.com    # egress check

Inside the sandbox curl works while getent hosts fails, because only proxy-aware traffic gets out — validate egress with curl, never with bare DNS. When it exits silently, strace -f -e trace=openat,access finds the missing bind; the usual culprit is /dev/pts.[19]

Kernel floor

landlock abi ladder · what your kernel version buys
ABI v1filesystem access rights5.13+
ABI v2REFER — cross-directory rename and link5.19+
ABI v3TRUNCATE6.2+
ABI v4TCP bind / connect — below this there is no network control at all6.7+
ABI v5device ioctl6.10+
ABI v6signal and abstract-socket scoping6.12+
ABI v10UDP rules, quiet-rule flagrecent

Enforcement needs either CAP_SYS_ADMIN in the namespace or no_new_privs set first — which is what makes Landlock usable by an ordinary user with no root and no system policy.[5][30]

Injury list

what breaks under a sandbox, and the fix
What breaksFix
dockerGenuinely incompatible. Put "docker *" in excludedCommands.[1][49]
jestRun it with --no-watchman.[1]
npm installWorks once registry.npmjs.org is allowed; in a hand-rolled jail, bind ~/.npm read-write so the cache survives sessions.[49][20]
dev serverWorks — needs network.allowLocalBinding on position 1, or --share-net in a hand-rolled jail.[51]
gpg commit signingThe most consistently reported breakage across bubblewrap and firejail backends: gpg: signing failed: Inappropriate ioctl for device. Bind the gpg-agent socket and set GPG_TTY.[50]
ssh authFails when SSH_AUTH_SOCK isn't bound.[50]
nvm installBinding ~/.nvm read-only means nvm use works and nvm install does not. That is usually the trade you want.[10]
--new-sessionThe real dilemma for a TUI. bubblewrap wants it “to protect against out-of-sandbox command execution”, but it calls setsid() and interactive programs break — fish reports No TTY for interactive shell (tcgetpgrp failed). Since Linux 6.2 TIOCSTI needs CAP_SYS_ADMIN when dev.tty.legacy_tiocsti is false, so check that sysctl: if it reads 0, omit the flag.[9][11][12]

Match abandoned

seven escape paths that survive every row above

Unprivileged user namespaces are the price of admission

“Flatpak uses bubblewrap as its sandboxing layer, and bubblewrap depends on user namespaces.”[23] CVE-2026-23111 let an unprivileged local user reach root and escape container confinement through exactly that combination plus nftables.[24] The mitigation — user.max_user_namespaces=0, or Ubuntu's apparmor_restrict_unprivileged_userns=1 — is precisely the thing you switched off to make bwrap work.

this is the single strongest argument for putting the agent in a Proxmox VM instead

Ubuntu's userns restriction is itself bypassable

Three published bypasses using aa-exec, busybox and LD_PRELOAD against default-installed binaries with permissive AppArmor profiles.[22]

Anything you mount in is an escalation primitive

A D-Bus socket bound into the sandbox “can be used to execute commands via systemd”;[9] /var/run/docker.sock is equivalent to host root.[1] Writable paths holding $PATH executables, shell rc files or systemd units are the same class of mistake.

Hostname-based egress filtering is soft

The proxy decides from the client-supplied hostname without inspecting TLS, so code inside “can potentially use domain fronting or similar techniques to reach hosts outside the allowlist” — and allowing a broad domain like github.com is itself an exfiltration path.[1]

execve gates don't stop mmap

Even content-hash enforcement fell to invoking the dynamic linker directly: /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /usr/bin/wget loads wget's code without ever execve-ing wget.[17]

The agent is an active participant

It routed around a denylist, disabled its own sandbox, and generated an approval prompt plausible enough to be waved through.[17]

“an agent that can disable its own sandbox is, by definition, not sandboxed”[18]

No resource containment anywhere

Neither Claude Code's nor Codex's sandbox layer implements memory, CPU or PID limits, so a runaway build can still wedge the workstation.[14] That is a systemd MemoryMax= / TasksMax= job — position 4's only real prize.

Away fixtures

the rest of this expedition
expedition · the away leg

Proxmox as the isolation boundary: KVM VM, not LXC

Use a KVM VM, never an LXC: Proxmox's own docs say containers share the host kernel and LXC upstream says privileged containers cannot be root-safe — plus the wiring, firewall rules, snapshot policy and sizing to make a dedicated agent guest actually usable.

expedition

Claude Code's native guardrails in 2026

The permission rules are an in-process policy layer, not a security boundary; only the bubblewrap-backed Bash sandbox has OS teeth.

survey

Threat model and blast radius

The realized 2026 losses come from the agent's own destructive git commands and from supply-chain malware that weaponizes an installed agent CLI.

survey

Containers as the containment layer

Rootless Podman plus a proxy-only internal network beats Anthropic's reference devcontainer; the reference firewall leaks DNS and hands the agent NET_ADMIN.

survey

Credential isolation on Linux

An agent that can read $HOME holds your whole identity, and on Linux the keyring does not help.

recon

Audit trail and recovery drills

Git commits are your real undo and a PostToolUse hook is your real audit log.

Match officials

the sources that set the scores · 52 in the full ledger on the canonical page
◉ scoring · containment and friction are this page's reading of the evidence in the comparison table on the canonical page, not vendor figures. Cards mark documented leaks (yellow) and documented escapes or CVEs (red).
◉ the one-line version · sandbox.enabled + allowUnsandboxedCommands: false + strictAllowlist today, srt or nono this week — and a VM when the repository is one you don't trust.
canonical page · expedition · Atlas