Sandbox fails to arm: apply-seccomp setgroups EPERM in initial userns unless allowAllUnixSockets:true (2.1.220)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 8 comments · opened Jul 27, 2026

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 Bash

View original on GitHub ↗

3 Comments

Krzysztof318 · 1 month ago

Independent reproduction on different hardware and a different kernel, plus the root cause of the setgroups EPERM and 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 · bwrap 0.9.0 (the only version in the 24.04 archive) · kernel.apparmor_restrict_unprivileged_userns=0, every process unconfined.

Same symptom, same A/B result: every sandboxed Bash call fails with

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

and sandbox.network.allowAllUnixSockets: true makes the sandbox arm, which matches the runtime's own [Sandbox Linux] Skipping seccomp filter - allowAllUnixSockets is enabled path.

Root cause — it is bwrap, not the host userns policy. bwrap 0.9.0 spawns its child with an empty capability bounding set (CapBnd: 0000000000000000). apply-seccomp runs inside bwrap and then unshares a second user namespace; a nested userns whose creator has an empty bounding set can never regain CAP_SYS_ADMIN, so the setgroups write 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 the bwrap boundary, one level in.

Isolating tests on this host:

  • unshare --user --map-root-user true — succeeds
  • plain bwrap --unshare-user true — succeeds
  • apply-seccomp standalone, and under unshare --user — succeeds
  • apply-seccomp under bwrap, with or without --unshare-user / --cap-add ALL — fails

Filed against the runtime as anthropic-experimental/sandbox-runtime#428.

The detail that matters for workarounds: renaming the npm-global @anthropic-ai/sandbox-runtime vendor/seccomp/{x64,arm64}/apply-seccomp binaries used to disable the helper and restore the sandbox. It no longer does. The native Claude Code build has apply-seccomp compiled into the CLI binary and re-invokes it through argv0 mode, so getApplySeccompBinaryPath — and with it sandbox.seccomp.applyPath and bpfPath — is bypassed entirely. Verified on 2.1.220: both global binaries renamed to .disabled, srt /bin/echo ok succeeding standalone, and Claude's own Bash still failing with the message above.

That leaves allowAllUnixSockets: true as 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:

  1. Have apply-seccomp detect an empty CapBnd and skip the nested userns instead of attempting a write that cannot succeed, or install the filter before entering bwrap while capabilities still exist.
  2. Failing that, make the diagnostic name the real precondition. "nested userns is capability-restricted" sends people to AppArmor and kernel.apparmor_restrict_unprivileged_userns, which on this host are already permissive and were never involved.
  3. Expose an explicit sandbox.seccomp.enabled: false, so declining Unix-socket filtering does not have to be spelled as "allow every Unix socket".
glen-84 · 27 days ago

Hit this on 2.1.220 (Linux, bubblewrap 0.11.1, @anthropic-ai/sandbox-runtime 0.0.67). There is an alternative to allowAllUnixSockets: true that keeps Unix-socket blocking intact.

The nested user namespace the seccomp helper needs is blocked by Ubuntu's kernel.apparmor_restrict_unprivileged_userns=1. Granting bwrap the userns permission fixes it:

# /etc/apparmor.d/bwrap
abi <abi/4.0>,
include <tunables/global>

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

followed by sudo apparmor_parser -r /etc/apparmor.d/bwrap.

Two caveats found along the way, both on Ubuntu:

  • systemctl reload apparmor does not put this profile into effect on my system, and neither does the boot-time apparmor.service — only a direct apparmor_parser -r does. So it does not survive a reboot unless you add a unit that runs that command explicitly (After=apparmor.service, Type=oneshot).
  • Because it silently does not survive a reboot, the sandbox reverts to failing every command with this same apply-seccomp error. failIfUnavailable: true does not catch it, since bubblewrap and socat are still installed and the failure is per-command rather than at startup. Pairing with allowUnsandboxedCommands: false is 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)._

lee2716 · 15 days ago

Data point on what remains after @glen-84's /etc/apparmor.d/bwrap profile: it does fix the deterministic setgroups failure, 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-binary bwrap profile from @glen-84's comment loaded via apparmor_parser -r.

With that profile in place the sandbox arms and allowAllUnixSockets can stay false — 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:

apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument

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 returns EINVAL. 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:

python3 -c "
import ctypes, threading, errno
libc = ctypes.CDLL('libc.so.6', use_errno=True)
stop = threading.Event()
for _ in range(4):                      # set to 0 for the single-threaded arm
    threading.Thread(target=stop.wait, daemon=True).start()
r = libc.unshare(0x10000000)            # CLONE_NEWUSER
print('threads', threading.active_count(), '->',
      'ok' if r == 0 else errno.errorcode[ctypes.get_errno()])
stop.set()"

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-seccomp unshares a second user namespace inside bwrap: that nested unshare is subject to the single-thread precondition. The client is Node — three live claude PIDs 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 read apply-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 — every apparmor="DENIED" for bwrap in the audit log predates loading the profile; zero denials in the window containing the failures, and bwrap --unshare-net --dev-bind / / true succeeds standalone at the moment of failure.
  • Resourcesuser.max_user_namespaces = 190503 with 17 in use.
  • Version — 2.1.233, current at time of writing.
  • 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 on EINVAL. 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.

Showing cached comments. Read the full discussion on GitHub ↗