Summary
Parameter expansion (${x//old/new}, ${x^^}, ${x:offset:len}, ${#x}) silently fails — returns empty stdout with exit code 1 — when just-bash is imported as ESM on Node ≥ 22.23. The CommonJS entry works correctly on the same Node version, and both entries work on Node ≤ 22.14.
Version: just-bash@3.0.2.
Repro
t.mjs (ESM) — fails on Node ≥22.23, passes on ≤22.14:
import { Bash } from 'just-bash';
const r = await new Bash().exec('x=foobar; echo ${x//o/0}');
console.log(JSON.stringify(r.stdout.trim()), r.exitCode);
// Node 22.23.1 / 24.x : "" 1
// Node 22.14.0 / 20.x : "f00bar" 0
t.cjs (CommonJS) — passes on all Node versions, including 22.23+:
const { Bash } = require('just-bash');
new Bash().exec('x=foobar; echo ${x//o/0}')
.then(r => console.log(JSON.stringify(r.stdout.trim()), r.exitCode));
// all versions: "f00bar" 0
Matrix (verified in clean node:* Docker images)
| Node |
CJS require |
ESM import |
| 20.20.2 |
✅ f00bar |
✅ f00bar |
| 22.14.0 |
✅ f00bar |
✅ f00bar |
| 22.23.1 |
✅ f00bar |
❌ "" exit 1 |
| 24.18.0 |
✅ f00bar |
❌ "" exit 1 |
Verified across ${x//old/new}, ${x^^}, ${x:offset:len}, and ${#x} — all four break together on ESM ≥22.23 and all four pass via CJS on every version.
Scope
- Affects the family of expansions that route through the same path:
${x//}, ${x^^}, ${x,,}, ${x:o:l}, ${#x}.
- Not affected: basic commands, pipes, loops, conditionals,
jq, arithmetic $(( )), error/exit-code parity, containment/execution-limits — all behave identically ESM vs CJS.
- The flip is purely ESM entry × Node ≥22.23; isolating either variable alone doesn't reproduce it.
Impact
ESM consumers on current Node (22.23+/24) get silently wrong results for common string operations. Workaround today is pinning Node ≤22.14 or loading the CJS build via createRequire.
Mechanism (captured from result.stderr)
The failing call returns this on the stderr channel (stdout empty, exit 1):
bash: security violation: dynamic import of Node.js builtin 'node:module' is blocked during script execution
This is a defense-in-depth measure and indicates a bug in just-bash. Please report this at security@vercel.com
So just-bash's own dynamic-import guard is blocking an internal import('node:module') (used to createRequire the esbuild shim) that the ESM build reaches only on Node ≥22.15. CJS never takes that path (native require), which is why CJS is unaffected on every version. It is also order-sensitive: the first expansion that triggers the lazy node:module import is blocked, while some later calls in the same process succeed — so a batch shows partial failures rather than total. Likely fix: statically import / allowlist just-bash's own node:module use so the sandbox guard doesn't block it. (Reporting here as a functional bug rather than to security@vercel.com since it's a self-block, not an escape — happy to move it if you prefer.)
Possibly related
Closed #211 ("Dynamic require of tty is not supported when used as ESM in Node") points at dynamic-require fragility in the ESM bundle. A silently-failing dynamic require on the expansion code path (surfacing only after a Node ≥22.23 module-resolution change) would explain why this reproduces only via the ESM entry and only on newer Node, while CJS is unaffected — but I haven't confirmed the exact mechanism.
Happy to provide the full 26-case conformance battery or a bisected build diff if useful.
Summary
Parameter expansion (
${x//old/new},${x^^},${x:offset:len},${#x}) silently fails — returns empty stdout with exit code 1 — whenjust-bashis imported as ESM on Node ≥ 22.23. The CommonJS entry works correctly on the same Node version, and both entries work on Node ≤ 22.14.Version:
just-bash@3.0.2.Repro
t.mjs(ESM) — fails on Node ≥22.23, passes on ≤22.14:t.cjs(CommonJS) — passes on all Node versions, including 22.23+:Matrix (verified in clean
node:*Docker images)requireimportf00barf00barf00barf00barf00bar""exit 1f00bar""exit 1Verified across
${x//old/new},${x^^},${x:offset:len}, and${#x}— all four break together on ESM ≥22.23 and all four pass via CJS on every version.Scope
${x//},${x^^},${x,,},${x:o:l},${#x}.jq, arithmetic$(( )), error/exit-code parity, containment/execution-limits — all behave identically ESM vs CJS.Impact
ESM consumers on current Node (22.23+/24) get silently wrong results for common string operations. Workaround today is pinning Node ≤22.14 or loading the CJS build via
createRequire.Mechanism (captured from
result.stderr)The failing call returns this on the stderr channel (stdout empty, exit 1):
So just-bash's own dynamic-import guard is blocking an internal
import('node:module')(used tocreateRequirethe esbuild shim) that the ESM build reaches only on Node ≥22.15. CJS never takes that path (nativerequire), which is why CJS is unaffected on every version. It is also order-sensitive: the first expansion that triggers the lazynode:moduleimport is blocked, while some later calls in the same process succeed — so a batch shows partial failures rather than total. Likely fix: statically import / allowlist just-bash's ownnode:moduleuse so the sandbox guard doesn't block it. (Reporting here as a functional bug rather than to security@vercel.com since it's a self-block, not an escape — happy to move it if you prefer.)Possibly related
Closed #211 ("Dynamic require of
ttyis not supported when used as ESM in Node") points at dynamic-requirefragility in the ESM bundle. A silently-failing dynamicrequireon the expansion code path (surfacing only after a Node ≥22.23 module-resolution change) would explain why this reproduces only via the ESM entry and only on newer Node, while CJS is unaffected — but I haven't confirmed the exact mechanism.Happy to provide the full 26-case conformance battery or a bisected build diff if useful.