Found in safe-bash deep pentest (v3, successor to docs/issues/safe-bash-security-audit-v2.md). Verified with executed PoCs against current src/ on Node 22.
Severity: Critical — threat (d): fatal, uncatchable isolate OOM from a 21-byte guest script
Summary
destination.write in filesystem-output.ts does await Promise.race([accepted, task!]) per chunk, where task is the long-lived writer-task promise that stays pending for the whole exec. Every chunk write attaches a new race reaction to it, retained (~300 B each) until the task settles at exec end. A redirect of ~2–2.8 MB output (≈15% of the 16 MB maxOutputBytes budget) fatally OOMs a 128 MB isolate (Cloudflare Workers proxy). The identical data to stdout or a pipe hits maxOutputBytes cleanly at 33 MB heap.
Location
packages/safe-bash/src/contracts/filesystem-output.ts:61 — await Promise.race([accepted, task!]) inside the write loop (:56-64)
- Consumers sharing the path: shell redirects
src/shell/runtime.ts:2246, tee src/commands/streams.ts:333, curl -o src/commands/network/output.ts:37,53
PoC (executed)
cd packages/safe-bash
node --max-old-space-size=128 --import tsx /tmp/poc.ts
// guest script is 21 bytes:
await shell.exec("seq 1 400000 > /big");
Measured:
seq 1 400000 > /big (2.8 MB redirected output), --max-old-space-size=128 → FATAL ERROR: JavaScript heap out of memory (uncatchable, process death). 600k/1M/2M/3M all fatal; 300k (2 MB) survives → fatal threshold ≈ 2–2.8 MB of redirect output.
- Control:
seq 1 3000000 | wc -c (same data to a pipe) → clean ShellLimitError: maxOutputBytes at 33 MB heap.
- Default heap:
seq 1 2000000 > /big → exit 0, 13.4 s, peak heap 988 MB for a 14.9 MB file (66× amplification); seq 1 3000000 → limit fires with file at 16,777,215 B, peak 1055 MB.
- Mechanism microbench: 300k ×
Promise.race([Promise.resolve(), pendingTask]) → live heap 9 MB → 98 MB (+89 MB, ~300 B/iter), released the moment the task settles — exact match.
- In-exec live heap during
seq 1 300000 > /big with forced GC sampling: 44 → 62 → 82 → 98 MB, then 25 MB after exec.
tee amplifies multiplicatively: seq 1 20000 | tee /t1 … /t200 | wc -c → fatal at 128 MB; 2000 targets × 2k lines → fatal at 128 MB; default heap → 15.8 s, peak heap 984 MB / RSS 1189 MB before maxOutputBytes fired.
- Chunk granularity drives it: per-line producers (
seq, awk) = one race per line; a single-chunk 588 KB write is clean (26 MB live).
Suggested fix
Don't race the long-lived task per write. Track writer failure once (settled-flag / one-shot failed promise created before the loop, or check existing failure / operation.signal.aborted state), or attach a single rejection handler to task that aborts acknowledge. Apply the same to the per-64 KB-slice loop.
Status: NEW
Found in safe-bash deep pentest (v3, successor to
docs/issues/safe-bash-security-audit-v2.md). Verified with executed PoCs against currentsrc/on Node 22.Severity: Critical — threat (d): fatal, uncatchable isolate OOM from a 21-byte guest script
Summary
destination.writeinfilesystem-output.tsdoesawait Promise.race([accepted, task!])per chunk, wheretaskis the long-lived writer-task promise that stays pending for the whole exec. Every chunk write attaches a new race reaction to it, retained (~300 B each) until the task settles at exec end. A redirect of ~2–2.8 MB output (≈15% of the 16 MBmaxOutputBytesbudget) fatally OOMs a 128 MB isolate (Cloudflare Workers proxy). The identical data to stdout or a pipe hitsmaxOutputBytescleanly at 33 MB heap.Location
packages/safe-bash/src/contracts/filesystem-output.ts:61—await Promise.race([accepted, task!])inside the write loop (:56-64)src/shell/runtime.ts:2246,teesrc/commands/streams.ts:333,curl -osrc/commands/network/output.ts:37,53PoC (executed)
cd packages/safe-bash node --max-old-space-size=128 --import tsx /tmp/poc.tsMeasured:
seq 1 400000 > /big(2.8 MB redirected output),--max-old-space-size=128→FATAL ERROR: JavaScript heap out of memory(uncatchable, process death). 600k/1M/2M/3M all fatal; 300k (2 MB) survives → fatal threshold ≈ 2–2.8 MB of redirect output.seq 1 3000000 | wc -c(same data to a pipe) → cleanShellLimitError: maxOutputBytesat 33 MB heap.seq 1 2000000 > /big→ exit 0, 13.4 s, peak heap 988 MB for a 14.9 MB file (66× amplification);seq 1 3000000→ limit fires with file at 16,777,215 B, peak 1055 MB.Promise.race([Promise.resolve(), pendingTask])→ live heap 9 MB → 98 MB (+89 MB, ~300 B/iter), released the moment the task settles — exact match.seq 1 300000 > /bigwith forced GC sampling: 44 → 62 → 82 → 98 MB, then 25 MB after exec.teeamplifies multiplicatively:seq 1 20000 | tee /t1 … /t200 | wc -c→ fatal at 128 MB; 2000 targets × 2k lines → fatal at 128 MB; default heap → 15.8 s, peak heap 984 MB / RSS 1189 MB beforemaxOutputBytesfired.seq,awk) = one race per line; a single-chunk 588 KB write is clean (26 MB live).Suggested fix
Don't race the long-lived
taskper write. Track writer failure once (settled-flag / one-shotfailedpromise created before the loop, or check existingfailure/operation.signal.abortedstate), or attach a single rejection handler totaskthat abortsacknowledge. Apply the same to the per-64 KB-slice loop.Status: NEW