sandbox.enabled: true triggers a synchronous full-tree directory walk on startup → multi-minute hang in large workspaces
Summary
With sandbox.enabled: true, Claude Code performs a synchronous, single-threaded recursive walk of the entire working-tree directory structure on every prompt, before the first API request. It runs on the JS main thread, does not respect .gitignore/.ignore/.claudeignore, and follows symlinks (re-traversing self-referential loops). Its duration scales with the number of directories. With sandbox.enabled: false, the identical workspace is instant.
Real-world impact: in a ~256k-directory workspace, every prompt hangs ~171s with sandbox on vs ~5s with it off.
Environment
- Claude Code 2.1.177 (also reproduced on 2.1.165 / 2.1.175 / 2.1.176 — not a version regression)
- Linux x86-64, native install; bubblewrap 0.11.0
---
Reproducer A — minimal, isolates the symlink-following defect
One self-referential symlink + a few thousand directories. Both ingredients are individually harmless; only the combination hangs.
#!/usr/bin/env bash
set -euo pipefail
K="${K:-6000}"; BIN="${BIN:-claude}"
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
mkdir -p "$T/.claude" "$T/base"; git -C "$T" init -q
# a few thousand ordinary directories
( cd "$T/base"; i=0; while [ "$i" -lt "$K" ]; do mkdir -p "p$((i/50))/s$i"; i=$((i+1)); done )
# ONE absolute, self-referential symlink loop
# (mimics Composer's orchestra/testbench: <pkg>/vendor/.../laravel/vendor -> <pkg>/vendor)
ln -s "$T/base" "$T/base/self_loop"
probe(){ local s=$SECONDS
( cd "$T" && timeout 600 "$BIN" -p "say hi" >/dev/null 2>&1 || true )
printf ' %-26s %ss\n' "$1" "$((SECONDS-s))"; }
printf '{"sandbox":{"enabled":false}}\n' > "$T/.claude/settings.local.json"
probe "loop, sandbox DISABLED"
rm "$T/base/self_loop"
printf '{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":true}}\n' > "$T/.claude/settings.local.json"
probe "no loop, sandbox ENABLED"
ln -s "$T/base" "$T/base/self_loop"
probe "loop, sandbox ENABLED"
Results (6,121 base directories):
loop, sandbox DISABLED 5s <- harmless on its own
no loop, sandbox ENABLED 7s <- harmless on its own
loop, sandbox ENABLED 55s <- only the combination hangs
Raise K= to make the hang arbitrarily long. Verified to reproduce even under a pristine CLAUDE_CONFIG_DIR (no global settings, hooks, or plugins) — so it is the project sandbox setting, not any global config.
Reproducer B — pure scale (no symlinks), confirms the walk is the whole tree
#!/usr/bin/env bash
set -euo pipefail
N="${N:-100000}"; BIN="${BIN:-claude}"
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
cd "$T"; git init -q; mkdir -p .claude
i=0; while [ "$i" -lt $(( N/500 + 1 )) ]; do mkdir -p "d$i"/sub{1..500}; i=$((i+1)); done
probe(){ local s=$SECONDS; timeout 600 "$BIN" -p "say hi" >/dev/null 2>&1 || true; printf ' %-18s %ss\n' "$1" "$((SECONDS-s))"; }
printf '{"sandbox":{"enabled":false}}\n' > .claude/settings.local.json; probe "sandbox DISABLED"
printf '{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":true}}\n' > .claude/settings.local.json; probe "sandbox ENABLED"
Results (100,713 directories): sandbox DISABLED 7s vs sandbox ENABLED 21s. The gap grows with directory count (256k dirs → 5s vs 171s in the real workspace).
---
Expected vs actual
- Expected: enabling the sandbox should not add startup latency proportional to the whole working tree. The filesystem-view setup should be lazy/bounded/off-main-thread, prune
.git/vendor/node_modules, honor ignore files, and not follow symlink cycles. - Actual: a blocking O(directories) walk on the main thread before every request, ignoring all ignore rules and following symlink loops.
Root-cause evidence
- gdb sampling of the pegged process: the hot leaf is the instruction immediately after a
syscallwithrax=0x101(openat) andflags=0x10000(O_DIRECTORY) — a tight loop opening directory after directory, single JS caller frame. - Single-threaded on the main JS thread (worker/GC threads idle).
- Isolation: removing the project
.claude/settings.local.json→ fast; a config of only{"sandbox":{"enabled":true}}→ slow; same config with thesandboxkey removed → fast.permissions/enabledPlugins/ LSP plugins /.git/CLAUDE.md/ Claude version all ruled out by direct test. - The two self-referential Composer
vendorsymlinks in the real workspace roughly doubled the time (83s → 171s) via re-traversal; an isolated single loop on a tiny tree is handled fine, so cycle handling exists but is overwhelmed at scale.
Impact
Any large workspace (monorepos, multi-repo meta-checkouts, trees with big vendor//node_modules/) becomes unusable at the root with sandbox enabled — every prompt blocks for minutes.
Workarounds
sandbox.enabled: falsefor the workspace (loses sandboxing).- Launch from a smaller subdirectory.
- Remove self-referential symlinks from the tree.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Corroborating on
2.1.195with a real-world pnpm monorepo — and adding a more severe failure mode than the multi-minute hang: on a pnpm symlink farm this walk is effectively unbounded → OOM, not just slow.Severity: the main
claudeprocess spins (R) and RSS climbs ~2–2.5 GB/min (~290 MB → 3 GB → 5 GB → 7.75 GB …). Withswap=0the kernel OOM-killer eventually takessshdand the whole box becomes unreachable (the "can't SSH, must hard-reboot" failure). It ignoresSIGTERM(spinning); onlySIGKILLfrees it.Why unbounded here (confirms your "follows symlinks / self-referential loops" on a real pnpm tree): the workspace is pnpm —
pnpm-lock.yaml+ anode_modules/.pnpmstore (~2,400 pkgs), andnode_modules/<pkg>entries are symlinks into.pnpm. Following them re-enters the dependency graph (which has cycles) so the walker turns a finite DAG into an effectively infinite tree; RSS grows with the number of distinct paths visited, not real files.strace (real pnpm workspace) — pure directory traversal, no file contents read:
Extra config knob that also does NOT help (in addition to the
.gitignore/.ignore/.claudeignoreyou noted):sandbox.allowManagedReadPathsOnly: true→ still walks. Removing allsandbox.filesystem.denyReadglobs → still walks.sandbox.enabled: false→ instant, no walk.Multi-agent amplifies it: each sub-agent runs the walk, and the orchestrator respawns a fresh leaking sub-agent every time one is
SIGKILLed (whack-a-mole) — only killing the whole process tree stops it. (Filed #72367 before finding this issue; closing that as a duplicate of this one.)Reproduces on Claude Code 2.1.197, Fedora Linux 44 (kernel
7.0.14-201.fc44.x86_64), bubblewrap 0.11.0.My workspace is a pnpm v11 monorepo on the default isolated (symlinked) linker. The checkout has about 226k real filesystem entries, but following symlinks (
find -L) balloons that to roughly 719k — around 3.2× — almost all of it insidenode_modules.Moving the symlink graph out of the workspace doesn't help either. I turned on pnpm's global virtual store (
enableGlobalVirtualStore: true), which repoints everynode_modules/<pkg>symlink to the shared store outside the repo (confirmed withreadlink). A single trivial prompt still walked the whole graph:That's 1.22M
getdents64calls just to say hi, and it didn't drop at all — if anything it's worse, since the shared store is a cross-project superset. So the walk is following symlinks straight across the working-directory boundary, which means relocating the store isn't a viable workaround.I can also confirm the non-fixes mentioned in this thread:
.claudeignore/.gitignore,sandbox.filesystem.denyRead, andsandbox.allowManagedReadPathsOnly: trueall still trigger the full walk.Thanks for the detailed reproducer. I retested this on 2.1.233 (Linux, bubblewrap 0.11) using your exact setup at K=6,000 and K=20,000 directories, with and without the self-referential symlink loop:
claude -p "say hi"withsandbox.enabled: true: 5.5–6.3sNo pre-request directory-walk phase and no hang in any combination, and I confirmed the sandbox was genuinely active during the runs. The multi-minute per-prompt traversal you saw on 2.1.165–2.1.177 appears to have been fixed in a recent release — the changelog for 2.1.179 includes a fix for sandbox handling of large directory trees making sessions unusable on Linux, right after your affected range.
Closing as fixed — if you still see the hang on 2.1.233 or later (especially at your ~256k-directory scale), please reply with
time claude -p "say hi"output and a--debuglog and we'll reopen.🤖 Generated with Claude Code