[BUG] Bash sandbox (bwrap) fails intermittently with different errors each run on WSL2 + Docker Engine — race condition, not a permissions issue

Status Open
Maintainer reply None cached
Activity 0 comments · opened Aug 25, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

---
Summary

The Bash tool's nested bwrap sandbox fails intermittently on WSL2 + Docker Engine, with different specific error messages and paths across otherwise-identical invocations (same container, same image, same command). A full strace of a failing invocation shows every traced syscall — including every capset() call — returning success, while the sandbox setup still fails. This points to a race condition in the CLI's dynamic bind-mount/placeholder-path setup, not a permissions, seccomp, capability, or LSM issue in the host/container environment.

Environment

  • Host: Windows 11, WSL2 (Ubuntu 26.04 LTS), kernel 6.18.33.2-microsoft-standard-WSL2
  • Docker: Docker Engine (docker-ce, not Docker Desktop) 29.7.2, installed natively inside the WSL2 distro; runc runtime, cgroup v2, systemd cgroup driver
  • Container: built from a project's own Dockerfile (Node 22 / Debian bookworm-slim base), non-root node user (uid 1000), bubblewrap installed with chmod u+s /usr/bin/bwrap (the documented Docker-compat workaround)
  • Claude Code CLI: v2.1.245
  • Org uses a managed managed-settings.json with sandbox.enabled: true, sandbox.failIfUnavailable: true, permissions.allowUnsandboxedCommands: false — i.e. no unsandboxed fallback is available when the sandbox fails to initialize

Symptom

Across one investigation session, the Bash tool failed with four distinct bwrap error messages, on different specific paths, non-deterministically, on what was otherwise the identical container/image/command:

  1. bwrap: Can't mkdir /mnt/c/Users/<wrong-windows-username>/.claude: Permission denied — a Windows-side path used for sandbox masking resolves to the wrong local Windows account. Matches #80284's shape (empty username there; a different real-but-wrong account here).
  2. bwrap: Can't create file at /mnt/c/.mcp.json: Permission denied — the project's own .mcp.json gets path-truncated to the drive root, dropping the project-folder path segment. Reproduced with the file present, absent, and restored — happens regardless of file state.
  3. bwrap: capset failed: Operation not permitted — see strace evidence below; no failing capset() call is present in the trace of an invocation that showed this exact message moments earlier in the same container.
  4. bwrap: Can't create file at /home/node/.local/bin: Permission denied — seen during the same diagnostic session as #3, on the same running container, moments after a trace showed no capability failures at all.

What was ruled out (with direct evidence, not just "tried it, no change")

  • dangerouslyDisableSandbox: true — identical failure.
  • Workspace location — moved the project off /mnt/c onto the WSL2 distro's native ext4 filesystem entirely; same failures occur regardless.
  • Fresh vs. resumed container — a genuinely fresh docker compose run --rm container fails identically to a resumed one.
  • Seccomp — applied a targeted custom profile (Docker's default + explicit allow for clone/clone3/unshare/setns/mount/umount2/pivot_root), confirmed correctly applied via docker inspect. No change. Also, capget/capset were always in Docker's default profile's unconditional-allow list.
  • no_new_privs — confirmed 0 via /proc/1/status and via systemctl show docker.service/containerd.service -p NoNewPrivileges (both no).
  • Capability bounding set — added SYS_ADMIN to cap_add. No change.
  • AppArmor — docker info shows no apparmor entry in Security Options; aa-status confirms the module is loaded but the apparmor filesystem isn't mounted, so Docker never enforces any profile on this kernel. apparmor:unconfined was a no-op.
  • Direct proof bwrap itself works correctly, as the same unprivileged node user, in the same container: manually ran the representative smoke test (bwrap --die-with-parent --new-session --unshare-user --unshare-ipc --unshare-pid --unshare-net --unshare-uts --ro-bind / / --proc /proc true) under strace. Exit code 0, every syscall succeeds.

Strace evidence from an actual failing Bash tool invocation

Attached strace -f -p 1 to the live claude process and triggered real Bash tool calls. The actual invocation is far more elaborate than any smoke test — dozens of per-file bind mounts, including a credential-scrub pass bind-mounting /dev/null over discovered .env*/.netrc/.npmrc/.zprofile/etc. Notably, .local gets bind-mounted then immediately remounted MS_RDONLY — which would exactly explain symptom #4 if a placeholder for .local/bin were created after that remount instead of before it.

Grepping the whole trace for capset|EPERM|exited with across three separate Bash invocations: every capset() call returns 0, zero failing capability calls anywhere, yet each invocation's process group still exits with status 1. The UI showed two different error messages across those same invocations, neither corresponding to a failing syscall visible in the trace.

Hypothesis

A race condition in the CLI's dynamic sandbox setup — likely creating/touching a bind-mount placeholder path inside a directory that was already remounted read-only moments earlier in the same setup sequence. This explains why the specific failing path varies run to run, why no environment-level relaxation ever fixed it, and why a simpler manual bwrap invocation never reproduces it.

Impact

Under sandbox.failIfUnavailable: true + permissions.allowUnsandboxedCommands: false, this makes the Bash tool entirely and unpredictably unusable, with no fallback.

Ask

  • Does the sandbox setup ever create a bind-mount placeholder after remounting its parent directory read-only, in a way that could race depending on discovery order?
  • Is there verbose/debug logging available for the CLI's actual bwrap invocation construction?
  • Is this the same root cause as #80284 resurfacing differently, or a separate bug in the same subsystem?

---

What Should Happen?

Expected behavior: Given an unchanged container, image, and command, the Bash tool's sandbox setup should behave deterministically — either it reliably succeeds every time, or it reliably fails with the same actionable error every time. It should not produce four different specific error messages (on four different specific paths) across otherwise-identical invocations. Concretely: sandbox setup shouldn't attempt to create/touch a bind-mount placeholder inside a directory it has already remounted read-only earlier in the same setup pass — that ordering should be fixed regardless of which paths happen to be discovered in what sequence.

Error Messages/Logs

--- Error 1 (session start, native WSL2 host) ---
bwrap: Can't mkdir /mnt/c/Users/roblocal/.claude: Permission denied

--- Error 2 (after removing wrong-user trigger, .mcp.json path truncation) ---
bwrap: Can't create file at /mnt/c/.mcp.json: Permission denied

--- Error 3 (containerized, fresh docker compose run --rm, multiple retries) ---
bwrap: capset failed: Operation not pe

--- Error 4 (same container, same straures above) ---
bwrap: Can't create file at /home/node/.local/bin: Permission denied

--- docker inspect confirming the custom seccomp profile was applied (Error 3 persisted anyway) ---
docker inspect <container> --format '{
[seccomp={"defaultAction":"SCMP_ACT_ERRNO", ... "clone","clone3","unshare","setns","mount","umount2","pivot_root"] ... }]

--- no_new_privs confirmed 0 (rules out setuid-escalation-blocked theory) ---
docker exec <container> cat /proc/1/st
NoNewPrivs:     0

systemctl show docker.service -p NoNewPrivileges
systemctl show containerd.service -p NoNewPrivileges
NoNewPrivileges=no
NoNewPrivileges=no

--- AppArmor never enforcing on this kernel (apparmor:unconfined was a no-op) ---
docker info | grep -A3 "Security Options"
 Security Options:
  seccomp
   Profile: builtin
  cgroupns

aa-status
apparmor module is loaded.
apparmor filesystem is not mounted.

--- Manual bwrap reproduction as unprivileged `node` user: SUCCEEDS (exit 0) ---
strace -f -e trace=clone,clone3,unsharet,capget \
  -o /tmp/bwrap-trace-node.txt \
  bwrap --die-with-parent --new-sessioc --unshare-pid \
    --unshare-net --unshare-uts --ro-bind / / --proc /proc true
exit code: 0
452   capget(...) = 0
452   clone(flags=CLONE_NEWNS|CLONE_NEWUTS|CLONE_NEWIPC|CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD) = 453
452   capset(..., {effective=0, permit
453   mount(...) = 0   [... all mounts succeed ...]
453   pivot_root(".", ".") = 0
453   capset(..., {effective=0, permit
454   +++ exited with 0 +++
452   +++ exited with 0 +++
453   +++ exited with 0 +++

--- strace -f -p 1 attached to the live `claude` process during a real, UI-visible failure ---
(grep -n -E "capset|EPERM|exited with" separate Bash
tool invocations, PID groups 417-419 / 435-437 / 455-457)

417   capset(..., {effective=1<<CAP_SE_ADMIN|1<<CAP_SYS_CHROOT|1<<CAP_SYS_PTRACE|1<<CAP_SYS_ADMIN, ...}) = 0
417   capset(..., {effective=0, permitted=0, inheritable=0}) = 0
418   capset(..., {effective=1<<CAP_SETGID|..., ...}) = 0
419   capset(..., {effective=0, permit
419   +++ exited with 1 +++
418   +++ exited with 1 +++
417   +++ exited with 1 +++
[... identical pattern repeats for 435

grep -c "capset(" /tmp/live-trace.txt   # every match above returns "= 0"; zero EPERM lines exist in the file

--- what the Claude Code UI actually displayed for these same invocations ---
Invocation A: "bwrap: capset failed: Operation not permitted"
Invocation B (same session, moments lale at /home/node/.local/bin: Permission denied"

Steps to Reproduce

Steps to Reproduce

Note: This is intermittent — a single Bash tool call may succeed. Reproducing may take several consecutive Bash invocations in the same session. We have not yet reduced this to a fully minimal standalone Dockerfile independent of this project's own build; steps below reproduce it via the actual setup that hit the bug, with the minimal-repro Dockerfile fields called out separately.

  1. Host: Windows 11 with WSL2, distro Ubuntu 26.04 LTS, kernel 6.18.33.2-microsoft-standard-WSL2. Docker Engine (docker-ce, not Docker Desktop) installed natively inside the distro (apt-get install docker-ce docker-ce-cli containerd.io), so dockerd runs on the distro itself.
  2. Dockerfile — the essential elements needed to reproduce (full file available on request):

FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends bubblewrap git curl \
&& chmod u+s /usr/bin/bwrap
RUN npm install -g @anthropic-ai/claude-code@latest
RUN groupmod -o -g 1000 node && usermod -o -u 1000 -g 1000 node \
&& mkdir -p /workspace /home/node/ /workspace /home/node
WORKDIR /workspace
USER node
CMD ["claude"]

  1. docker-compose.yml (or equivalent docker run) mounting a real project directory read-write at /workspace, plus a named volume for /home/node/.claude (persists CLI auth/config across container recreation). No security_opt or extra lts needed to reproduce the bug — those were added later purely as failed diagnostic attempts.
  2. Build and start:

docker compose build
docker compose run --rm claude-code

  1. This lands you at the claude CLI prompt inside the container, running as the non-root node user (uid 1000).
  2. Complete first-run login/workspace-trust prompts if shown.
  3. Run several Bash tool invocations in a row within the same claude session — e.g. ask it to run echo hello, then echo test2, then pwd, etc.ughly 1 in 3 attempts failed; exactrate not measured precisely.)
  4. Observed: some invocations succeed normally; others fail before running the actual command, witseveral different bwrap error messagestion) — the specific path/messagevaries between failures on the same unchanged container.
  5. To go deeper (optional, used to gather the strace evidence in this report): from a second host terminal, docker ps to find the contaitainer> bash to get a root shell inside it, install strace, then strace -f -p 1 -e trace=clone,clone3,unshare,setns,mount,pivot_root,capset,capget,execve -o /tmp/trace.txt while triggering Bash tool calls from the actual claudeE "capset|EPERM|exited with"/tmp/trace.txt shows every capset() succeeding even on invocations the UI reports as failed.

Claude Model

Sonnet (default)

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

v2.1.245

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

VS Code integrated terminal

Additional Information

---
Configuration files (attached/inlined, sanitized of client/org-identifying content):

  • Dockerfile — the container build (Node 22, bubblewrap + chmod u+s, non-root node user)
  • docker-compose.yml — service definition, volume mounts, and the diagnostic security_opt/cap_add entries added during investigation (none of which fixed the issue — included for completeness, not as a suggested fix)
  • policy/seccomp/claude-nested-sandbox.json — the custom seccomp profile tested (Docker's default + 7 additional allowed syscalls); confirmed via docker inspect to be correctly applied, and confirmed not to be the fix

View original on GitHub ↗