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. Since sandbox.allowUnsandboxedCommands defaults to true, 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 CLI 2.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 in do_exit() and does put_user(0, tsk->clear_child_tid) + futex_wake() — i.e. the joiner is woken before release_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
EINVAL around unshare(CLONE_NEWUSER), with sched_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 calling unshare.
- 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: true makes 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 reads settings.sandbox.seccomp. Passing it via the SDK sandbox option, --settings, or a settings file had no effect (/proc/1/cmdline inside 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.
Sandboxed Bash intermittently fails:
apply-seccomp: unshare(CLONE_NEWUSER): Invalid argumentSummary
With
sandbox: { enabled: true }, roughly 1 in 10 sandboxed Bash tool calls fail immediately with:The same command succeeds when re-run. It is not related to the command's content (
echo 1hits it).Root cause is a kernel-level race inside the embedded
apply-seccomphelper: the helper is the Claude Code binary re-executing itself (ARGV0=apply-seccomp /proc/self/fd/3), the bun runtime starts ami-scavengerthread 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 followingunshare(CLONE_NEWUSER)sees a non-empty thread group and returnsEINVAL.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:
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
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.2336.1.0-52-cloud-arm64(Debian 12), aarch64Observed rate
query(), normal loadstrace -f(widens the window)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
straceof a failing invocation: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), butjoinis not a sufficient barrier for this particular precondition.Minimal reproduction (no Claude Code involved)
Suggested fixes (in preference order)
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.Threads:in/proc/self/status(or/proc/self/task) until it reads 1 before callingunshare.Workarounds, for anyone else hitting this
What we shipped: a small
bwrapwrapper early onPATHthat rewrites theARGV0=apply-seccomp /proc/self/fd/3prefix to run underunshare -U --map-current-user --keep-capsfirst. util-linuxunshareis 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: 2with 1 filter,AF_UNIXsocket()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.