[BUG] bwrap sandbox broken on merged-usr systems (Arch): "Can't mount tmpfs on /newroot/lib64" — enableWeakerNestedSandbox does not fix it, MCP servers fail to start

Status Open
Reported on v2.1.160
Maintainer reply None cached
Activity 8 comments · opened Jun 2, 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?

On Arch Linux (and any distro using merged-usr where /lib64 is a symlink), the bwrap sandbox fails unconditionally with:

bwrap: Can't mount tmpfs on /newroot/lib64: No such file or directory

This is because /lib64 is a symlink (/lib64 -> usr/lib) and bwrap tries to mount a tmpfs on the symlink target path before resolving it, which fails. Setting enableWeakerNestedSandbox: true does not fix this — the error persists. As a result, MCP servers cannot start at all (they are spawned by Claude Code at startup via the sandbox), and /mcp is unusable.

What Should Happen?

Sandbox starts successfully. Either bwrap uses --symlink usr/lib /lib64 instead of trying to bind-mount /lib64 directly, or enableWeakerNestedSandbox resolves symlinks before constructing the sandbox filesystem.

Error Messages/Logs

bwrap: Can't mount tmpfs on /newroot/lib64: No such file or directory

Steps to Reproduce

Use Arch Linux or any merged-usr distro where /lib64 is a symlink:

Claude Model

Sonnet (default)

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.160

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

Other

Additional Information

_No response_

View original on GitHub ↗

5 Comments

jonasced · 2 months ago

Similar issue with sandboxing on Ubuntu 22.04/v2.1.167, any attempt at file access such as !ls fails with bwrap: Can't mount tmpfs on /newroot/lib: No such file or directory even when it's done within the .claude/settings.json specified folders. Using bubblewrap 0.6.1.

realproject7 · 2 months ago

Corroborating with a fresh data point: this also reproduces on Ubuntu 26.04 (merged-usr) with Claude Code 2.1.168 — i.e. it's not specific to 22.04 or one CC version.

Symptom: every Bash tool call — including true and echo hello — returns exit code 1 with no stdout/stderr. File-read tools are unaffected; only the sandboxed Bash layer is dead. (Same error class @jonasced reported: bwrap: Can't mount tmpfs on /newroot/lib….)

Environment: Ubuntu 26.04 (KVM VM), bubblewrap 0.11.1, socat installed, run with --dangerously-skip-permissions.

The host itself is fully sandbox-capable — direct bwrap succeeds for every namespace:

bwrap --ro-bind / / --dev /dev --proc /proc --unshare-all -- /bin/echo OK   # → OK
bwrap --ro-bind / / --unshare-user --unshare-net -- /bin/echo OK            # → OK

…so it's Claude Code's specific sandbox invocation failing on merged-usr (where /lib64/usr/lib64 is a symlink), not a kernel/namespace restriction on the host.

Workarounds that do NOT fix it:

  • the AppArmor userns profile from #17727
  • sandbox.enabled: false (silently ignored on Linux — see #35986)
  • enableWeakerNestedSandbox: true (as already noted in the OP)
  • installing socat

Impact: completely blocks Bash for every Claude Code agent on the host — no build/test/commit possible. It also compounds with #17374 (sandbox fails in git worktrees), which is the common layout for worktree-based multi-agent setups.

Since merged-usr is the default on modern Ubuntu/Fedora/Arch, this likely hits a large share of Linux-server users. Happy to provide more diagnostics. 🙏

NoTuxNoBux · 2 months ago

On Arch with Claude CLI 2.1.177 I'm still seeing:

Exit code 1
bwrap: Can't mount tmpfs on /newroot/bin: No such file or directory

So a variant of the error from the OP, but bin always fails (first) rather than lib64, which is strange given that it's the same system, but likely the same issue anyway.

Workarounds that do NOT fix it: sandbox.enabled: false

FWIW, for me this does work around it. Of course this results in no sandboxing being applied, but it allows me to use Claude CLI for now. If I set that to true I immediately get the issue again.

c-seeger · 2 months ago

@bcherny any chance this gets priotized as disabling sandbox is not an option.

IgaSuke · 1 month ago

Workaround: bwrap wrapper script for usrmerge systems

(Since my English isn't great, I had Claude Code review the diff and write this message for me.)

Confirmed on Ubuntu 24.04 inside a Docker dev container (same usrmerge layout where /bin, /lib, /lib64, /sbin are symlinks to usr/*).

There are two failure modes on usrmerge:

  1. --tmpfs /bin (and /lib, /lib64, etc.) — bwrap tries to create a mount point on the symlink path before mounting; fails with "No such file or directory"
  2. --tmpfs /usr — blanks the entire /usr tree, so /bin/bash/usr/bin/bash → gone; bwrap exits with execvp /bin/bash: No such file or directory

Workaround

Install a thin wrapper at the path Claude Code calls as bwrap (configurable via managed-settings.json's sandbox.bwrapPath). The wrapper transforms the args before passing them to the real binary:

  • When it sees --tmpfs <usrmerge-path> (e.g. /bin, /lib, /lib64): replaces it with --symlink usr/bin /bin (preserving the symlink structure instead of trying to mount tmpfs on the symlink destination)
  • When it sees --tmpfs /usr: keeps --tmpfs /usr and immediately injects --dir /usr/bin --ro-bind /usr/bin /usr/bin (and lib, lib64, lib32, sbin) to restore the real contents after blanking

/usr/bin/bwrap (wrapper):

#!/bin/bash
REAL_BWRAP=/usr/bin/bwrap-real
USRMERGE=("/bin" "/lib" "/lib64" "/lib32" "/sbin")
newargs=()
seen_dirs=()

is_usrmerge() {
  local t="$1"
  for p in "${USRMERGE[@]}"; do [ "$t" = "$p" ] && return 0; done
  return 1
}

has_dir() {
  local t="$1"
  for d in "${seen_dirs[@]}"; do [ "$t" = "$d" ] && return 0; done
  return 1
}

inject() {
  local dest="$1"
  if is_usrmerge "$dest" && ! has_dir "$dest"; then
    seen_dirs+=("$dest")
    local real
    real=$(readlink -f "$dest" 2>/dev/null)
    if [ -n "$real" ] && [ "$real" != "$dest" ]; then
      ! has_dir "$real" && newargs+=(--dir "$real") && seen_dirs+=("$real")
    else
      newargs+=(--dir "$dest")
    fi
  fi
}

args=("$@")
i=0; n=${#args[@]}
while [ $i -lt $n ]; do
  arg="${args[$i]}"
  case "$arg" in
    --tmpfs)
      dest="${args[$((i+1))]}"
      if [ "$dest" = "/usr" ]; then
        newargs+=(--tmpfs /usr)
        for subdir in bin lib lib64 lib32 libx32 sbin; do
          full="/usr/$subdir"
          if [ -d "$full" ] && [ ! -L "$full" ]; then
            if ! has_dir "$full"; then
              seen_dirs+=("$full")
              newargs+=(--dir "$full" --ro-bind "$full" "$full")
            fi
          elif [ -L "$full" ]; then
            _lnk=$(readlink "$full" 2>/dev/null)
            [ -n "$_lnk" ] && ! has_dir "$full" && seen_dirs+=("$full") && newargs+=(--symlink "$_lnk" "$full")
          fi
        done
      elif is_usrmerge "$dest" && ! has_dir "$dest"; then
        _lnk=$(readlink "$dest" 2>/dev/null)
        if [ -n "$_lnk" ]; then
          seen_dirs+=("$dest")
          newargs+=(--symlink "$_lnk" "$dest")
        else
          newargs+=(--tmpfs "$dest")
        fi
      else
        newargs+=(--tmpfs "$dest")
      fi
      i=$((i+2)) ;;
    --proc|--dev|--mqueue|--remount-ro)
      inject "${args[$((i+1))]}"; newargs+=("$arg" "${args[$((i+1))]}"); i=$((i+2)) ;;
    --dir)
      seen_dirs+=("${args[$((i+1))]}"); newargs+=("$arg" "${args[$((i+1))]}"); i=$((i+2)) ;;
    --symlink)
      seen_dirs+=("${args[$((i+2))]}"); newargs+=("$arg" "${args[$((i+1))]}" "${args[$((i+2))]}"); i=$((i+3)) ;;
    --bind|--bind-try|--ro-bind|--ro-bind-try|--dev-bind|--dev-bind-try)
      inject "${args[$((i+2))]}"; newargs+=("$arg" "${args[$((i+1))]}" "${args[$((i+2))]}"); i=$((i+3)) ;;
    --file|--bind-data|--ro-bind-data)
      inject "${args[$((i+2))]}"; newargs+=("$arg" "${args[$((i+1))]}" "${args[$((i+2))]}"); i=$((i+3)) ;;
    --chmod)
      inject "${args[$((i+2))]}"; newargs+=("$arg" "${args[$((i+1))]}" "${args[$((i+2))]}"); i=$((i+3)) ;;
    --) newargs+=("$arg"); i=$((i+1))
      while [ $i -lt $n ]; do newargs+=("${args[$i]}"); i=$((i+1)); done; break ;;
    *) newargs+=("$arg"); i=$((i+1)) ;;
  esac
done

exec "$REAL_BWRAP" "${newargs[@]}"

Setup steps:

# 1. Move real bwrap
mv /usr/bin/bwrap /usr/bin/bwrap-real
# 2. Install wrapper (content above) at /usr/bin/bwrap
chmod +x /usr/bin/bwrap

# 3. Tell Claude Code to use this wrapper path
mkdir -p /etc/claude-code
cat > /etc/claude-code/managed-settings.json <<'EOF'
{ "sandbox": { "bwrapPath": "/usr/bin/bwrap" } }
EOF

# 4. Allow user namespace creation
#    (Docker/container only — plain Linux hosts usually don't need this)
sysctl -w user.max_user_namespaces=15000

The root fix in Claude Code would be to emit --symlink usr/bin /bin (etc.) instead of --tmpfs /bin when the target is a symlink on the host, and to rebind /usr/bin etc. after any --tmpfs /usr. Hope this helps narrow down the fix.

P.S. This workaround is just a temporary fix, so I'll be looking forward to an official patch.

Showing cached comments. Read the full discussion on GitHub ↗