Where: packages/safe-bash/src/shell/brace-expansion.ts:39-42 — admit() calls allocation.reserve(64, 0) per character/node; each reservation creates a record + closure in ValueScope.reserve (src/shell/value-state.ts ~L120) that is only released when the outer allocation.close() runs at generator end. Each 64-byte charge costs ~600 B of real heap, so the 16 MiB maxExpansionBytes / 262 144 maxParseUnits admit ~150 MB of bookkeeping. Parsing alone is fine (parseShellUnit of the same script: 28 ms, ~0 MB) — the blowup is in expansion admission.
PoC (59 KB source, ≤ maxSourceBytes, default limits):
echo {a}{a}{a}…×20000 # expands to literal text, no cardinality
echo {{{…×30000 a }}}…×30000 # nested
Run: NODE_OPTIONS=--max-old-space-size=128 npx tsx harness.mts "echo $(printf '{a}%.0s' $(seq 1 20000)) | wc -c"
Measured: {a}×10000 → +57 MB; ×20000 → +120 MB, exit 0, 608 ms; ×50000 → +140 MB then maxParseUnits; nested ×20000 → +67 MB; {,×20000 → +104 MB. 128 MB isolate: {a}×20000 FATAL ERROR: … heap out of memory, nested ×30000 FATAL (re-verified independently); ×10000 survives (49 MB). cloudflareWorkerLimits: caps at ~36–43 MB then maxParseUnits (survives).
Impact: (d) — ~2 KB of heap per source byte; 60 KB of source crashes the isolate under default limits before any expansion output exists. Introduced with brace expansion (e4b598542, #637).
Fix: charge one aggregated reservation per part (count units in a plain integer and reserve once), instead of a ledger record per character; or cap brace tokens per word (e.g. 4 096) with ShellLimitError.
Found in security audit v3 (2026-09-07).
Where:
packages/safe-bash/src/shell/brace-expansion.ts:39-42—admit()callsallocation.reserve(64, 0)per character/node; each reservation creates a record + closure inValueScope.reserve(src/shell/value-state.ts~L120) that is only released when the outerallocation.close()runs at generator end. Each 64-byte charge costs ~600 B of real heap, so the 16 MiBmaxExpansionBytes/ 262 144maxParseUnitsadmit ~150 MB of bookkeeping. Parsing alone is fine (parseShellUnitof the same script: 28 ms, ~0 MB) — the blowup is in expansion admission.PoC (59 KB source, ≤
maxSourceBytes, default limits):Run:
NODE_OPTIONS=--max-old-space-size=128 npx tsx harness.mts "echo $(printf '{a}%.0s' $(seq 1 20000)) | wc -c"Measured:
{a}×10000 → +57 MB; ×20000 → +120 MB, exit 0, 608 ms; ×50000 → +140 MB thenmaxParseUnits; nested ×20000 → +67 MB;{,×20000 → +104 MB. 128 MB isolate:{a}×20000FATAL ERROR: … heap out of memory, nested ×30000 FATAL (re-verified independently); ×10000 survives (49 MB).cloudflareWorkerLimits: caps at ~36–43 MB thenmaxParseUnits(survives).Impact: (d) — ~2 KB of heap per source byte; 60 KB of source crashes the isolate under default limits before any expansion output exists. Introduced with brace expansion (
e4b598542, #637).Fix: charge one aggregated reservation per part (count units in a plain integer and reserve once), instead of a ledger record per character; or cap brace tokens per word (e.g. 4 096) with
ShellLimitError.Found in security audit v3 (2026-09-07).