[BUG] 2.1.216 sandbox regression: "bwrap: Can't mkdir /opt/.claude" — ancestor-walk denyWrite mountpoints fail-closed on non-root installs under root-owned dirs
Preflight Checklist
- [x] I have searched existing issues. The closest is #79606 — also a 2.1.216 sandbox regression that kills every Bash tool call before the command runs, but a different failure mode (root install;
apply-seccomp: write /proc/self/uid_map: Operation not permittedfrom the new--cap-drop ALLdefault). This report is a distinct regression from the same 2.1.216 sandbox hardening wave: a non-root install whose workspace/HOME sits under a root-owned ancestor directory, where the sandbox's ancestor-walk denyWrite mountpoints cannot be created. #43454 / #48304 (non-rootsetgroups) are yet another manifestation. - [x] This is a single bug report.
- [x] I am using the latest version of Claude Code (reproduced on 2.1.217).
What's Wrong?
On a non-root install (dedicated user, HOME=/home/<user>) running a background agent (claude --bg) with CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, where the current working directory is a git repo under a root-owned directory (/opt/continuous-claude/repo), every sandboxed Bash tool call dies before the user command runs:
bwrap: Can't mkdir /opt/.claude: Permission denied
2.1.215 works fine. 2.1.216 (and 2.1.217) break it. dangerouslyDisableSandbox: true does not help — the sandbox pre-flight mkdir fails first. Pinning back to 2.1.215 restores it.
The full set of failing mountpoints (captured by strace-ing the actual bwrap argv) is exactly 14: /opt/.claude ×12, /opt/.mcp.json, /home/.mcp.json.
Root cause: the sandbox walks every ancestor of CWD and HOME up to / and, for each ancestor Y, adds to the denyWrite set:
Y/.claude/{launch.json, workflows, routines, output-styles, scheduled_tasks.json,
loop.md, skills, commands, agents, hooks, settings.json, settings.local.json} (12)
Y/.mcp.json
(config-injection defense; unconditional — no settings gate.) A non-existent denyWrite path is enforced by having bwrap create the mountpoint and --ro-bind an empty dir / /dev/null over it. When the ancestor is root-owned and the process is non-root (here /opt, and /home), bwrap's mkdir of the mountpoint returns EACCES → bwrap aborts → all Bash fails.
The asymmetry is the bug: non-existent read-allow paths are skipped (Skipping non-existent read allow path appears in the sandbox debug log), but write-deny mountpoint creation is fail-closed. The deny-bind it is trying to create is redundant anyway — the kernel already denies the non-root process any write under a root-owned directory.
Reproduces / doesn't: running the same agent from a fully-writable ancestor chain (e.g. CWD /tmp/a/b/c) works with no workaround — confirming the failure is purely about a root-owned ancestor of CWD/HOME, not config-dir resolution.
Why the usual escapes don't work:
CLAUDE_CONFIG_DIRis irrelevant — the/optpaths come from the CWD/HOME ancestor walk, not config-dir resolution (and the env-scrubbed worker doesn't inherit it anyway).- Pre-creating the mountpoints requires writes under root-owned
/opt. - The scrub
allowWriteroots are hardcoded[/home /root /tmp /var /opt /run /mnt](settings can only union-add), andsandbox.bwrapPathsupplied via--settingsis ignored.
What Should Happen?
Sandboxed Bash should run on non-root installs whose workspace/HOME is under a root-owned dir, as it did in 2.1.215. Specifically: write-deny mountpoint creation should fail-open (skip the bind) when the mountpoint cannot be created under an unwritable ancestor — mirroring the existing read-allow "skip non-existent path" behavior. A deny-bind that cannot be created adds no protection the kernel is not already providing.
Workaround (keeps the sandbox / isolation intact)
Since the scrub worker invokes bwrap via bash -c "bwrap ..." (bare bwrap, PATH-resolved), a shim placed first on PATH fixes it without disabling the sandbox: drop exactly the --bind/--ro-bind pairs whose destination does not exist and whose nearest existing ancestor is not writable, then exec /usr/bin/bwrap with everything else. The dropped binds are all paths the kernel already blocks, so isolation is unchanged; deny-binds at writable locations are preserved. Verified on 2.1.217 with SUBPROCESS_ENV_SCRUB=1 — all 14 un-createable binds dropped, Bash restored, every other bind/tmpfs/seccomp arg passed through untouched.
<details><summary>shim</summary>
#!/bin/bash
args=()
while [ $# -gt 0 ]; do
case "$1" in
--ro-bind|--bind|--ro-bind-try|--bind-try|--dev-bind)
dst=$3
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then
p=$dst; while [ ! -e "$p" ]; do p=$(dirname "$p"); done
if [ ! -w "$p" ] || [ ! -x "$p" ]; then shift 3; continue; fi
fi
args+=("$1" "$2" "$3"); shift 3 ;;
--setenv) args+=("$1" "$2" "$3"); shift 3 ;;
--tmpfs|--dev|--proc|--cap-drop|--unsetenv|--chdir|--hostname) args+=("$1" "$2"); shift 2 ;;
--) args+=("$@"); break ;;
*) args+=("$1"); shift ;;
esac
done
exec /usr/bin/bwrap "${args[@]}"
</details>
Environment
- Claude Code 2.1.216 / 2.1.217 (2.1.215 unaffected)
- Linux, non-root install (dedicated
claude-agentuser), background agent (claude --bg) CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1- CWD = git repo under root-owned
/opt(/opt/continuous-claude/repo) - Related: #79606 (sibling 2.1.216 sandbox regression, different code path / fix)
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
I have this exact issue, reverting to 2.1.215 fixes it as well.
Interestingly it still breaks again if this is set "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB": "1",, but is fine if it's not set. This only works for version 2.1.215. For 2.1.216/7 always break regardless of this setting.
Same here and works with 2.1.215 revert.
Confirming the same failure on WSL2, with an org-wide delivery vector and one aggravating detail.
Env: Claude Code v2.1.217 (CLI) and v2.1.198 (VS Code extension), WSL2 Ubuntu 22.04, bubblewrap 0.6.1, project under /home/<user>/repos/....
Symptom: identical — every Bash tool call dies during sandbox setup with bwrap: Can't create file at /home/.mcp.json: Permission denied. Standalone bwrap --ro-bind / / --bind /tmp /tmp --dev /dev echo ok works, so the binary is fine.
Aggravating factor — server-managed settings force the sandbox with no client-side opt-out: our Team org delivers CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 via server-managed settings (claude.ai admin console). Per the docs, when SCRUB is set, sandbox.filesystem.disabled / sandbox.enabled: false are ignored from every source — so the sandbox is forced on, user settings cannot disable it, and the managed env cannot be overridden (managed > user). Net effect: every WSL user in the org with a project under /home/<user>/... loses all Bash.
Workaround we had to take: temporarily set CLAUDE_CODE_SUBPROCESS_ENV_SCRUB to "0" in the org console — i.e. give up the credential-scrub control org-wide until this is fixed.
Possibly related: the deny-mask leak in #79785 — on this setup we also found 12 leaked 0-byte read-only nodes (.bashrc, .gitconfig, .mcp.json, .github, scripts, …) in a repo root.
Expected: skip deny-mask mountpoints in unwritable ancestors (they are already unwritable), or don't fail the whole sandbox closed on EACCES for these masks; and please reconsider SCRUB forcing filesystem isolation with no admin-side escape hatch.