[BUG] Sandbox masking of nonexistent ~/.claude/.config.json creates a transient empty file visible system-wide; concurrent sessions fail with 'contains invalid JSON'

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

Summary

When the Bash-tool sandbox masks ~/.claude/.config.json (part of the default filesystem deny list) and that file does not exist, sandbox setup creates a real zero-byte, mode 0444 file at the real path to serve as a bind-mount point for /dev/null, and deletes it again at teardown. For the whole duration of the sandboxed command this empty file is visible to every other process on the machine. Any claude process that starts inside that window reads the empty file and aborts the config load with:

The configuration file at /home/<user>/.claude/.config.json contains invalid JSON.

With several parallel sessions (each running sandboxed Bash commands regularly), these windows are frequent, so the error appears seemingly at random when opening or resuming sessions. The file's absence is the normal state on this install — Claude Code 2.1.220 itself never creates it — so the error is always a false positive caused by the sandbox's own placeholder.

Environment

  • Claude Code 2.1.220 (native install, autoUpdatesChannel: latest); also observed on 2.1.212
  • Ubuntu 24.04.4 LTS, kernel 7.0.0-28-generic
  • Sandbox enabled (default sandbox.network/filesystem settings; ~/.claude/.config.json is in the built-in write-deny set)

Reproduction

  1. Ensure ~/.claude/.config.json does not exist.
  2. In session A, run any long sandboxed Bash command, e.g. sleep 30.
  3. While it runs, from a plain shell:

``
$ ls -la ~/.claude/.config.json
-r--r--r-- 1 user user 0 Aug 1 11:46 /home/user/.claude/.config.json
``

  1. Start a new claude session (or claude -p ...) while the command is still running → the "contains invalid JSON" error is printed at startup.
  2. Let the sandboxed command finish → the file disappears:

``
$ ls -la ~/.claude/.config.json
ls: cannot access '/home/user/.claude/.config.json': No such file or directory
``

Inside the sandbox the same path appears as a character device 1:3, i.e. the /dev/null bind mount over the placeholder:

$ ls -la ~/.claude/.config.json   # from within a sandboxed command
crw-rw-rw- 1 nobody nogroup 1, 3 Aug  1 05:49 /home/user/.claude/.config.json

Expected behaviour

Masking a path that does not exist should not materialise a world-readable empty file at the real location. Options: create the mount point inside a private mount namespace only, mount over a tmpfs stub, or treat a missing deny-list target as "nothing to mask".

Impact

  • Spurious "invalid JSON" startup errors whenever a session starts concurrently with any sandboxed command in any other session. Misleading: it looks like config corruption and invites users to "repair" a file that does not actually exist.
  • Suspected, not verified: repeated ~/.claude/backups/.claude.json.backup.<epoch> files are written in bursts that coincide with these windows, suggesting sessions also trigger backup/recovery logic off the phantom empty config.

View original on GitHub ↗

3 Comments

PSchulz-LFI · 1 month ago

We hit a persistent variant of this that escalates the impact from transient/random startup failures to a permanent outage of all new sessions until manual cleanup.

Environment: Claude Code 2.1.241, Debian 13.6 (kernel 6.12.96+deb13-amd64), bubblewrap 0.11.0-2+deb13u1, sandbox enabled with default filesystem protections, AD domain account via SSSD.

What happened (2026-08-24):

  • At one session start, sandbox teardown apparently never ran. All mask placeholders for the deny-listed paths that didn't exist — 19 of them (.config.json, hooks, commands, settings.local.json, rules, workflows, …) — were left behind in the real ~/.claude as 0-byte mode-0444 files sharing one mtime.
  • Every subsequent session start then failed with The configuration file at ~/.claude/.config.json contains invalid JSON. JSON Parse error: Unexpected EOF — permanently, not just during a command window.
  • The corrupt-config auto-recovery copied the file to backups/.config.json.corrupted.<epoch> but could not replace the original because the placeholder is read-only (0444), so it never self-healed.
  • Recovery required manually moving all 19 files out of ~/.claude from a plain shell. The remaining placeholders matter too: hooks/commands/output-styles shadow directories and fail silently, so writing {} into .config.json is not a fix.
  • Likely trigger: the same morning we saw intermittent apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument failures (#86928 / #87138). Kernel userns is healthy (unshare -U true and direct bwrap both succeed).
  • Diagnosis trap for anyone else debugging this: from inside any sandboxed session the placeholders present as crw-rw-rw- nobody nogroup 1,3 character devices, which looks like directory corruption.

Suggestions beyond the masking fix proposed above:

  1. Make placeholder cleanup crash-safe, or sweep stale ones at session start — a deny-listed path that is a 0-byte 0444 regular file is unambiguously identifiable.
  2. Treat a present-but-empty .config.json as absent at startup, and/or let the corrupt-config recovery unlink a read-only file it already backed up.

Happy to provide timestamps and directory listings from the incident if useful.

bcherny collaborator · 1 month ago

Partially reproduced on v2.1.233, with the mechanism corroborated.

What we reproduced directly: with a zero-byte ~/.claude/.config.json present (the exact state your ls shows during a sandboxed command's window), starting a new session fails immediately with a config-corruption error (JSON Parse error: Unexpected EOF on that path), and each such start also writes a backup file under ~/.claude/backups/ (.config.json.corrupted.<timestamp>). That confirms both the spurious startup error and your suspected backup-burst side effect — a concurrent session even moves the placeholder aside as a "corrupted" file while the sandbox still has it mounted.

What we couldn't observe directly: our test environment is itself a container that can't start the Linux sandbox (nested user-namespace restrictions), so we couldn't watch the transient file appear during a live sandboxed command. Your evidence is consistent with how missing deny-listed paths are handled on Linux, though: a path that must be masked but doesn't exist gets a real stub file created at the real location so /dev/null can be bind-mounted over it — which matches your outside view (0-byte, mode 0444, gone at teardown) and your inside view (character device 1:3) precisely.

Nothing in the changelog through current releases indicates this has been fixed.

🤖 Generated with Claude Code

dannewman70 · 10 days ago

Seeing the same mechanism on 2.1.288 and 2.1.289 (native install, Linux x64 on WSL2), with a different symptom: the other session's sandbox fails to start instead of reading bad JSON.

bwrap: Can't find source path /home/<user>/.claude/local: No such file or directory
bwrap: Can't get type of source /home/<user>/.claude/etc: No such file or directory

The deny paths ~/.claude/local, ~/.claude/etc and ~/.claude/workflows don't exist on the host. During each sandboxed command a 0-byte placeholder appears at each of them on the host filesystem and is removed when the command exits. If a second session builds its sandbox while the first session's placeholder exists, it binds the path as a source; if the first session deletes it before the mount, the second session's Bash call dies before running. An identical retry succeeds.

Deterministic repro without a second session. From an unsandboxed shell, churn the placeholder:

end=$((SECONDS+40)); while [ $SECONDS -lt $end ]; do touch ~/.claude/local; sleep 0.05; rm -f ~/.claude/local; sleep 0.05; done

While it runs, issue trivial sandboxed commands (echo probe). Some fail with Can't find source path /home/<user>/.claude/local.

Workaround. mkdir ~/.claude/local ~/.claude/etc ~/.claude/workflows. The sandbox then binds the existing directories and creates no placeholders; a host stat during a sandboxed command shows them unchanged, and claude doctor does not treat the empty ~/.claude/local as a legacy install.

The same churn on a DrvFS/OneDrive-backed additional working directory shows up as bwrap: Can't create file at …/.mcp.json: Permission denied, and can leave the placeholder behind (claude doctor reports it as a stale sandbox mask file).

The --ro-bind-try approach suggested in #79248 would cover this too. Related: #81602.