[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

Status Fixed / completed
Reported on v2.1.216
Maintainer reply None cached
Activity 6 comments · opened Jul 22, 2026 · closed Aug 16, 2026

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 permitted from the new --cap-drop ALL default). 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-root setgroups) 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_DIR is irrelevant — the /opt paths 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 allowWrite roots are hardcoded [/home /root /tmp /var /opt /run /mnt] (settings can only union-add), and sandbox.bwrapPath supplied via --settings is 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-agent user), 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)

View original on GitHub ↗

3 Comments

DoubleAgentDave · 1 month ago

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.

daniel-advisori · 1 month ago

Same here and works with 2.1.215 revert.

YoshiHi-code · 1 month ago

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.

Showing cached comments. Read the full discussion on GitHub ↗