Sandbox fails to arm: apply-seccomp setgroups EPERM in initial userns unless allowAllUnixSockets:true (2.1.220)
Environment: Claude Code 2.1.220 · Ubuntu (kernel 7.0.0-28-generic) · kernel.apparmor_restrict_unprivileged_userns=0, kernel.apparmor_restrict_unprivileged_unconfined=0 · launch stack verified fully in the initial user namespace (identity uid_map) and AppArmor-unconfined end to end.
Bug: enabling the native sandbox kills every Bash tool call with:
apply-seccomp: write /proc/self/setgroups (nested userns is capability-restricted; caller must provide CAP_SYS_ADMIN): Permission denied
enableWeakerNestedSandbox: true makes no difference. Plain bwrap --unshare-user <cmd> works on the same host, so host userns policy is not the blocker.
Isolation (A/B canaries): run in a throwaway CLAUDE_CONFIG_DIR (no hooks, neutral cwd, temp settings), one Bash printf probe each:
- A — sandbox enabled with
"network": {"allowedDomains": [], "allowAllUnixSockets": false}→ every Bash call fails with the error above:
````
apply-seccomp: write /proc/self/setgroups (nested userns is capability-restricted; caller must provide CAP_SYS_ADMIN): Permission denied
- B — identical settings except
"allowAllUnixSockets": true→ sandbox arms, Bash runs, probe prints its marker:
````
CANARY_B_OK
Conclusion: the unix-socket filtering path (the embedded apply-seccomp helper, which constructs a second nested user namespace) fails its setgroups write even when the outer process is in the initial userns with no LSM confinement — so socket filtering can never arm on this host, and with it disabled everything else (filesystem allow/deny lists, network domain filtering) works correctly.
Expected: unix-socket filtering arms like the rest of the sandbox, or the diagnostic names the actual unmet requirement (it currently blames a nested userns that isn't there).
Settings overlay used (canary A; B flips allowAllUnixSockets to true):
{"sandbox":{"enabled":true,"failIfUnavailable":true,"autoAllowBashIfSandboxed":true,"allowUnsandboxedCommands":false,"enableWeakerNestedSandbox":false,"network":{"allowedDomains":[],"allowAllUnixSockets":false},"filesystem":{"allowWrite":[]}}}
Repro: write the overlay to $DIR/settings.json, then from a neutral cwd:
echo 'Use the Bash tool exactly once to run: printf "CANARY_OK\n". Report the result verbatim.' | CLAUDE_CONFIG_DIR=$DIR claude -p --model haiku --allowedTools BashShowing cached comments. Read the full discussion on GitHub ↗
3 Comments
Independent reproduction on different hardware and a different kernel, plus the root cause of the
setgroupsEPERMand one detail that changes which workarounds still work.Environment: Claude Code 2.1.220 (native install,
~/.local/share/claude/versions/2.1.220) · bare-metal Ubuntu 24.04, kernel 6.8.0-136-generic ·bwrap0.9.0 (the only version in the 24.04 archive) ·kernel.apparmor_restrict_unprivileged_userns=0, every processunconfined.Same symptom, same A/B result: every sandboxed Bash call fails with
and
sandbox.network.allowAllUnixSockets: truemakes the sandbox arm, which matches the runtime's own[Sandbox Linux] Skipping seccomp filter - allowAllUnixSockets is enabledpath.Root cause — it is
bwrap, not the host userns policy.bwrap0.9.0 spawns its child with an empty capability bounding set (CapBnd: 0000000000000000).apply-seccompruns insidebwrapand then unshares a second user namespace; a nested userns whose creator has an empty bounding set can never regainCAP_SYS_ADMIN, so thesetgroupswrite is unconditionally denied. This is consistent with the initial-userns observation above: the outer process really is unconfined and in the initial namespace — the capability loss happens at thebwrapboundary, one level in.Isolating tests on this host:
unshare --user --map-root-user true— succeedsbwrap --unshare-user true— succeedsapply-seccompstandalone, and underunshare --user— succeedsapply-seccompunderbwrap, with or without--unshare-user/--cap-add ALL— failsFiled against the runtime as anthropic-experimental/sandbox-runtime#428.
The detail that matters for workarounds: renaming the npm-global
@anthropic-ai/sandbox-runtimevendor/seccomp/{x64,arm64}/apply-seccompbinaries used to disable the helper and restore the sandbox. It no longer does. The native Claude Code build hasapply-seccompcompiled into the CLI binary and re-invokes it through argv0 mode, sogetApplySeccompBinaryPath— and with itsandbox.seccomp.applyPathandbpfPath— is bypassed entirely. Verified on 2.1.220: both global binaries renamed to.disabled,srt /bin/echo oksucceeding standalone, and Claude's own Bash still failing with the message above.That leaves
allowAllUnixSockets: trueas the only user-reachable lever on this host, and it is a real trade-off rather than a neutral toggle — it turns off Unix-socket filtering wholesale, including the Docker socket, to work around a capability bug that has nothing to do with sockets.Suggestions:
apply-seccompdetect an emptyCapBndand skip the nested userns instead of attempting a write that cannot succeed, or install the filter before enteringbwrapwhile capabilities still exist.kernel.apparmor_restrict_unprivileged_userns, which on this host are already permissive and were never involved.sandbox.seccomp.enabled: false, so declining Unix-socket filtering does not have to be spelled as "allow every Unix socket".Hit this on 2.1.220 (Linux, bubblewrap 0.11.1,
@anthropic-ai/sandbox-runtime0.0.67). There is an alternative toallowAllUnixSockets: truethat keeps Unix-socket blocking intact.The nested user namespace the seccomp helper needs is blocked by Ubuntu's
kernel.apparmor_restrict_unprivileged_userns=1. Grantingbwraptheusernspermission fixes it:followed by
sudo apparmor_parser -r /etc/apparmor.d/bwrap.Two caveats found along the way, both on Ubuntu:
systemctl reload apparmordoes not put this profile into effect on my system, and neither does the boot-timeapparmor.service— only a directapparmor_parser -rdoes. So it does not survive a reboot unless you add a unit that runs that command explicitly (After=apparmor.service,Type=oneshot).apply-seccomperror.failIfUnavailable: truedoes not catch it, since bubblewrap and socat are still installed and the failure is per-command rather than at startup. Pairing withallowUnsandboxedCommands: falseis worth considering, so a broken sandbox fails loudly instead of falling back to unsandboxed execution via the retry path.Unlike
allowAllUnixSockets: true, this preserves the seccomp filter's Unix-socket blocking — which is what stops sandboxed commands from reaching the D-Bus secret service and reading credentials held in the system keyring._Drafted by Claude (Anthropic AI assistant)._
Data point on what remains after @glen-84's
/etc/apparmor.d/bwrapprofile: it does fix the deterministicsetgroupsfailure, but on this host it leaves a residual intermittent failure with a different signature, and that one has a mechanical explanation worth recording here.Environment: Claude Code 2.1.233 · Ubuntu 24.04.4, kernel 6.17.0-40-generic · bubblewrap 0.9.0 ·
kernel.apparmor_restrict_unprivileged_userns=1(left at the Ubuntu default, not set to 0) · the per-binarybwrapprofile from @glen-84's comment loaded viaapparmor_parser -r.With that profile in place the sandbox arms and
allowAllUnixSocketscan stayfalse— so the workaround does what it says. But roughly 4 Bash calls out of several dozen in one session still fail outright, in clusters, with:Different syscall, different errno, and non-deterministic — a retry of the identical command succeeds. It is fail-closed (nothing partially executes).
Mechanism. The kernel permits
unshare(CLONE_NEWUSER)only from a single-threaded process; from a multi-threaded one it returnsEINVAL. Measured directly on this host:| Threads in the calling process |
unshare(CLONE_NEWUSER)||---|---|
| 1 | succeeds |
| 5 | EINVAL |
| 17 | EINVAL |
Reproducer — no Claude Code needed, one arm per process:
One trap when measuring this: the syscall must be issued in-process. My first attempt called it after
fork(), which resets the child to a single thread and made both arms report success — the effect vanishes entirely if you fork first.This lines up with @Krzysztof318's finding that
apply-seccompunshares a second user namespace insidebwrap: that nested unshare is subject to the single-thread precondition. The client is Node — three liveclaudePIDs here measured 26 / 15 / 21 threads — so whether the call lands single-threaded looks timing-dependent, which matches the low, clustered, retry-survivable rate. I have not readapply-seccomp's source, so the specific race is inference; the kernel rule and the thread counts are measured.Ruled out for this residual failure specifically:
apparmor="DENIED"forbwrapin the audit log predates loading the profile; zero denials in the window containing the failures, andbwrap --unshare-net --dev-bind / / truesucceeds standalone at the moment of failure.user.max_user_namespaces= 190503 with 17 in use.failIfUnavailable— as @glen-84 notes, it does not catch this: the sandbox is available and fails per invocation.enableWeakerNestedSandbox— no effect, and the mechanism does not match.Suggested fix: issue
unshare(CLONE_NEWUSER)from a guaranteed single-threaded context (immediately post-fork/clone, before any thread pool is touched), or retry once onEINVAL. Either would also make the failure deterministic rather than intermittent, which matters more than it sounds — an intermittent sandbox failure reads as a flaky machine, not as a bug.Separately, confirming @glen-84's reboot caveat is a real trap — with a correction to how it presents. On this host there is no unit re-parsing the profile, and the profile's mtime is after the current boot, so I have not yet observed a reboot with it in place; I can only confirm the precondition for the failure he describes (nothing re-parses it at boot). Worth folding into the docs either way: the failure mode is that the sandbox silently reverts to erroring on every command, with no indication that the cause is an AppArmor profile that quietly did not reload.