Skip to content

Refuse to spawn .bat/.cmd CLI scripts on Windows to prevent command injection#1127

Open
qing-ant wants to merge 3 commits into
mainfrom
qing/refuse-windows-batch-cli
Open

Refuse to spawn .bat/.cmd CLI scripts on Windows to prevent command injection#1127
qing-ant wants to merge 3 commits into
mainfrom
qing/refuse-windows-batch-cli

Conversation

@qing-ant

Copy link
Copy Markdown
Contributor

Summary

Follow-up to the argv-injection fix in #1123 (HackerOne #3870101). On Windows installs that have no bundled claude.exe — sdist / source installs and platforms without a bundled wheel, notably Windows ARM64 — CLI discovery falls back to shutil.which("claude"), which resolves npm's claude.cmd batch shim. CreateProcess runs .bat/.cmd files by rewriting the spawn into cmd.exe /c ..., and cmd.exe re-parses the whole command line. subprocess.list2cmdline quotes for the MSVCRT argv rules only (it adds quotes only around whitespace, and cmd.exe does not honor \" escaping), so cmd.exe metacharacters inside an argument value — a --resume session title, the --mcp-config JSON, a system prompt — reach cmd.exe unescaped and can execute injected commands before the CLI even starts. The --flag=value equals form from #1123 does not help on this path: once cmd.exe re-parses the string there is no argv boundary left to protect.

This is the "BatBadBut" vulnerability class (CVE-2024-27980).

The fix

Refuse to spawn a .bat/.cmd script as the CLI on Windows. There is no reliable escaping for cmd.exe (%VAR% expands even inside double quotes), so refusing is the only robust remediation — the same one Node.js shipped for CVE-2024-27980.

The check runs once in connect(), immediately after the CLI path is resolved and before anything is spawned with it, so it covers every route to the executable path:

  • the shutil.which("claude") fallback that finds npm's claude.cmd,
  • an explicit ClaudeAgentOptions(cli_path=...) pointing at a .bat/.cmd,
  • the version probe, which spawns the same path before the main process.

The extension test normalizes the path the way Win32 does before checking it — trailing dots and spaces stripped, ./.. and repeated separators collapsed, NTFS alternate-data-stream specs (claude.cmd:stream, claude:evil.cmd) covered in both directions, drive-relative C:claude.cmd, and a bare .cmd treated as a batch extension (as PathFindExtension treats it). This is the same normalization Rust applied for CVE-2024-24576. It is deliberately plain string logic rather than pathlib, so it behaves identically on the POSIX CI hosts and on Windows.

The error message points at the alternatives that avoid cmd.exe entirely: the native installer (irm https://claude.ai/install.ps1 | iex), an explicit claude.exe path via ClaudeAgentOptions(cli_path=...), or a wheel for a platform that bundles claude.exe.

Defense in depth

With batch-script spawning refused, cmd.exe metacharacters are harmless — list2cmdline quotes correctly for native executables. resume and session_id are nevertheless the values applications most often take from external input, so on Windows they now reject cmd.exe metacharacters (& | < > ^ % ! ") and CR/LF. That keeps them inert even if a cmd.exe hop is ever reintroduced between the SDK and the CLI. No format is imposed beyond that (resume values may be arbitrary session titles, not only UUIDs), and POSIX behavior is unchanged.

Related hardening

extra_args now emits --flag=value when the value starts with -, so a dash-leading value binds to its flag instead of parsing as a separate CLI flag — the same class of issue the --resume equals-form change in #1123 closed, applied to the remaining two-token call site.

Behavior changes on Windows

  • Launching a .bat/.cmd CLI (including npm's claude.cmd shim) now raises CLIConnectionError instead of spawning it. Windows users relying on the npm shim need the native installer, an explicit claude.exe path, or a wheel that bundles the CLI.
  • resume / session_id values containing cmd.exe metacharacters or newlines now raise ValueError on Windows — e.g. resume="R&D notes". POSIX is unaffected.

Recommended follow-up

Where an npm-only Windows install has no claude.exe, the SDK could resolve the .cmd shim to the underlying node.exe + cli.js and launch node directly with a list argv, restoring npm-shim support without any cmd.exe hop. Not included here to keep this change a focused refusal.

Scope

  • src/claude_agent_sdk/_internal/transport/subprocess_cli.py: _reject_windows_batch_cli, _reject_windows_cmd_metacharacters, the connect() chokepoint, and the extra_args equals form.
  • tests/test_transport.py: TestWindowsBatchScriptRefusal, TestExtraArgsValueBinding, TestWindowsCmdMetacharacterRejection.
  • No behavior change on Linux or macOS.

Testing

  • Full suite: 1268 passed, 5 skipped (pytest tests/), plus ruff check, ruff format --check, and mypy src/ clean.
  • 36 new tests; 30 of them fail with the source change reverted, so they exercise the fix rather than pre-existing behavior. The refusal tests assert anyio.open_process is called zero times — the batch script is refused before the version probe, not merely before the main spawn.
  • The Windows code paths are exercised by patching platform.system(); the actual cmd.exe re-parse is Windows-only behavior reasoned from CreateProcess / list2cmdline semantics rather than executed in CI.

When no bundled claude.exe is present (sdist installs, and any platform
without a wheel that bundles the CLI, notably Windows ARM64), CLI
discovery falls back to shutil.which("claude"), which on Windows
resolves npm's claude.cmd shim. CreateProcess runs .bat/.cmd files by
rewriting the spawn into `cmd.exe /c ...`, and cmd.exe re-parses the
whole command line: subprocess.list2cmdline quotes for the MSVCRT argv
rules only, so cmd.exe metacharacters inside any argument value (a
--resume session title, the --mcp-config JSON, the system prompt) reach
cmd.exe unescaped and can execute injected commands before the CLI
starts. Passing the values in the `--flag=value` equals form does not
help on this path -- there is no argv boundary once cmd.exe re-parses
the string.

There is no reliable escaping for cmd.exe (%VAR% expands even inside
double quotes), so the fix refuses to spawn a .bat/.cmd script at all,
the same remediation Node.js shipped for this vulnerability class
(CVE-2024-27980, "BatBadBut"). The check runs once in connect(),
immediately after the CLI path is resolved -- covering the shutil.which
fallback, an explicit ClaudeAgentOptions(cli_path=...), and the version
probe -- and normalizes the path the way Win32 does before testing the
extension (trailing dots/spaces, ".."/"." collapse, alternate data
stream specs, bare ".cmd"), following the normalization Rust applied
for CVE-2024-24576. The error message points at the native installer,
an explicit claude.exe path, or a platform wheel that bundles the CLI.

Also emit extra_args entries as `--flag=value` when the value starts
with "-", so a dash-leading value binds to its flag instead of parsing
as a separate CLI flag (the same class of issue the --resume equals-form
change closed).

As defense in depth, resume and session_id now reject cmd.exe
metacharacters and CR/LF on Windows, so those commonly externally
sourced values stay inert even if a cmd.exe hop is ever reintroduced.
This is a behavior change on Windows only: a value such as
resume="R&D notes" now raises ValueError. POSIX is unchanged.

:house: Remote-Dev: homespace

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, this pass also examined and ruled out: (1) the refusal aborting CLI discovery rather than skipping the .cmd candidate — intended behavior, and the error message carries the remediation; (2) cmd.exe-metacharacter bypass via extra_args-supplied resume/session-id — with batch spawning refused, list2cmdline quotes correctly for native executables, so the metachar check is defense-in-depth for the two documented options only; (3) the metacharacter ValueError escaping connect() unwrapped — it matches the existing ValueError pattern for invalid option values in _build_command.

Extended reasoning...

One inline finding is a bypass of the batch-script refusal itself (trailing dot-dot-space segment diverging from Win32 normalization), so this security PR needs that addressed before merge — no approval. This note only records the additional candidates that were investigated and refuted this run, so a later pass or the author does not re-explore them from scratch. It is informational, not a guarantee of correctness elsewhere in the diff.

Comment thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py Outdated
Comment thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py
…nt safe on Windows

A final path segment like ".. " or ".. ." was dropped instead of
treated as a parent reference, so "claude.cmd\x\.. " normalized to
"x" and passed the batch-script guard even though Win32 trims the
trailing dots and spaces first and resolves the path to claude.cmd.
Classify each component after that trimming: a dots-and-spaces
segment that starts with ".." pops the previous component, and any
other one still disappears. This only widens what is refused.

The CLI-not-found message also recommended the npm install first,
which on Windows produces the claude.cmd shim that connect() now
refuses. Lead with the native claude.exe installer there instead and
call out the npm shim; the POSIX wording is unchanged.

:house: Remote-Dev: homespace
Comment thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Comment thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py
The stale-PATH fallback list probed only extensionless names, so
Path.exists() (which does no PATHEXT resolution) could never find the
native installer's ~/.local/bin/claude.exe -- the very remedy the Windows
not-found message recommends. Add that location.

Also stop treating 3+-dot components ("...", "....") as parent
references. Win32 only applies a parent reference when a segment's
leading dot-run is exactly ".."; longer runs trim away or cannot be
opened, so popping on them let "claude.cmd\..." resolve to its parent
directory and be allowed while the equivalent "claude.cmd\" spelling was
refused. Pop only when the dot-run is exactly two, keeping the guard
over-refuse-only.

:house: Remote-Dev: homespace
Comment on lines +641 to +644
# Validate the resolved CLI before anything is spawned with it --
# this guards the version probe below as well as the main spawn.
self._reject_windows_batch_cli(self._cli_path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 On Windows, _find_cli() returns shutil.which("claude") unconditionally, and which() is directory-major over PATH — so npm's claude.cmd shim shadows a native claude.exe installed later in PATH, and connect() hard-refuses via _reject_windows_batch_cli even though a safe, SDK-blessed executable is discoverable. The refusal message's leading remedy (run the native installer) cannot recover this setup, because which() keeps resolving the shim on retry; consider trying shutil.which("claude.exe") and the fallback locations before committing to a .bat/.cmd hit, so dual-install machines auto-discover the native exe while shim-only machines still get the explanatory refusal.

Extended reasoning...

The gap

_find_cli() (src/claude_agent_sdk/_internal/transport/subprocess_cli.py:158-159, unchanged by this PR) returns shutil.which("claude") unconditionally. On Windows, CPython's shutil.which is directory-major: for each PATH directory it tries every PATHEXT extension before moving to the next directory. So when npm's %APPDATA%\npm precedes the native installer's %USERPROFILE%\.local\bin in PATH — the common layout, since npm's PATH entry is written at npm-install time and the native installer appends its entry later — which() resolves claude.cmd even though a claude.exe sits in a later directory. connect() then hits the new chokepoint at line 643 and _reject_windows_batch_cli raises CLIConnectionError.

The consequence is that the refusal's own leading remedy cannot restore auto-discovery:

Step-by-step proof

  1. Windows machine, SDK without a bundled claude.exe (sdist install, or a platform with no bundled wheel such as Windows ARM64 — exactly the population the PR description targets). Claude Code is installed via npm, so %APPDATA%\npm\claude.cmd is on PATH.
  2. connect()_find_cli()shutil.which("claude")...\claude.cmd_reject_windows_batch_cli raises: "install Claude Code natively (irm https://claude.ai/install.ps1 | iex), point ClaudeAgentOptions(cli_path=...) at a claude.exe, ...".
  3. The user follows the lead remedy and runs the native installer. It writes ~\.local\bin\claude.exe and appends its directory to PATH — after the npm entry.
  4. The user retries. which("claude") still returns claude.cmd (earlier PATH directory wins), and connect() refuses again with the same message recommending the installer they just ran.

Why this PR's own follow-up fixes don't reach it

Both remediation-loop fixes the author already shipped live on the which()-returns-None path, which is never taken here because which() succeeds:

  • the platform-aware CLINotFoundError message (1bf1e9e, lines 178-191), and
  • the ~/.local/bin/claude.exe fallback entry (bb004a8, line 168) — the fallback locations list, which now explicitly contains the very executable the user just installed, is unreachable whenever which() returns a hit.

So the same remediation-dead-end class the author fixed twice on the not-found path recurs on the which()-succeeds path.

Addressing the counter-argument

One reviewer argued this is intentional, documented behavior rather than a defect. Partially agreed — and it is why this is a nit, not a blocker:

  • Refusing the shim is indeed the PR's explicit purpose, and the failure is a loud, immediate CLIConnectionError, not silent misbehavior.
  • The refusal message does list ClaudeAgentOptions(cli_path=...), which always recovers regardless of PATH ordering — so no user is truly bricked.
  • The PR's "Recommended follow-up" scoped out a different enhancement (resolving the .cmd shim to node.exe + cli.js); preferring a non-batch candidate during discovery is a much smaller change and was not explicitly considered.

But "discovery commits to a candidate that the very next line of connect() is guaranteed to reject, while a usable candidate is discoverable" is a genuine policy gap, not just standard PATH semantics: the author demonstrably wants the native exe to be auto-discoverable (that is what bb004a8 was for), and the shim-first which() result silently defeats that on dual-install machines. The PR description's behavior-change note ("Windows users relying on the npm shim need the native installer...") implies installing natively is sufficient — on this path it is not.

Suggested fix

On Windows, when which("claude") resolves to a path with a batch extension (reuse the extension test from _reject_windows_batch_cli), do not return it immediately: first try shutil.which("claude.exe") and the fallback locations, and only return the shim path (so the connect() guard can raise its explanatory refusal) if no non-batch candidate exists. This preserves the security property — a batch script is still never spawned — while letting dual-install setups auto-discover the safe executable.

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.

1 participant