Sandboxed Bash: "Can't create file at <path>" when permissions.deny Read globs expand to symlink-spelled paths (bind destination uncreatable)
Environment
- Claude Code: 2.1.263 (native CLI), Linux x64
- OS: Ubuntu 24.04 (kernel 6.8.0-139-generic), bubblewrap 0.9.0-1ubuntu0.1
- Sandboxing enabled globally (
sandbox.enabled: truein~/.claude/settings.json) - Project
permissions.denycontains broadRead(...)deny globs over a
build/dependency cache tree (structure described below; exact patterns withheld
from a public issue, available on request)
Observed
In a project whose deny globs cover a cache tree containing symlinked
directories, every sandboxed Bash command fails immediately at sandbox
construction and nothing executes:
Can't create file at /home/<user>/<project>/.npm-cache/<build-root>/<hash>/<uuid>/external/<pkg>/.envrc: No such file or directory
This is fatal for the whole sandbox, so all Bash tooling in that project is
unusable: in one recorded session, 18 out of 18 distinct commands failed
identically this way.
Trigger condition (analysis)
permissions.deny Read globs are expanded per-session into ~130 concrete
absolute paths. The expansion walks the real filesystem, and when it traverses
a directory symlink (in our case a Bazel workspace convenience symlink whose
target lives elsewhere on disk, e.g. bazel/... vs the realpath twin underbazel-contents-cache/<hash>/<uuid>/...), the produced deny paths are
spelled through the symlink.
The deny list therefore contains the same concrete content twice - once
spelled, once realpath-resolved - and each entry is handed to the sandbox as a
bind at the spelled destination. Inside the sandbox, a destination whose
path crosses an existing symlink cannot be created, and bwrap aborts withCan't create file at, killing the entire namespace rather than just that one
entry.
Supporting evidence from fresh A/B sessions on the same tree:
- Before: every sandboxed command dies instantly at mount construction with
Can't create file at <spelled-path> (18/18 commands).
- After adding a project-level carve-out:
{ "sandbox": { "filesystem": {
"allowRead": [ "/home/<user>/<project>/.npm-cache/codex-host-build/bazel/**/*" ] } } }
the sandbox's existing re-allowed-deny skip logic (debug log:
Skipping read deny for re-allowed path) drops the spelled-path binds, mount
construction completes, and execution proceeds to the later seccomp stage
(a separate issue - different layer, unrelated root cause). Same deny list,
same tree; the only delta is the allowRead carve-out.
Suggested fix
- Realpath-resolve bind destinations. When constructing deny binds, resolve
each expanded deny path to its realpath and bind at the realpath destination
(that is the content the deny is meant to protect anyway). Spelled-vs-realpath
duplicates collapse naturally.
- Actionable fatal error. If a bind destination still cannot be created,
fail with a message naming the originating permissions.deny entry (the
current error names only the concrete path, so the user has no idea which
settings glob produced it).
Note: I deliberately did not suggest silently skipping uncreatable deny
binds - a denied path that silently isn't masked would be a protection hole.
Workaround used
A scoped sandbox.filesystem.allowRead glob in project-local settings covering
the symlink-spelled subtree, so the failing binds hit the existing
"re-allowed path" skip. Caveat: this also unmasks reads under that subtree,
which is a deliberate, owner-approved trade-off and should not be needed once
destinations are realpath-resolved.
Not attaching transcripts (they contain project source); happy to provide the
debug-log excerpts and an anonymized minimal repro on request.