Skip to content

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

Description

@Xiaokebuyu

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:sandboxbugSomething isn't workinghas reproHas detailed reproduction stepsplatform:linuxIssue specifically occurs on LinuxreproducedBug reproduced by maintainers on a released build

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions