Skip to content

node:child_process: exec / execSync do not run a shell, spawn() stdin is not delivered, errors have no .code / .status #56

Closed

Description

@hassidan

Test I ran

node child_process.ts
slice run child_process.ts --allow-exec=/bin/sh,/bin/cat,echo,exit,/nonexistent/bin/xyz
child_process.ts
import * as cp from "node:child_process";
const show = async (k: string, f: () => unknown) => { let v; try { v = await Promise.race([Promise.resolve().then(f), new Promise((r) => setTimeout(() => r("NEVER SETTLED (2000 ms)"), 2000))]); } catch (e: any) { v = "THROWS " + e.name + ": " + String(e.message).slice(0, 100); } console.log(k.padEnd(52) + JSON.stringify(v)); };
(async () => {
  await show("cp.execSync('echo $((1+2))')", () => cp.execSync("echo $((1+2))").toString());
  await show("cp.execSync('echo a && echo b')", () => cp.execSync("echo a && echo b").toString());
  await show("cp.execSync('echo hi | tr a-z A-Z')", () => cp.execSync("echo hi | tr a-z A-Z").toString());
  await show("cp.execSync('exit 4') error.status", () => { try { cp.execSync("exit 4", { stdio: "pipe" }); return "no throw"; } catch (e: any) { return "status=" + e.status; } });
  await show("cp.execFileSync('/bin/sh',['-c','exit 4']) status", () => { try { cp.execFileSync("/bin/sh", ["-c", "exit 4"], { stdio: "pipe" }); return "no throw"; } catch (e: any) { return "status=" + e.status; } });
  await show("cp.spawn('/bin/cat') stdin -> stdout", () => new Promise((res, rej) => { const c = cp.spawn("/bin/cat"); let o = ""; c.stdout.on("data", (d) => (o += d)); c.on("error", rej); c.on("close", (code) => res([code, o])); c.stdin.write("to-"); c.stdin.end("cat"); }));
  await show("cp.spawn(missing binary) -> 'error' code", () => new Promise((res) => { const c = cp.spawn("/nonexistent/bin/xyz"); c.on("error", (e: any) => res("error code=" + e.code)); c.on("close", (code) => res("close code=" + code + " (no 'error' event)")); }));
  await show("cp.spawnSync(missing binary).error.code", () => { const r = cp.spawnSync("/nonexistent/bin/xyz"); return r.error ? (r.error as any).code : "no .error; status=" + r.status; });
})();

Result

Node v24.22.2:

cp.execSync('echo $((1+2))')                        "3\n"
cp.execSync('echo a && echo b')                     "a\nb\n"
cp.execSync('echo hi | tr a-z A-Z')                 "HI\n"
cp.execSync('exit 4') error.status                  "status=4"
cp.execFileSync('/bin/sh',['-c','exit 4']) status   "status=4"
cp.spawn('/bin/cat') stdin -> stdout                [0,"to-cat"]
cp.spawn(missing binary) -> 'error' code            "error code=ENOENT"
cp.spawnSync(missing binary).error.code             "ENOENT"

Slice v0.10.7:

cp.execSync('echo $((1+2))')                        "$((1+2))\n"
cp.execSync('echo a && echo b')                     "a && echo b\n"
cp.execSync('echo hi | tr a-z A-Z')                 "hi | tr a-z A-Z\n"
cp.execSync('exit 4') error.status                  "status=undefined"
cp.execFileSync('/bin/sh',['-c','exit 4']) status   "status=4"
cp.spawn('/bin/cat') stdin -> stdout                [0,""]
cp.spawn(missing binary) -> 'error' code            "error code=undefined"
cp.spawnSync(missing binary).error.code             "THROWS Error: Error: spawn /nonexistent/bin/xyz ENOENT (No such file or directory (os error 2))"

What is wrong

  1. exec / execSync do not go through a shell. The command string is split on whitespace and the first word is executed with the rest as literal arguments, so echo a && echo b prints a && echo b, pipes and $(( )) are passed through as text, and exit 4 is not a shell builtin. In Node these two functions are defined as "spawn a shell and run the command in it". If refusing a shell is a deliberate ExecCap decision (a shell would defeat the binary allow-list), the call should throw on shell syntax or the behaviour should be documented; returning different output silently is the hazardous part.
  2. spawn(): data written to child.stdin never reaches the child (/bin/cat exits 0 with empty output). spawnSync(..., { input }) works.
  3. Error shape: execSync failure has no .status; spawn() of a missing binary emits 'error' without .code (ENOENT); spawnSync() of a missing binary throws instead of returning { error }.

execFileSync, spawnSync (status, stdout, stderr, input), spawn stdout/stderr/exit code, the execFile callback, and the env and cwd options all matched Node in the same run.

Environment

  • Slice v0.10.7, commit ea7c70c, built with cargo +1.96.0 build --release --locked --bin slice
  • Reference: Node.js v24.22.2 (same script, same machine)
  • Ubuntu 26.04, Linux 7.0, x86_64
  • The same output is produced by the v0.10.5 tag, so this is not a regression in 0.10.6/0.10.7.

Activity

  1. siktec-lab commented on Nov 11, 2026

    @siktec-lab
    Collaborator

    Addressed in #66 (v0.10.10):

    • exec, execSync, and spawn/spawnSync with { shell: true } now execute commands through /bin/sh -c (supporting &&, ||, ;, pipelines |, and propagating non-zero exit codes via err.status) while enforcing Ring-1 OCap ExecCap checks.
    • spawn() now buffers and pipes cp.stdin.write() / cp.stdin.end() binary and UTF-8 payloads to the child process stdin.
    • spawnSync returns { status: null, error } with error.code = ENOENT when the target binary does not exist, and spawn asynchronously emits 'error' with err.code = ENOENT.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions