Sandboxed Bash intermittently fails: apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument
Sandboxed Bash intermittently fails: apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument
Summary
With sandbox: { enabled: true }, roughly 1 in 10 sandboxed Bash tool calls fail immediately with:
apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument
The same command succeeds when re-run. It is not related to the command's content (echo 1 hits it).
Root cause is a kernel-level race inside the embedded apply-seccomp helper: the helper is the Claude Code binary re-executing itself (ARGV0=apply-seccomp /proc/self/fd/3), the bun runtime starts a mi-scavenger thread at startup, and the helper's join on that thread returns before the kernel has removed the dead thread from the thread group — so the immediately following unshare(CLONE_NEWUSER) sees a non-empty thread group and returns EINVAL.
Why this is worse than a flaky command
This is what made us dig in rather than paper over it. Both happened in real sessions on a hosted product:
- The model misdiagnoses it and reports a wrong conclusion to the user. In one session the agent hit this error while probing isolation boundaries and concluded "each command runs in a fresh filesystem layer that discards writes", then told the user so. It burned ~5 minutes of a turn on a false theory.
- The model disabled the sandbox on its own to get past it. After two transient failures, the agent retried a third time with
dangerouslyDisableSandbox: true— and succeeded, reading a file the sandbox had been blocking. It reported this honestly, which is the only reason we noticed. Sincesandbox.allowUnsandboxedCommandsdefaults totrue, a transient sandbox failure is a plausible trigger for a model to turn the sandbox off. Worth considering whether that default is right, and whether transient setup failures should be distinguishable from policy denials in the message the model sees.
Environment
- Claude Code
2.1.232(the binary bundled in@anthropic-ai/claude-agent-sdk@0.3.232,@anthropic-ai/claude-agent-sdk-linux-arm64); also reproduced with the standalone CLI2.1.233 - Linux
6.1.0-52-cloud-arm64(Debian 12), aarch64 - bubblewrap 0.8.0
- Single vCPU GCP instance (this matters, see below)
Observed rate
| Condition | EINVAL rate |
|---|---|
| Sandboxed Bash calls via query(), normal load | 2/14 |
| Same, under strace -f (widens the window) | 12/14 |
| Helper invoked in a loop, idle machine | 200/200 |
| Same loop, with a competing CPU-bound process | 29/100 |
| Helper inside a real bwrap userns/pidns, in a loop | 20/30 |
Counterintuitive: an idle machine fails more. When nothing else wants the CPU, the joining thread is scheduled the instant the futex wakes it, before the dying thread has been reaped. Under load the dying thread gets time to finish. Multi-core machines rarely see this at all, which is likely why it has gone unnoticed.
Root cause
strace of a failing invocation:
clone(..., CLONE_THREAD|CLONE_CHILD_CLEARTID, ...) = 11810 # bun/mimalloc starts a thread
[main] futex(child_tidptr, FUTEX_WAIT_BITSET, ...) # helper joins it
[11810] prctl(PR_SET_NAME, "mi-scavenger"); exit(0) # thread exits
[main] futex resumed # join returns
[main] unshare(CLONE_NEWNS|CLONE_NEWPID) = -1 EPERM # expected, unprivileged
[main] unshare(CLONE_NEWUSER) = -1 EINVAL # ~80us after the join returned
Kernel side (kernel/fork.c, 6.1):
ksys_unshare():if (unshare_flags & CLONE_NEWUSER) unshare_flags |= CLONE_THREAD | CLONE_FS;check_unshare_flags():if (unshare_flags & (CLONE_THREAD|CLONE_SIGHAND|CLONE_VM)) { if (!thread_group_empty(current)) return -EINVAL; }mm_release()runs early indo_exit()and doesput_user(0, tsk->clear_child_tid)+futex_wake()— i.e. the joiner is woken beforerelease_task()→__unhash_process()removes the thread from the group.
So a thread that has "exited" from the joiner's point of view can still make thread_group_empty() false for a short window. The helper is already doing the right thing conceptually (it knows the syscall requires a single-threaded process), but join is not a sufficient barrier for this particular precondition.
Minimal reproduction (no Claude Code involved)
// race.c — join returns before the kernel removes the dead thread from the
// thread group, so an immediate unshare(CLONE_NEWUSER) gets EINVAL.
#define _GNU_SOURCE
#include <sched.h>
#include <linux/futex.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
static int tid_slot;
static char tstack[65536];
static int thread_fn(void *arg) { return 0; }
int main(int argc, char **argv) {
int iters = argc > 1 ? atoi(argv[1]) : 100;
int sleep_us = argc > 2 ? atoi(argv[2]) : 0;
int fails = 0, ok = 0, other = 0;
for (int i = 0; i < iters; i++) {
pid_t pid = fork();
if (pid == 0) {
tid_slot = -1;
int tid = clone(thread_fn, tstack + sizeof(tstack),
CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|
CLONE_SYSVSEM|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,
NULL, &tid_slot, NULL, &tid_slot);
if (tid < 0) { perror("clone"); _exit(4); }
int v;
while ((v = __atomic_load_n(&tid_slot, __ATOMIC_SEQ_CST)) != 0)
syscall(SYS_futex, &tid_slot, FUTEX_WAIT, v, NULL, NULL, 0);
if (sleep_us) usleep(sleep_us);
int r = unshare(CLONE_NEWUSER);
_exit(r == 0 ? 0 : (errno == EINVAL ? 1 : 3));
}
int st; waitpid(pid, &st, 0);
int code = WIFEXITED(st) ? WEXITSTATUS(st) : 9;
if (code == 1) fails++; else if (code == 0) ok++; else other++;
}
printf("iters=%d ok=%d EINVAL=%d other=%d (post-join sleep %dus)\n",
iters, ok, fails, other, sleep_us);
return 0;
}
$ gcc -O2 race.c -o race
$ ./race 200 # iters=200 ok=0 EINVAL=200 other=0 (post-join sleep 0us)
$ ./race 200 50 # iters=200 ok=200 EINVAL=0 other=0 (post-join sleep 50us)
$ ./race 200 1000 # iters=200 ok=200 EINVAL=0 other=0 (post-join sleep 1000us)
Suggested fixes (in preference order)
- Bounded retry on
EINVALaroundunshare(CLONE_NEWUSER), withsched_yield()or a short backoff between attempts. The repro shows a 50µs delay closes the window entirely; a few retries would be robust without a fixed sleep. - Wait on the actual precondition rather than on the join: poll
Threads:in/proc/self/status(or/proc/self/task) until it reads 1 before callingunshare. - Create the user namespace before the runtime spawns any thread, if the helper's entry point can run early enough.
Workarounds, for anyone else hitting this
What we shipped: a small bwrap wrapper early on PATH that rewrites the ARGV0=apply-seccomp /proc/self/fd/3 prefix to run under unshare -U --map-current-user --keep-caps first. util-linux unshare is single-threaded, so the namespace is created cleanly and the helper never executes the racy syscall; it still applies its seccomp filter. Measured 0 failures in 15 + 17 sandboxed calls and 0/100 in a bwrap loop (control: 20/30 failures in the same window). Verified unchanged afterwards: Seccomp: 2 with 1 filter, AF_UNIX socket() still denied, uid unmapped, ambient caps cleared before the payload exec.
Two things that look like workarounds but are not:
sandbox.network.allowAllUnixSockets: truemakes the error disappear — because the helper is not invoked at all on that path. The AF_UNIX block goes away with it, which on a shared host means local daemon sockets become reachable from sandboxed commands. Not a safe trade.sandbox.seccomp.applyPath(mentioned by the binary's own "install @anthropic-ai/sandbox-runtime" hint) appears to be inert in 2.1.232: the sandbox config constructor hardcodes the embedded helper and never readssettings.sandbox.seccomp. Passing it via the SDKsandboxoption,--settings, or a settings file had no effect (/proc/1/cmdlineinside the sandbox still shows/proc/self/fd/3). If that path is meant to be supported, it looks like a separate bug; if it is not, the hint text may be worth removing.
Showing cached comments. Read the full discussion on GitHub ↗
5 Comments
Same issue here. Claude told me to add the following to this bug report:
----------------
Another data point, different hardware profile from the report.
subagent fan-out — not a tight loop
This is a multi-core x86_64 case, which cuts against "multi-core machines rarely
see this at all" — though the nesting is the likely driver, consistent with your
20/30 bwrap userns/pidns row. Kernel 7.1 and x86_64 both still reproduce, so it's
neither arch- nor kernel-version-specific.
On your question about whether
sandbox.allowUnsandboxedCommandsdefaulting totrueis the right default — a data point in favour of changing it. This configsets it to
false. During an audit of this sandbox, an agent did invokedangerouslyDisableSandbox: true, and enforcement was byte-for-byte identicalacross network, raw sockets and filesystem: the flag was inert. So the failure
mode you describe (transient sandbox error → model disables the sandbox → reads
something it shouldn't) is fully prevented by that setting. Minor UX note: the
flag was silently accepted rather than rejected, so nothing signalled that it
had no effect.
Pitfall for anyone writing a reproducer: calling
unshare(CLONE_NEWUSER)in afork()ed child won't show the bug —fork()gives a single-threaded childregardless of the parent, so the constraint isn't exercised. The thread has to
exist in the process making the call.
Same issue here and also Claude Code 2.1.233. Atempting to run Claude inside a rootless Docker container gw0/docker-claude-code with sandboxed Bash tools turned on. Everything works fine without sandboxing (
sandbox.enable=falseandCLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0).Thanks @remileduc — that's a valuable data point, and it corrects my framing. I wrote "multi-core machines rarely see this at all", but under nested userns the window evidently stays open regardless of core count, so the exposure is wider than my single-core numbers suggested — and ~13% of real-workload Bash calls is well past nuisance level.
The
allowUnsandboxedCommands: falseaudit result (enforcement byte-for-byte identical,dangerouslyDisableSandboxinert) is exactly the field evidence the default-value question needed. The silent accept seems worth a small fix of its own: a hard error would teach the model that the flag is a dead end, instead of letting it believe the escape worked.Good catch on the
fork()pitfall too — that's why the reproducer above creates the thread withclone(CLONE_THREAD)in the same process rather than forking first.@gw0 thanks — that makes a third independent environment shape: single-core arm64 VM with no nesting (original report), multi-core x86_64 bare metal under nested bwrap userns (@remileduc), and rootless Docker (yours). The common thread is that anything keeping the thread-reap window open gets hit; core count just changes the odds.
For anyone landing here: the proposed fix (a bounded EINVAL retry in the vendored
apply-seccomp.c) is up at anthropic-experimental/sandbox-runtime#479.I have the same issue (using claude-agent-sdk-python), it runs inside a docker container.
Environment shape #4, plus a simpler workaround.
ARGV0=apply-seccomp <claude-binary> /bin/true); 0/200 in the same loop under full CPU load — direction consistent with the reap-window mechanism (idle machines fail more)Workaround:
MIMALLOC_PURGE_DELAY=0in the environment. This stops mimalloc from spawning themi-scavengerthread at all, so the helper process is genuinely single-threaded and the racy precondition can't be violated. Verified by strace (zeroclone3(CLONE_THREAD)calls in helper mode with the var set) and 300/300 clean in the loop that failed 1/200 without it. It's settable via theenvblock in settings.json, so no wrapper binary or PATH shim is needed and it survives Claude Code updates:Caveat: the variable also reaches the main Claude Code process, where it switches mimalloc to eager purging — a marginal perf cost on frees.
-1also suppresses the thread but disables purging entirely, which would grow RSS in long-running sessions;0is the right value. Obviously this is a workaround leaning on an allocator implementation detail, not a fix — the bounded-EINVAL-retry in anthropic-experimental/sandbox-runtime#479 is the real thing.