Skip to content

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

Merged
qing-ant merged 6 commits into
mainfrom
qing/refuse-windows-batch-cli
Jul 20, 2026
Merged

Refuse to spawn .bat/.cmd CLI scripts on Windows to prevent command injection#1127
qing-ant merged 6 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 Outdated
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 thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py
shutil.which walks PATH directory-major, and within a directory PATHEXT
tries .CMD before .EXE, so on a machine with both an npm install (an
early %APPDATA%\npm\claude.cmd) and a native claude.exe in a later
directory, which("claude") resolves the shim and connect() refuses even
though a safe executable is discoverable.

When which("claude") resolves a batch script on Windows, probe
which("claude.exe") and the fallback install locations first, and
only hand the shim back when no native executable is found -- so a
shim-only machine still gets the explanatory batch-script refusal.
The .bat/.cmd refusal itself is unchanged; POSIX discovery 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 run also examined and ruled out: a missing CHANGELOG.md entry (this repo updates the changelog in per-release commits, e.g. f604b8c, not per PR); a separate '.. ..' dots-and-spaces pop divergence in _is_windows_batch_cli (not a distinct issue beyond the middle-component desync already reported inline); and the shim fallthrough returning an extensionless POSIX shell script that then executes via cmd.exe (a non-.bat/.cmd file is never rewritten through cmd.exe — CreateProcess fails to spawn a non-PE image, so the spawn fails loudly rather than injecting).

Extended reasoning...

Bugs were found, so the inline comments carry the verdict and I am not approving. This note only records what else was examined and refuted this run so a later pass does not re-explore it from scratch: the changelog convention was checked against git history, the '.. ..' segment behavior was traced through the same component loop as the confirmed finding, and the extensionless-script concern was ruled out because the BatBadBut rewrite applies only to .bat/.cmd images. This is informational, not a guarantee of correctness or an instruction to skip anything.

Comment thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py Outdated
Comment thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py Outdated
…native-only

Classify every path component instead of simulating Win32 normalization
to find the effective final one: a middle dots-or-spaces-only component
("...", ". .", " ", ".. ") is a literal name on Windows, so a following
".." pops it and "C:\tools\claude.cmd\...\.." resolves to claude.cmd
while a final-component simulation lands on another name. Refusing when
any component (or NTFS stream / drive segment) carries a .bat/.cmd
extension closes the whole normalization-desync class -- every such trick
still has to spell the batch component somewhere -- and over-refuses
nothing real, since no claude.exe lives beneath a batch-named directory.

Also give Windows its own fallback list containing only the native
installer's ~/.local/bin/claude.exe. The shim-fallthrough now reaches
the fallback loop for npm-shim-only Windows machines, and the POSIX
entries misbehave there: an extensionless artifact would preempt the
explanatory batch-script refusal with an opaque spawn failure, and the
rooted-but-driveless /usr/local/bin/claude resolves against the current
drive, a location another local user can create.

: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 this run's inline nits, I re-checked the two findings from my previous review against 85da04a: the middle-dots/.. stack-desync bypass no longer reproduces — I ran _is_windows_batch_cli directly against all four bypass spellings (claude.cmd\...\.., . .\.., \ \.., .. \..) and each is now refused by the any-component batch-extension rule, while the legitimate claude.exe paths remain allowed. The drive-relative /usr/local/bin/claude planting path is also closed: the Windows fallback list is now native-only (~/.local/bin/claude.exe), locked by test_windows_fallback_skips_posix_shaped_probes.

Extended reasoning...

Both findings from the previous review round (the 🔴 component-stack desync bypass of _is_windows_batch_cli and the 🟡 POSIX-shaped fallback probes reachable on Windows) were independently re-verified against the current head (85da04a): I executed the guard with platform.system() patched to Windows and confirmed all four bypass spellings are refused and the legitimate exe corpus is unaffected, and confirmed by reading _find_cli that the Windows fallback probes only the native installer's claude.exe. This run's two remaining findings are documentation/coverage nits posted inline; the PR remains security-sensitive command-injection hardening, so it still warrants human review rather than shadow approval.

Comment thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Comment thread src/claude_agent_sdk/_internal/transport/subprocess_cli.py Outdated
On Windows the native-exe rescue only ran when which("claude")
resolved a batch shim, so two other which() results were trusted
blindly. On Python 3.12+ shutil.which appends PATHEXT extensions even
to a name that already has one, so which("claude.exe") can return a
stray "claude.exe.cmd", and it also probes the bare name, so an
extensionless git-bash / WSL wrapper script in an early PATH
directory shadows a native claude.exe in a later one and is returned
as-is (dying at spawn with WinError 193 while the rescue never runs).

Take the early return only for a native .exe/.com hit, apply the same
native check to the claude.exe probe, and route every other hit
through the fallback probe before handing it back as the last resort
-- so a shim-only machine still gets the batch-script refusal and a
wrapper-only one still fails at spawn, but a discoverable native
executable now always wins. Also reword the shadowing comments: the
default PATHEXT prefers .EXE over .CMD within one directory, so the
shadowing is directory-order alone.

: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.

No new findings this round — the fixes in 85da04a and 28fac00 hold up under re-review, and everything raised in earlier rounds is now addressed. Since this PR is a security fix in the CLI spawn path, it still warrants a human sign-off rather than a bot approval.

What was reviewed this round:

  • The any-component batch check in _is_windows_batch_cli against the four prior normalization-desync spellings — all refused now, since claude.cmd appearing as any component triggers refusal regardless of what follows.
  • _is_windows_native_exe vetting on both which() results — a PATHEXT-appended claude.exe.cmd is skipped (final segment ends .cmd after trim), and an extensionless wrapper hit falls through to the native-exe rescue instead of an early return.
  • The Windows fallback list — the drive-relative and extensionless POSIX probes are gone; which_hit is returned only as the last resort, so shim-only machines still get the explanatory refusal.
  • POSIX behavior — the non-Windows branch returns the which("claude") hit directly and the POSIX locations list is unchanged.
Extended reasoning...

Overview

This PR hardens SubprocessCLITransport against the BatBadBut vulnerability class (CVE-2024-27980): on Windows, CreateProcess runs .bat/.cmd files by rewriting the spawn into cmd.exe /c, and subprocess.list2cmdline quotes only for MSVCRT argv rules, so cmd.exe metacharacters in argument values (resume titles, MCP config JSON, system prompts) could execute injected commands. The fix refuses to spawn batch scripts at a single chokepoint in connect(), adds a defense-in-depth metacharacter rejection for resume/session_id on Windows, closes a dash-leading-value binding gap in extra_args, and — across four review-driven commits — reworks Windows CLI discovery to prefer a native claude.exe over npm's shim while never widening what may be spawned. Changes are confined to subprocess_cli.py plus ~500 lines of new tests.

Security risks

The PR is itself a security fix, and its review history shows why it needs careful eyes: three successive rounds found real issues — a Win32-normalization desync that let claude.cmd\...\..-style paths bypass the guard entirely (a genuine bypass of the PR's core protection, confirmed and fixed in 85da04a), a binary-planting probe via the drive-relative /usr/local/bin/claude fallback (fixed in 85da04a), and unvetted shutil.which() results on Python 3.12+ (fixed in 28fac00). I verified the final state against HEAD: the any-component refusal in _is_windows_batch_cli structurally kills the normalization-desync class (every trick must spell the batch component somewhere in the string), the Windows fallback probes only the native installer's ~/.local/bin/claude.exe, and both which() results pass _is_windows_native_exe before being preferred — while connect() still validates every returned path with _reject_windows_batch_cli, so the discovery preference never widens the spawn surface. This run's bug hunt found no new issues.

Level of scrutiny

Maximum. This is production transport code guarding against command injection, tied to a HackerOne report, with subtle Windows path-resolution semantics (trailing dot/space trimming, NTFS streams, drive-relative paths, PATHEXT behavior differences across Python versions) that this PR's own history proves are easy to get slightly wrong. It also carries a deliberate behavior break: Windows npm-shim users now get a hard CLIConnectionError instead of a working (but injectable) CLI, and resume/session_id values with cmd.exe metacharacters now raise ValueError on Windows. Whether that breakage/telemetry tradeoff is acceptable — and whether the recommended follow-up (resolving the shim to node.exe + cli.js) should land first — is a product call for a human maintainer.

Other factors

Test coverage is strong: the refusal tests assert zero anyio.open_process calls (guarding the version probe too), the author reports 30 of 36 new tests fail with the fix reverted, and each review round added regression tests confirmed failing-before/passing-after. All review threads are resolved and every raised issue has a corresponding fix commit verified in this pass. The one open nit (inverted PATHEXT-ordering claim in a comment) was fixed in the final commit's comment text. Nothing is outstanding — the only reason not to approve is the security-critical nature of the code, which per policy requires human sign-off.

@qing-ant
qing-ant enabled auto-merge (squash) July 20, 2026 19:37
@qing-ant
qing-ant disabled auto-merge July 20, 2026 19:40
@qing-ant
qing-ant merged commit 879e920 into main Jul 20, 2026
16 checks passed
@qing-ant
qing-ant deleted the qing/refuse-windows-batch-cli branch July 20, 2026 20:31
Flohs pushed a commit to Flohs/claude-agent-sdk-go that referenced this pull request Jul 21, 2026
…tacharacters in Resume/SessionID

On Windows, CLI discovery can resolve npm's claude.cmd batch shim (via
PATHEXT), which Windows always executes through cmd.exe. cmd.exe
re-parses the entire command line at spawn time, so argv quoting that
is correct for a native executable does not protect against cmd.exe
metacharacter expansion (e.g. %VAR% expands even inside double
quotes) -- the "BatBadBut" vulnerability class, CVE-2024-27980.

SubprocessTransport.Connect now refuses to spawn if the resolved CLI
path (from Options.CLIPath or findCLI()) is a .bat/.cmd script,
returning a *ConnectionError before any process -- including the
version probe -- is spawned. The path-normalization extension check
(isWindowsBatchScript) is pure, OS-independent string logic so it is
unit-tested on Linux CI; it and the cmd.exe-metacharacter check
(containsWindowsCmdMetacharacters) are only enforced when
runtime.GOOS == "windows", via small *ForGOOS gating wrappers that
take the OS name as a parameter for direct testability. As defense in
depth, Options.Resume/Options.SessionID values containing a cmd.exe
metacharacter or CR/LF are now rejected with an error on Windows
instead of being forwarded.

POSIX/Linux/macOS behavior is unchanged.

Port of Python SDK PR anthropics/claude-agent-sdk-python#1127, a
follow-up to the argv-injection fix already ported as #495/#502.

Closes #527
Flohs added a commit to Flohs/claude-agent-sdk-go that referenced this pull request Jul 21, 2026
…tacharacters in Resume/SessionID (#534)

On Windows, CLI discovery can resolve npm's claude.cmd batch shim (via
PATHEXT), which Windows always executes through cmd.exe. cmd.exe
re-parses the entire command line at spawn time, so argv quoting that
is correct for a native executable does not protect against cmd.exe
metacharacter expansion (e.g. %VAR% expands even inside double
quotes) -- the "BatBadBut" vulnerability class, CVE-2024-27980.

SubprocessTransport.Connect now refuses to spawn if the resolved CLI
path (from Options.CLIPath or findCLI()) is a .bat/.cmd script,
returning a *ConnectionError before any process -- including the
version probe -- is spawned. The path-normalization extension check
(isWindowsBatchScript) is pure, OS-independent string logic so it is
unit-tested on Linux CI; it and the cmd.exe-metacharacter check
(containsWindowsCmdMetacharacters) are only enforced when
runtime.GOOS == "windows", via small *ForGOOS gating wrappers that
take the OS name as a parameter for direct testability. As defense in
depth, Options.Resume/Options.SessionID values containing a cmd.exe
metacharacter or CR/LF are now rejected with an error on Windows
instead of being forwarded.

POSIX/Linux/macOS behavior is unchanged.

Port of Python SDK PR anthropics/claude-agent-sdk-python#1127, a
follow-up to the argv-injection fix already ported as #495/#502.

Closes #527

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants