sandbox.enabled: true triggers a synchronous full-tree directory walk on startup → multi-minute hang in large workspaces

Status Fixed / completed
Reported on v2.1.177
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jun 15, 2026 · closed Aug 17, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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 syscall with rax=0x101 (openat) and flags=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 the sandbox key removed → fast. permissions / enabledPlugins / LSP plugins / .git / CLAUDE.md / Claude version all ruled out by direct test.
  • The two self-referential Composer vendor symlinks 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: false for the workspace (loses sandboxing).
  • Launch from a smaller subdirectory.
  • Remove self-referential symlinks from the tree.

View original on GitHub ↗

3 Comments

zordius · 3 months ago

Corroborating on 2.1.195 with 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 claude process spins (R) and RSS climbs ~2–2.5 GB/min (~290 MB → 3 GB → 5 GB → 7.75 GB …). With swap=0 the kernel OOM-killer eventually takes sshd and the whole box becomes unreachable (the "can't SSH, must hard-reboot" failure). It ignores SIGTERM (spinning); only SIGKILL frees it.

Why unbounded here (confirms your "follows symlinks / self-referential loops" on a real pnpm tree): the workspace is pnpm — pnpm-lock.yaml + a node_modules/.pnpm store (~2,400 pkgs), and node_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:

strace -f -c -p <pid>  (3s):  getdents64 20240,  openat 10106,  close 10106
/proc/<pid>/io:  read_bytes: 0   (rchar advanced ~8 bytes)
~5,800 node_modules openat() per 2s, paths like
  .../node_modules/<a>/node_modules/<b>/node_modules/<c>/node_modules/<d>/...

Extra config knob that also does NOT help (in addition to the .gitignore/.ignore/.claudeignore you noted): sandbox.allowManagedReadPathsOnly: true → still walks. Removing all sandbox.filesystem.denyRead globs → 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.)

mgreystone · 3 months ago

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 inside node_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 every node_modules/<pkg> symlink to the shared store outside the repo (confirmed with readlink). A single trivial prompt still walked the whole graph:

$ strace -f -e trace=getdents64 -o t timeout 120 claude -p "say hi"; wc -l t
1223639 t

That's 1.22M getdents64 calls 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, and sandbox.allowManagedReadPathsOnly: true all still trigger the full walk.

bcherny collaborator · 1 month ago

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" with sandbox.enabled: true: 5.5–6.3s
  • Same with sandbox disabled: 5.9–6.0s

No 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 --debug log and we'll reopen.

🤖 Generated with Claude Code

Showing cached comments. Read the full discussion on GitHub ↗