Where: packages/safe-bash/src/shell/cleanup.ts — InvocationScope.register() only appends to #callbacks, drained only in close(). Redirects register on the caller's scope (src/shell/runtime.ts :2293, registerCleanup: cleanup => io[invocationScope].register(cleanup)); pipelines do the same via the InvocationCancellationOwner constructor (:977, parent.register(async () => {...}), invoked at ~:1761 with io[invocationScope]). Inside a loop body io[invocationScope] is the exec root, so every iteration leaks a closure (file output target / streams / cancellation owner) until the whole exec finishes. Instrumentation confirms 1 root registration per echo >f, 4 per echo | cat, 1 per $(...) / ( ), versus 5 total for a plain : loop.
PoC (≤50 bytes each, default limits, within maxCommands=10 000):
for i in $(seq 1 6000); do echo $i >/work/f; done
for i in $(seq 1 4000); do echo $i | cat; done | wc -l
for i in $(seq 1 3000); do echo $i | cat | cat; done | wc -l
Run: NODE_OPTIONS=--max-old-space-size=128 npx tsx harness.mts '<script>'
Measured: forced-GC sampling (--expose-gc) grows linearly: echo $i >/work/f ×6000 → 93 MB live at end (≈15.5 KB/iter); echo $i | cat ×3000 → 109 MB (≈36 KB/iter); { echo $i; } >f ×3000 → 50 MB; (echo $i) ×3000 → 28 MB; x=$(echo $i) ×3000 → 30 MB; all freed after exec. Default-heap peaks for 1000/2000/4000/8000 redirects: 38/51/117/216 MB. 128 MB isolate: all three PoCs FATAL ERROR: … JavaScript heap out of memory (exit 0 on unconstrained Node; re-verified independently). Controls that stay flat (~5 MB): for … do echo $i; done >/work/f and cat <<<$i loops — the #633 heredoc fix does not cover these sites. cloudflareWorkerLimits survives only because its maxCommands is 1000 (peak ~30 MB).
Impact: (d) — a ≤50-byte script kills the isolate with every budget green. Same family as closed #613/#632/#633 (per-invocation retention), at different sites: per-redirect target cleanup, per-pipeline cancellation owner, subshell, command substitution.
Fix: register redirect/pipeline cleanups on the per-command child scope (snapshotScope from ~:1934, or a child() created in redirect()/pipeline setup) and close it when the command completes; or have InvocationScope.register drop callbacks once their owner is finalized.
Found in security audit v3 (2026-09-07).
Where:
packages/safe-bash/src/shell/cleanup.ts—InvocationScope.register()only appends to#callbacks, drained only inclose(). Redirects register on the caller's scope (src/shell/runtime.ts:2293,registerCleanup: cleanup => io[invocationScope].register(cleanup)); pipelines do the same via theInvocationCancellationOwnerconstructor (:977,parent.register(async () => {...}), invoked at ~:1761withio[invocationScope]). Inside a loop bodyio[invocationScope]is the exec root, so every iteration leaks a closure (file output target / streams / cancellation owner) until the whole exec finishes. Instrumentation confirms 1 root registration perecho >f, 4 perecho | cat, 1 per$(...)/( ), versus 5 total for a plain:loop.PoC (≤50 bytes each, default limits, within
maxCommands=10 000):Run:
NODE_OPTIONS=--max-old-space-size=128 npx tsx harness.mts '<script>'Measured: forced-GC sampling (
--expose-gc) grows linearly:echo $i >/work/f×6000 → 93 MB live at end (≈15.5 KB/iter);echo $i | cat×3000 → 109 MB (≈36 KB/iter);{ echo $i; } >f×3000 → 50 MB;(echo $i)×3000 → 28 MB;x=$(echo $i)×3000 → 30 MB; all freed after exec. Default-heap peaks for 1000/2000/4000/8000 redirects: 38/51/117/216 MB. 128 MB isolate: all three PoCsFATAL ERROR: … JavaScript heap out of memory(exit 0 on unconstrained Node; re-verified independently). Controls that stay flat (~5 MB):for … do echo $i; done >/work/fandcat <<<$iloops — the #633 heredoc fix does not cover these sites.cloudflareWorkerLimitssurvives only because itsmaxCommandsis 1000 (peak ~30 MB).Impact: (d) — a ≤50-byte script kills the isolate with every budget green. Same family as closed #613/#632/#633 (per-invocation retention), at different sites: per-redirect target cleanup, per-pipeline cancellation owner, subshell, command substitution.
Fix: register redirect/pipeline cleanups on the per-command child scope (
snapshotScopefrom ~:1934, or achild()created inredirect()/pipeline setup) and close it when the command completes; or haveInvocationScope.registerdrop callbacks once their owner is finalized.Found in security audit v3 (2026-09-07).