Windows without a shell
How bellpull runs npm.cmd and other batch files on Windows without shell: true: resolve with PATHEXT, invoke cmd.exe itself, and escape every argument for both parsers so caller data cannot become a command.
On Windows npm is npm.cmd, and a .cmd is a script CreateProcess will not run. The
tempting fix is { shell: true }, and it re-opens command injection: through a shell an
argument is text in a command line, so a & in caller data starts a second command.
bellpull does what cross-spawn does instead:
- Resolve the command with
PATHEXT—npmbecomesC:\…\npm.cmd. - If the file is not an
.exeor.com, invokecmd.exeitself:cmd.exe /d /s /c "…". - Escape the command and every argument for both parsers the line passes through:
CommandLineToArgvW's quoting, thencmd.exe's metacharacters, with a caret before each. An argument bound for anode_modules\.bin\*.cmdshim is escaped twice, because the shim re-expands it. - Spawn with
windowsVerbatimArguments, so Node does not quote it all a second time.
shell: true stays available, off by default, and documented as the injection surface it is.
What the escaping produces
The escapers are exported, and they are pure — this runs the same on any platform:
import { escapeArgument, escapeCommand } from 'bellpull';
for (const argument of ['plain', 'two words', 'foo & calc', '"(foo|bar>baz)"', '%PATH%']) {
console.log(`${argument.padEnd(16)} -> ${escapeArgument(argument)}`);
}
console.log(escapeCommand('my&tool'));plain -> ^"plain^"
two words -> ^"two^ words^"
foo & calc -> ^"foo^ ^&^ calc^"
"(foo|bar>baz)" -> ^"\^"^(foo^|bar^>baz^)\^"^"
%PATH% -> ^"^%PATH^%^"
my^&toolThe command is escaped without quotes, because cmd.exe resolves it before it parses
arguments.
How it is checked
escape.test.ts round-trips a 37-vector injection corpus through simulations of both
parsers — cmd.exe's caret and quote handling, then CommandLineToArgvW — and asserts each
vector comes back as exactly one argument, unchanged. It then asserts the naive fix (quote it
and hand it to a shell) lets the dangerous half of the corpus through, so the contrast is a
real one. On a Windows runner, the same suite runs a hostile argument through a real
cmd-shim and checks that an argument written to create a file does not create it.
What is not covered: CR and LF. cmd.exe treats both as command separators and has no
escape for them. bellpull does not caret-escape them — the same as cross-spawn — and records
that as an unverified gap on Windows. execa 10 refuses arguments containing either, which is
the stricter answer.
cross-spawn's own suite, which grades bellpull/cross-spawn 68 of 68, is a pass-through on
POSIX: it never reaches this branch. The Windows behaviour is covered by bellpull's own tests.
What is tested
escape.test.ts: the corpus through both parsers; the naive fix, broken; the double escape through a cmd-shim; the argv array as the boundary.cross-spawn.test.ts: the Windows branch, driven from a Mac — an unresolvable command routed throughcmd.exerather than a shell option, every argument escaped, a POSIX path normalised, and POSIX left alone.
Finding the executable
bellpull/which: whichSync returns the path and the PATH entry that found it, skips empty and relative PATH entries, honours and reports PATHEXT, and runPath puts node_modules/.bin in front.
One result for people and agents
format, toJson and toEvent: three renderings of one bellpull Result, over one verdict — ok, failed, timedOut or signalled — so a --json flag cannot say something the human output did not.