[BUG] [claude-generated] sandbox filesystem: allowRead re-bind of a directory is shadowed by a later --tmpfs on a parent path

Status Open
Reported on v2.1.209
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 22, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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?

(This bug report is co-authored by a LLM. I hope this is acceptable for this project.)

When running plain claude (`~/.config/Claude/claude-code/2.1.209/claude, no claude-desktop), sandboxed bash commands do not see the allow-within-deny exception due to a wrong ordering of the bindings in bwrap. (/u/ and /u/pklenze` are different file systems, not that this should matter).

Rest of this post is claude-authored.

---

What's Wrong?

With a config like:

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "denyRead": ["/u/"],
      "allowRead": ["/u/pklenze/claude_work/"]
    }
  }
}

allowRead is supposed to re-open /u/pklenze/claude_work/ inside the denyRead'd /u/ region (per the docs: "the more specific path wins"). This works for individual files, but entire directories bound via allowRead are silently emptied, because of bwrap argument ordering.

Captured from ps during a session, the generated invocation includes (order preserved, trimmed):

--bind /u/pklenze/claude_work /u/pklenze/claude_work
--bind /tmp/claude-8001/ /tmp/claude-8001/
--tmpfs /u/
--ro-bind /dev/null /u/pklenze/claude_work/.claude/settings.json
--ro-bind /u/pklenze/claude_work/.claude/settings.local.json /u/pklenze/claude_work/.claude/settings.local.json
... (more individual --ro-bind file re-exposes)
--ro-bind /u/pklenze/claude_work/drasi/.git/config /u/pklenze/claude_work/drasi/.git/config
--ro-bind /u/pklenze/claude_work/epics/epicscorelibs/.gitmodules /u/pklenze/claude_work/epics/epicscorelibs/.gitmodules
--ro-bind /u/pklenze/claude_work/epics/pythonSoftIOC/.gitmodules /u/pklenze/claude_work/epics/pythonSoftIOC/.gitmodules

bwrap applies mounts in argument order, and a later mount on a parent path shadows everything mounted under it earlier. Here, --tmpfs /u/ is emitted after the --bind that should satisfy allowRead, so the entire claude_work bind is wiped. Only the small set of individual files Claude Code re-binds one-by-one after the tmpfs (mostly its own config, plus a few auto-detected git files) survive. Everything else under the allowed directory — including epics/pythonSoftIOC/.git/ in its entirety — becomes inaccessible with No such file or directory, even though denyRead/allowRead overlap resolution should make it readable.

Environment

  • OS: Linux
  • Sandbox: bubblewrap-based, sandbox.enabled: true, sandbox.filesystem.denyRead + allowRead

What Should Happen?

The bind for a directory covered by sandbox.filesystem.allowRead should be emitted after any --tmpfs mount on an ancestor path (or the tmpfs/deny mounts should be emitted first, then all allow re-binds applied on top), so the full directory tree under the allowed path is actually restored — not just the individually-curated files Claude Code happens to re-bind for its own config protection.

Error Messages/Logs

Steps to Reproduce

  1. Config:

``json
{
"sandbox": {
"enabled": true,
"filesystem": {
"denyRead": ["/u/"],
"allowRead": ["/u/<user>/claude_work/"]
}
}
}
``

  1. Have a git repo somewhere under /u/<user>/claude_work/, e.g. /u/<user>/claude_work/epics/pythonSoftIOC/.git.
  2. Start a Claude Code session, run a sandboxed Bash command, e.g. ls -la /u/<user>/claude_work/epics/pythonSoftIOC/.git.
  3. Observe: No such file or directory, despite the path being nested inside the allowRead directory and existing on the real filesystem with normal permissions.
  4. Inspect the actual bwrap invocation (e.g. via ps aux while a sandboxed command briefly runs) and note the --tmpfs /u/ argument appears after the --bind /u/<user>/claude_work ... argument.

Claude Model

Sonnet (default)

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.209

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Xterm

Additional Information

This is a distinct root cause from #25603 and #17727 (which are about wrong paths being targeted for git-internal protection artifacts). This is about mount ordering nullifying a legitimate allowRead directory re-open entirely.

View original on GitHub ↗

3 Comments

klenze · 1 month ago

Update: the reason I do not encounter this bug in claude-desktop is because when run from claude-desktop, claude runs bash commands entirely without bwrap, which I also consider surprising behavior.

klenze · 1 month ago

I now have a workaround.

My idea was to run the claude binary itself in bwrap to restrict access to /u/. That way I do not need to use `~/.claude/settings.json` to restrict file system access -- if claude can not read my home directory, then any processes it spawns will also not be able to do that.

#!/bin/bash
CLAUDE_WORK=$HOME/claude_work 
CMD="bwrap --die-with-parent --unshare-all --share-net --ro-bind / /"
CMD+=" --dev /dev --proc /proc --tmpfs /tmp --tmpfs /u/"

BINDS="$HOME/.claude $HOME/.config/Claude $HOME/.npm $HOME/.local/share/claude
 $HOME/.nvm $HOME/.gitconfig $HOME/.claude.json"
# add socat, which is in $HOME/bin for me
BINDS+=" $HOME/bin/socat"
BINDS+=" ${CLAUDE_WORK}"

for B in $BINDS
do
    CMD+=" --bind $B $B"
done
CMD+=" --chdir ${CLAUDE_WORK}"
CMD+=" -- $HOME/.config/Claude/claude-code/2.1.209/claude"
#CMD+=" -- /usr/bin/bash -i"
echo running $CMD
exec $CMD

Together with `"denyRead":[]`, this does what I want to do, though I still needed to lock down access to .claude/credentials from within the sandbox.

It would still be nice if the bug got fixed.

bcherny collaborator · 9 days ago

Thanks — capturing the actual mount argument order made this a very actionable report, and your analysis of the ordering semantics is correct.

I tested this on the current release (2.1.233) on Linux with an equivalent config (denyRead on a parent directory, allowRead on a subdirectory containing the working directory) and inspected the actual sandbox invocation:

  1. The exact ordering you captured on 2.1.209 — the allowed directory's bind emitted before the tmpfs on the denied parent, silently emptying the allowed directory — no longer occurs on 2.1.233. The denied parent is now mounted first, with the allow re-binds after it, so that specific silent-empty failure mode appears to have been fixed in a release since 2.1.209.
  1. However, the same config is still broken on 2.1.233 by a sibling ordering problem: the read-only re-bind of the allowRead directory is emitted after the read-write bind of the working directory nested inside it, shadowing the working directory read-only. Later sandbox-setup mounts that need to create files directly in the working directory then fail with bwrap: Can't create file at .../.mcp.json: Read-only file system — so every sandboxed command errors out at sandbox setup with this deny/allow shape. Unlike the silent empty directory you saw, this at least fails loudly, but the allowRead-inside-denyRead configuration remains unusable when the working directory sits inside the allowed region.

So: original symptom likely fixed since 2.1.209 (upgrading is worth trying), but keeping this open for the remaining ordering issue — mounts for allowed regions need to be applied ancestors-first so more specific binds aren't shadowed by their parents.

If you upgrade and still see the silent empty directory variant rather than the loud setup error, please post the captured mount ordering again along with your exact version.

🤖 Generated with Claude Code