Skip to content

fix: decode escaped Windows executable tokens - #166

Merged
martinrrm merged 2 commits into
mainfrom
cli-170-windows-cmd-escaping
Oct 12, 2026
Merged

martinrrm merged 2 commits into
mainfrom
cli-170-windows-cmd-escaping

Conversation

@martinrrm

@martinrrm martinrrm commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Decode an already quoted and caret-escaped executable token before using it for Windows executable lookup.

This is a supporting change for npm/cli#10044. That proposal protects an executable name before placing it in a command interpreted by cmd.exe. promise-spawn must still identify the protected executable as a .cmd or .bat shim so it can apply the existing extra escaping required for batch-file arguments.

Why this is needed

There are two distinct representations of the executable:

  1. Execution representation — protected for cmd.exe, and kept unchanged in the final command string.
  2. Lookup representation — decoded temporarily so which.sync() can resolve the executable and determine whether it is a batch shim.

For example, a caller may pass this protected executable token:

^"tool.cmd^"

The existing parser derives the lookup value directly from that text:

^"tool.cmd^"

That is not an executable name that which.sync() can resolve. If lookup fails, promise-spawn may not recognize that the command is a .cmd shim, so doubleEscape remains false.

The updated parser derives this lookup-only value instead:

tool.cmd

The original ^"tool.cmd^" token is still retained in the command sent to cmd.exe; only the temporary value used for executable detection is decoded.

Why batch shims need detection

promise-spawn already distinguishes native executables from .cmd and .bat shims:

doubleEscape = pathToInitial.endsWith('.cmd') ||
  pathToInitial.endsWith('.bat')

A batch shim causes its arguments to pass through an additional cmd.exe parsing layer. The existing escape.cmd(arg, doubleEscape) implementation therefore adds another level of caret escaping when doubleEscape is true.

Using the safe argument hello world as an example:

Executable detection Encoded argument
Native executable or unresolved command ^"hello^ world^"
.cmd/.bat shim ^^^"hello^^^ world^^^"

If an escaped tool.cmd token cannot be resolved, the first form may be selected even though the second form is required for the batch shim.

Data flow with npm CLI

The related npm CLI proposal has this flow on Windows:

package bin name
    -> libnpmexec protects the executable token for cmd.exe
    -> promise-spawn receives the protected script
    -> promise-spawn decodes only the first token for which.sync()
    -> .cmd/.bat detection selects the existing double-escape mode
    -> the original protected script is sent to cmd.exe

This PR implements only the lookup step. It does not decide which executable names are valid, add shell quoting to arbitrary commands, or decode the command that is executed.

Changes

  • Decode caret escapes outside double-quoted sections while preparing the temporary lookup token.
  • Treat only " as a cmd.exe quote delimiter; single quotes have no quoting semantics in cmd.exe.
  • Remove surrounding double quotes before calling which.sync().
  • Leave the original command string unchanged for execution.
  • Preserve the existing .cmd/.bat argument double-escaping behavior.

Before and after

Input command token Previous lookup value New lookup value Executed token
tool tool tool tool
"tool.cmd" "tool.cmd" tool.cmd "tool.cmd"
^"tool.cmd^" ^"tool.cmd^" tool.cmd ^"tool.cmd^"
^"two^ words.cmd^" ^"two^ words.cmd^" two words.cmd ^"two^ words.cmd^"

Tests

Added mock-based regression coverage confirming that:

  • An escaped and quoted executable token is decoded before lookup.
  • An escaped .cmd shim is recognized.
  • Arguments for the recognized batch shim receive the existing double-escaping treatment.
  • The final command sent to cmd.exe retains the protected executable token.

The tests validate command construction and executable lookup without executing an injection payload.

Related change

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nkolev92
nkolev92 previously approved these changes Sep 28, 2026
Comment thread test/shell.js Outdated
Comment thread test/shell.js
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@martinrrm
martinrrm merged commit e823469 into main Oct 12, 2026
24 checks passed
@martinrrm
martinrrm deleted the cli-170-windows-cmd-escaping branch October 12, 2026 17:24
@github-actions github-actions Bot mentioned this pull request Oct 12, 2026
martinrrm pushed a commit that referenced this pull request Oct 21, 2026
🤖 I have created a release *beep* *boop*
---


##
[10.0.1](v10.0.0...v10.0.1)
(2026-10-12)
### Bug Fixes
*
[`e823469`](e823469)
[#166](#166) decode escaped
Windows executable tokens (#166) (@martinrrm, @Copilot)
### Chores
*
[`8d804b6`](8d804b6)
[#161](#161) bump
@npmcli/eslint-config from 6.0.1 to 7.0.0 (#161) (@dependabot[bot])
*
[`e19bfab`](e19bfab)
[#162](#162) bump
@npmcli/template-oss from 5.1.0 to 5.1.1 (#162) (@dependabot[bot],
@npm-cli-bot)

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
martinrrm added a commit that referenced this pull request Oct 21, 2026
Actual Windows CI checks out YAML with CRLF. Normalize workflow text before matching LF-delimited input and Publish-step blocks, and exercise both LF and CRLF fixtures on every platform. The new CRLF cases reproduce the original CI failures before normalization. Leave the published #166 backport, pinned which API, generated workflows, Node matrix and release configuration unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 330b549f-83dd-4218-81b7-003cc008f869
martinrrm added a commit that referenced this pull request Oct 21, 2026
Actual Windows CI checks out YAML with CRLF. Normalize workflow text before matching LF-delimited input and Publish-step blocks, and exercise both LF and CRLF fixtures on every platform. The new CRLF cases reproduce the original CI failures before normalization. Leave the published #166 backport, pinned which API, generated workflows, Node matrix and release configuration unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 330b549f-83dd-4218-81b7-003cc008f869
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants