Sandboxed Bash intermittently fails: apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument

Status Open
Reported on v2.1.232
Maintainer reply None cached
Activity 11 comments · opened Aug 15, 2026

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:

  1. 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.
  2. 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)

  1. 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.
  2. 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.
  3. 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.

View original on GitHub ↗

5 Comments

remileduc · 14 days ago

Same issue here. Claude told me to add the following to this bug report:

----------------

Another data point, different hardware profile from the report.

  • Claude Code 2.1.233, Linux 7.1.7+deb14-amd64 x86_64, 4 cores, bare metal
  • Running inside a nested unprivileged bwrap userns (bubbleclaude wrapper)
  • 19 failures / 151 Bash tool calls (~13%) in normal agentic work with

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.allowUnsandboxedCommands defaulting to
true is the right default — a data point in favour of changing it. This config
sets it to false. During an audit of this sandbox, an agent did invoke
dangerouslyDisableSandbox: true, and enforcement was byte-for-byte identical
across 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 a
fork()ed child won't show the bug — fork() gives a single-threaded child
regardless of the parent, so the constraint isn't exercised. The thread has to
exist in the process making the call.

gw0 · 13 days ago

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=false and CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0).

Xiaokebuyu · 13 days ago

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: false audit result (enforcement byte-for-byte identical, dangerouslyDisableSandbox inert) 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 with clone(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.

jerbob92 · 13 days ago

I have the same issue (using claude-agent-sdk-python), it runs inside a docker container.

StructByLightning · 13 days ago

Environment shape #4, plus a simpler workaround.

  • Claude Code 2.1.232, Linux 6.8.0 x86_64, 32 cores, bare metal desktop, no nesting beyond the stock bwrap invocation
  • ~1/3 of sandboxed Bash calls failing during a heavy multi-agent session this morning; 1/200 invoking the helper in an idle loop (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=0 in the environment. This stops mimalloc from spawning the mi-scavenger thread at all, so the helper process is genuinely single-threaded and the racy precondition can't be violated. Verified by strace (zero clone3(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 the env block in settings.json, so no wrapper binary or PATH shim is needed and it survives Claude Code updates:

{ "env": { "MIMALLOC_PURGE_DELAY": "0" } }

Caveat: the variable also reaches the main Claude Code process, where it switches mimalloc to eager purging — a marginal perf cost on frees. -1 also suppresses the thread but disables purging entirely, which would grow RSS in long-running sessions; 0 is 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.

Showing cached comments. Read the full discussion on GitHub ↗