OpusMill

DEP0190: passing args to a child process with shell: true

Verified on Windows 11, Node 24.20.0 · OpusMill

(node:43308) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
The fix

It was there to run npm, npx or a .bin shim on Windows: run the JS file behind the shim with process.execPath and no shell, or use cross-spawn.

You need pipes or &&: pass one command string and quote each argument yourself.

Neither: delete shell: true.

Not just a warning

With shell: true, Node joins the command and your args with spaces and hands one string to cmd.exe or /bin/sh to parse again. It became a runtime warning in Node 24.0.0. Here echo-arg.js is one line, console.log(JSON.stringify(process.argv.slice(2))):

const { spawnSync } = require("node:child_process");
const out = (r) => r.stdout.toString().trim();

// with shell: true
console.log(out(spawnSync("node", ["echo-arg.js", "two words"], { shell: true })));
console.log(out(spawnSync("node", ["echo-arg.js", "x&echo INJECTED"], { shell: true })));

// without a shell
console.log(out(spawnSync(process.execPath, ["echo-arg.js", "two words"])));
console.log(out(spawnSync(process.execPath, ["echo-arg.js", "x&echo INJECTED"])));
["two","words"]
["x"]
INJECTED
["two words"]
["x&echo INJECTED"]

One argument became two, and & ran a second command. Both exited 0. Two bad calls, one warning: Node warns once per process, so fixing one call can reveal the next.

exec(), or one command string, does not warn. That silences it; it fixes nothing.

Which fix to use

Why shell: true is thereDo this
To run npm, npx or a node_modules/.bin shim on Windows .cmd files throw EINVAL without a shell. Run the JS behind them, or cross-spawn. Below.
Pipes, &&, redirects, globbing One string, every argument quoted by you. Below.
No reason; the target is an .exe Delete it. Paths with spaces stop failing too.

npm and .bin shims, no shell

npm.cmd only starts a JS file. Inside an npm script, process.env.npm_execpath is that file:

const { spawnSync } = require("node:child_process");
const path = require("node:path");

const npmCli = process.env.npm_execpath ||
  path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");

const r = spawnSync(process.execPath, [npmCli, "--version"], { encoding: "utf8" });
console.log(npmCli);
console.log(r.status, r.stdout.trim());
--- npm run which-npm ---
C:\...\AppData\Roaming\npm\node_modules\npm\bin\npm-cli.js
0 11.14.1

--- node run-npm.js ---
C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js
0 11.19.0

Mind the fallback: outside an npm script it runs the npm bundled with Node (11.19.0 here), not the one on the PATH (11.14.1). Not tested with yarn or pnpm. For npx, use npx-cli.js beside it (echo-args is a test shim that prints its arguments):

const npxCli = path.join(path.dirname(npmCli), "npx-cli.js");
const args = ["two words", 'say "hi"', "%USERNAME%", 'a"&echo INJECTED'];
const x = spawnSync(process.execPath, [npxCli, "echo-args", ...args], { encoding: "utf8" });
console.log(x.status, x.stdout.trim());
0 ["two words","say \"hi\"","%USERNAME%","a\"&echo INJECTED"]

Any other .bin shim: run the file in the package's bin field. Load package.json by path (require.resolve fails on packages with exports); bin may be a string:

function binFile(pkg, name) {
  const dir = path.join(__dirname, "node_modules", pkg);
  const { bin } = require(path.join(dir, "package.json"));
  return path.join(dir, typeof bin === "string" ? bin : bin[name]);
}

spawnSync(process.execPath, [binFile("echo-args-bin", "echo-args"), ...args]);

Or let cross-spawn (7.0.6) find the .cmd and escape for it:

const spawn = require("cross-spawn");

const npm = spawn.sync("npm", ["--version"], { encoding: "utf8" });
console.log(npm.status, npm.stdout.trim());

const args = ["two words", 'say "hi"', "%USERNAME%", 'a"&echo INJECTED'];
const r = spawn.sync("node_modules/.bin/echo-args", args, { encoding: "utf8" });
console.log(r.status, r.stdout.trim());
0 11.14.1
0 ["two words","say \"hi\"","%USERNAME%","a\"&echo INJECTED"]

No warning, and it runs the npm on the PATH. binFile passed the same four arguments intact for all three package shapes.

When you do need the shell

function shellArg(s) {
  if (process.platform !== "win32") return "'" + s.replace(/'/g, "'\\''") + "'";
  if (/[\r\n]/.test(s)) throw new Error("cmd.exe cannot pass a line break in an argument");
  s = '"' + s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1") + '"';
  return s.replace(/[()\][%!^"`<>&|;, *?]/g, "^$&");
}

const args = ["two words", 'say "hi"', "%USERNAME%", "x&echo INJECTED", "C:\\dir\\"];
const cmd = `node echo-arg.js ${args.map(shellArg).join(" ")} && echo second command ran`;

const r = spawnSync(cmd, { shell: true, encoding: "utf8" });
console.log(r.status);
console.log(r.stdout.trim());
0
["two words","say \"hi\"","%USERNAME%","x&echo INJECTED","C:\\dir\\"]
second command ran

Limits. Tested on cmd.exe with an .exe target. It throws on a line break, because cmd.exe ends the command there and silently drops the rest, && part included, with exit 0. It is not safe for a .cmd or .bat, which cmd.exe parses a second time. Argument a"&echo INJECTED:

node.exe : ["a\"&echo INJECTED"]
.cmd shim: ["a\""]
INJECTED"

The /bin/sh branch was tested with Git for Windows' sh only, not on Linux or macOS.

Finding the call

node --trace-deprecation adds a stack. Under npm scripts, set NODE_OPTIONS=--trace-deprecation instead.

(node:24920) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
    at normalizeSpawnArguments (node:child_process:661:15)
    at spawnSync (node:child_process:887:8)
    at Object.<anonymous> (C:\...\split.js:5:17)
    at Module._compile (node:internal/modules/cjs/loader:1929:14)
    ...

node --throw-deprecation makes it a crash (exit code 1) for CI. It is not a guard: the throw comes later, after the children already ran:

["two","words"]
["x"]
INJECTED
["two words"]
["x&echo INJECTED"]
node:internal/process/warning:176
        throw warning;
        ^

DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
    at normalizeSpawnArguments (node:child_process:661:15)
    ...
  code: 'DEP0190'
}

Node.js v24.20.0

Check your own project for the rest of this class of bug. Paste your code or your package.json into the browser checker — nothing is uploaded, it runs on your machine — or run npx github:Hackierz/winbreak over the whole repository. The rule that finds this one is shell-true-args-array.

Other errors in the same family:

Background: I scanned the 600 most-downloaded npm CLI packages — 17.4% have a package.json script that cannot run on Windows.