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.
17 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.Confirmed / reproduced on 2.1.234 (Linux x86_64, 4-vCPU VM, idle): running the standalone loop from the comments above as an unprivileged user with a trivial
/bin/truepayload, 2 of 600 back-to-back invocations failed with exactlyapply-seccomp: unshare(CLONE_NEWUSER): Invalid argument(exit 1), and the identical command succeeded on the next run. The rate here is far below the single-vCPU / nested-userns numbers reported above, but the intermittent failure shows up on a stock multi-core x86_64 box too.🤖 Generated with Claude Code
---
_Generated by Claude Code_
Independent reproduction on hardware that does not match the "single vCPU" factor noted above, and on a newer Claude Code build. Sharing because both details argue the race is broader than the original environment suggested.
Environment
| | This report | Original report |
|---|---|---|
| Claude Code | 2.1.238 | 2.1.232 to 2.1.233 |
| Distro | Ubuntu 24.04.4 LTS | Debian 12 |
| Kernel | 7.0.0-28-generic | 6.1.0-52-cloud-arm64 |
| Arch | x86_64 | aarch64 |
| CPUs | 14 (Intel Core Ultra 7 155U) | 1 vCPU |
| bubblewrap | 0.9.0 | 0.8.0 |
Two things this adds
Confirming the diagnosis above independently
Before finding this issue I bisected it from the error string alone and landed on the same kernel constraint, which I mention only as corroboration of the
check_unshare_flagsexplanation:Ruled out on this machine, in case it saves anyone else the detour:
CONFIG_USER_NS=y/proc/sys/user/max_user_namespaces= 114669/proc/sys/kernel/unprivileged_userns_clone= 1kernel.apparmor_restrict_unprivileged_userns= 1, which is set, but that would fail deterministically rather than intermittently, so it is not the cause here. Worth stating explicitly since #87680 covers the AppArmor path and the two get conflated.Impact note
Failures are cheap in isolation, since re-running the identical command has succeeded on the first retry every time for me. The real cost is that the error is easy to misread as a problem with the command, which sends you debugging the wrong thing. A bounded retry on EINVAL, as suggested above, would remove that entirely.
Related, and possibly worth a separate issue: inside the sandbox the PID namespace makes
kill -0,pgrep, andsssilently report host processes and listening ports as absent. That is expected behavior for a PID namespace, but it is not obvious from the tool's output, and it produced a confidently wrong conclusion for me before I checked outside the sandbox.Still present in 2.1.237 (the runtime bundled with Claude Desktop), and on
a different platform than the reports above: Debian 13.6, Linux 6.12.101
x86_64, multi-core desktop machine.
Rate here is much lower than yours — 3 failures over two days of normal use,
on
git commit,grepandgit add. That fits your observation that busymulti-core machines are hit far less often than idle single-vCPU ones.
Independently confirmed fail-closed on this platform: after each failure I
checked state before retrying, and the command had not run.
Also ruled out on this host, in case it saves someone the detour:
user/max_user_namespacesis 2147483647,kernel/unprivileged_userns_cloneis 1, and
kernel/apparmor_restrict_unprivileged_usernsdoes not exist onDebian 13 — so none of the usual namespace/policy suspects apply here.
Confirming the
MIMALLOC_PURGE_DELAY=0workaround with a paired measurement, plus one factor I don't think anyone has reported yet: page cache state moves the failure rate by more than 10x, which I suspect is quietly behind the wide spread in the rates reported above.Environment
| | |
|---|---|
| Claude Code | 2.1.241 (build 2026-08-22) |
| Distro | Ubuntu 26.04 LTS |
| Kernel | 7.0.0-30-generic, x86_64 |
| CPU | Intel Core Ultra 9 285H, 16 cores |
| bubblewrap | 0.11.1 |
| Nesting | none, stock bwrap invocation |
Usual suspects ruled out here too:
CONFIG_USER_NS=y,kernel.unprivileged_userns_clone=1,user.max_user_namespaces=250542,kernel.apparmor_restrict_unprivileged_userns=0, andunshare --user truesucceeds as an unprivileged user.Method
Invoked the helper directly rather than going through tool calls, so the sample size is cheap:
Control used
env -u MIMALLOC_PURGE_DELAYon the same line. Run order was alternated after the cold-cache effect below turned up.The cold-cache factor
My first baseline run, on a cold page cache, gave 25/200 (12.5%). Re-running the identical control warm gave 2/200 (1%). Same machine, same binary, same command, minutes apart.
If I had stopped at my first pair I would have reported a 12.5% -> 0% win, and it would have been confounded. Worth flagging because it fits the mechanism: the race is a thread-group reap window, so anything that slows the process down while leaving it otherwise idle widens it. Cold cache does exactly that.
The practical consequence is that exposure is worst at the start of a session, before the binary and its pages are cached — which matches what I saw in real use, where the error clustered on the first few tool calls of a session and then largely stopped. It may also explain part of the 2/600 to 1/3 spread in this thread: reporters measuring during warm, busy sessions and reporters measuring cold will get very different numbers from the same defect.
Paired result, warm only
| condition | failures | rate |
|---|---|---|
| without
MIMALLOC_PURGE_DELAY| 2/200 + 2/600 + 32/3000 = 36/3800 | 0.95% || with
MIMALLOC_PURGE_DELAY=0| 0/200 + 0/600 + 0/3000 = 0/3800 | 0% |If the rate under the fix were still 0.95%, observing zero failures in 3800 runs has probability
(1 - 0.0095)^3800≈ 2e-16.Secondary signal
Wall time for 3000 invocations dropped from 12.16s to 7.92s (-35%) with the var set. That is consistent with the stated mechanism — no
mi-scavengerthread being spawned and joined per invocation — and is an independent check that the var is doing what it is claimed to do, not just perturbing timing enough to hide the race.On the reporting side
One more data point for the concern raised in the original report about the model mishandling this. In my case the agent hit the error, correctly derived the multithreaded-caller
EINVALmechanism from the kernel semantics, and then retried the same command withdangerouslyDisableSandbox: trueand proceeded. It disclosed that it had done so, but the sandbox was off for that command. Withsandbox.allowUnsandboxedCommandsdefaulting totrue, a transient setup failure is a reliable trigger for exactly that escape.Distinguishing transient setup failures from policy denials in the message surfaced to the model would help here. As it stands both read as "the sandbox stopped me", and only one of them is a reason to consider an unsandboxed retry.
---
🤖 Generated with Claude Code
Confirming
MIMALLOC_PURGE_DELAY=0on a third platform/version combination — butthe reason I'm posting is a question nobody in this thread has measured yet: does
the workaround weaken the sandbox it is meant to let succeed?
It does not, and here is the check.
Environment
| | |
|---|---|
| Claude Code | 2.1.237 (the runtime bundled with Claude Desktop) |
| Distro | Debian 13.6 |
| Kernel | 6.12.101 x86_64 |
| Machine | multi-core desktop, bare metal, no nesting |
The security check
Same helper invocation as above, but reporting from inside the sandbox it builds:
| | without the var | with
MIMALLOC_PURGE_DELAY=0||---|---|---|
|
NoNewPrivs| 1 | 1 ||
Seccomp| 2 (filter mode) | 2 (filter mode) ||
Seccomp_filters| 2 | 2 ||
uid_map|<uid> <uid> 1|<uid> <uid> 1|Line for line identical. The variable removes the reason the sandbox setup
intermittently failed; it changes nothing about what the built sandbox enforces.
Worth stating explicitly, because "set this env var and the seccomp error goes
away" reads like it could be doing the opposite.
Efficacy here
14,000 helper invocations per condition, alternating order, after a warm-up:
| condition | failures | rate |
|---|---|---|
| without | 16 / 14,000 | 0.114% |
| with
MIMALLOC_PURGE_DELAY=0| 0 / 14,000 | 0% |All 16 carried exactly
apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument.If the var were inert, zero in 14,000 would have probability
(1 - 0.00114)^14000≈ 1.1e-7.One sharper number for the mechanism
@martinstuecklschwaiger reports -35% wall time. Splitting that by where it goes is
more diagnostic than the total, since thread creation and reaping is kernel work.
3,000 invocations each:
| | without | with | change |
|---|---|---|---|
| real | 10.257s | 7.774s | -24.2% |
| user | 6.659s | 5.900s | -11.4% |
| sys | 3.567s | 1.914s | -46.3% |
Kernel time nearly halves. That is consistent with the thread not being spawned at
all, rather than the race merely resolving differently — a pure timing shift would
not remove the work.
One trap when adopting it via settings.json
@StructByLightning's
envblock in.claude/settings.jsonis the route I took,and it works — after a session restart,
env | grep MIMALLOCreturnsMIMALLOC_PURGE_DELAY=0inside Bash tool calls.That verification proves less than it looks like, though, and I got it wrong at
first. The Bash process having the variable does not establish that the
apply-seccomphelper had it — a parent can hand a child an environment viaexecvethat it does not carry itself, and the helper is where the race actuallyhappens. Checking directly is blocked by the sandbox itself: from inside, the
process chain ends at PID 1 and
/proc/1/environreturnsPermission denied, sothe helper sits outside that PID namespace.
So for anyone adopting this via
settings.json: the only evidence that it reachesthe helper is the failures staying gone over time. At a 0.114% base rate, one
uneventful session proves nothing; a few hundred tool calls without an occurrence
does.
Not a fix for this race, but a workaround category for anyone blocked by sandbox flakiness: external sandboxes sidestep the self-re-exec helper entirely.
vetto (mine, disclosure) creates the namespaces/seccomp/Landlock setup from a separate supervisor binary before the agent's bash ever spawns — there's no re-entrant helper racing the runtime, and the kernel primitives are the same ones the embedded helper is driving. Trade-off to be clear about: it's a wrapper process around
claude, not the built-in per-call sandbox, so granularity differs (whole session vs per Bash call).Also seeing this on x86_64 / Rocky Linux 9 (
kernel 5.14.0-687.26.1.el9_8), Claude Code2.1.236, bubblewrap sandbox on a busy multi-user login node — low, intermittent rate, consistent with your load-dependence analysis. Identical command succeeds on retry.Enterprise-managed deployment with
allowUnsandboxedCommands: false, so thedangerouslyDisableSandboxfallback you observed is blocked here, but the transient failures remain user-visible.+1 for the bounded-retry fix.
@bcherny what do we have to do to get this fixed?
@jerbob92 re "what do we have to do to get this fixed" — it may already be fixed. On the same machine as my Aug 24 comment, the race no longer reproduces on 2.1.255 and 2.1.260, while an older build still fails under identical conditions in the same run.
Environment
| | |
|---|---|
| Machine | same Debian 13 multi-core desktop as above, kernel now 6.12.107 x86_64 |
| Load | ~0.2, box up 9 days |
| Builds | 2.1.236 (apt package), 2.1.255 and 2.1.260 (Claude Desktop bundles) |
Method
Same as before — the helper invoked directly,
/bin/truepayload, 100 warm-up runs, stderr captured. All runs withoutMIMALLOC_PURGE_DELAY(env -u), since the question is whether the defect is still in the binary.Results
| build | invocations | failures | rate |
|---|---|---|---|
| 2.1.236 | 12,000 | 18 | 0.15% |
| 2.1.255 | 4,000 | 0 | 0 |
| 2.1.260 | 18,000 | 0 | 0 |
All 18 failures carried exactly
apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument.The control matters more than the zeros: 4,000 of the 2.1.236 runs and 4,000 of the 2.1.260 runs were interleaved in the same loop, 4 alternating blocks of 1,000 each — 2.1.236 failed 3 times, 2.1.260 zero times. So the difference is in the binary, not in kernel version or load.
If 2.1.260 were as prone as 2.1.236, zero failures in 18,000 would have probability
(1 - 0.0015)^18000≈ 2e-12.What still looks unchanged
MIMALLOC_PURGE_DELAY=0still cuts kernel time on 2.1.260 (3,000 invocations: sys 4.595s → 3.252s, -29%; on 2.1.237 it was -46%). So the scavenger thread appears to still be spawned. My guess — and it is only a guess — is that a retry/wait around theunsharelanded rather than the thread being removed. I can't find anything in CHANGELOG.md through 2.1.266 that mentions it.Sandbox state on 2.1.260 is identical with and without the var, same check as in my earlier comment (NoNewPrivs 1, Seccomp 2, Seccomp_filters 2, uid_map unchanged).
Caveats
@Mandelbrotmenge-21 I had Claude go through the changes in CLI between those versions but it didn't see a difference in the sandbox wrapper and/or some internal retry mechanism. I haven't confirmed myself yet whether the issue is really gone.
Update on anthropics/sandbox-runtime#479: it no longer retries. unshare(CLONE_NEWUSER) implies CLONE_THREAD and is refused with EINVAL whenever the thread group is non-empty — including a thread that exited a moment ago but is not yet released, which is the race here. clone(CLONE_NEWUSER|…) has no such check, and apply-seccomp already forks its inner init right after that point, so the PR now creates the user namespace on that clone and lets the child write its own uid/gid maps (the bubblewrap/
unshare -rpattern). Same host as before, in-process harness (thread joined, then apply-seccomp's main): unshare 200/200 EINVAL, clone 0/200; it also holds with the helper thread still alive, where a retry loop could never succeed.Re the 2.1.255+ zeros reported above: the library code on
mainstill calls unshare(CLONE_NEWUSER) and dies on EINVAL, so if those builds are clean it is because something in the bundle stopped parking a thread right before the sandbox setup, not because the race was closed. The PR removes the dependency on that.Correction to my last update on anthropics/sandbox-runtime#479: the raw
clone()version is withdrawn. It avoids theEINVAL, but a raw clone skips atfork handlers and glibc's fork-time lock reset. In a host with a live thread, the child can then hang forever in its nextfork()orperror(). In an in-process harness it hung 50/50 times with a thread cycling an atfork-guarded lock and 21/50 times with a thread cycling malloc/free.The PR now keeps the existing
unshareand, only onEINVAL, forks and redoes it in the single-threaded child, with the parent relaying signals and exit status. That completed 50/50 in the just-joined, atfork and malloc cases.On the zero-failure reports for newer builds: on 2.1.272 (arm64), strace of
ARGV0=apply-seccompshows no thread being created before the sandbox setup, so that build does not enter the race. The library code onmainstill dies on thisEINVAL, so any multithreadedseccomp.argv0embedder remains exposed until the PR lands.