fix: bulletproof classifier subprocess isolation on Windows (v1.2.2)#11
Merged
Merged
Conversation
v1.2.1 fixed the prompt-delivery channel for the Haiku classifier
(stdin instead of argv, to avoid cmd.exe's quoting the main prompt).
It shipped green on CI but on the first real Windows run the user
discovered TWO new symptoms:
1. Haiku responses like "I've received the context about the
PallasAI project structure, Datadog/PostgreSQL MCP integration
details..." — the classifier had been briefed on the whole
project despite our --system-prompt, --tools "", and
--disable-slash-commands flags.
2. Later, subprocess exit 1 with stderr containing:
CMD.EXE does not support UNC paths as the current directory.
Default to the Windows directory.
Error: Invalid MCP configuration:
MCP config file not found: C:\Windows\{mcpServers:{}}
Diagnosed from the new CLAUDE_CODE_WATCHDOG_LOG_ENABLED=1 verbose
log (set from a real failing Windows session, logs read back in
WSL2 via /mnt/c/...). Four real bugs behind those symptoms, each
fixed below.
## Fix 1: --system-prompt-file (bundled) instead of --system-prompt
Windows Node spawnSync with `shell: true` (required to resolve
claude.cmd through PATHEXT) passes argv through cmd.exe, which
splits multi-word arguments on spaces. A long "You are a binary
classifier..." sentence got chopped at the first space, "You"
became the --system-prompt value, and "are a binary classifier..."
became stray positional args that Claude Code interpreted as a
user prompt.
Fix: bundle lib/classifier-system-prompt.txt with the plugin and
pass its absolute path via --system-prompt-file. cmd.exe happily
forwards a short space-free path; Claude Code reads the file as
honest text. Zero runtime I/O to create the file — it ships with
the plugin.
## Fix 2: --mcp-config <bundled json file> instead of inline JSON
`--tools ""` disables built-in tools only. MCP servers the user
has globally enabled (Datadog, Postgres, Playwright, context7,
etc.) were still being loaded and their descriptions injected
into the classifier's context. That's why Haiku kept responding
as a PallasAI assistant instead of a binary classifier.
First attempt was `--mcp-config '{"mcpServers":{}}'` as an inline
string. On Windows, cmd.exe stripped the embedded double quotes,
leaving `{mcpServers:{}}` — not valid JSON. Claude Code fell back
to treating it as a file path, prefixed it with the subprocess
cwd, and tried to read `C:\Windows\{mcpServers:{}}` (see Fix 3 for
how it ended up at `C:\Windows`).
Fix: bundle lib/empty-mcp-config.json with the plugin and pass
its absolute path via --mcp-config + --strict-mcp-config. Cmd.exe
preserves the path, Claude Code reads the file, and the strict
flag refuses to merge in anything else.
## Fix 3: explicit non-UNC cwd for the classifier subprocess
cmd.exe cannot cd to UNC paths like
`\\wsl.localhost\Ubuntu-24.04\home\jonyan\projects\claude-code-watchdog`.
When Node spawned claude with that cwd inherited from the hook
(the user runs Windows Claude Code but accesses the repo over an
SMB UNC path to WSL2), cmd.exe silently fell back to `C:\Windows`
as cwd. Any remaining relative-looking paths in argv got resolved
from there. That's the direct cause of the hilarious
`C:\Windows\{mcpServers:{}}` in the stderr above.
Fix: pass `cwd: os.tmpdir()` explicitly to spawnSync. On Windows
this resolves to something like `C:\Users\<user>\AppData\Local\Temp`
— always a real Windows path, never UNC. Bonus: the subprocess
runs outside any project directory, so Claude Code definitely
won't auto-discover project-level CLAUDE.md or .claude/settings.json
from it. Cleaner context isolation as a side effect.
## Fix 4: broaden allowed-tools to Bash(node:*)
Running /watchdog:start under `acceptEdits` permission mode hit:
Error: Shell command permission check failed for pattern
"! node "/home/.../scripts/setup-watchdog.js" "..." ..."
The slash command's `allowed-tools` frontmatter had
`Bash(node ${CLAUDE_PLUGIN_ROOT}/scripts/setup-watchdog.js:*)`.
The actual command (a) carried a leading `!` shell-exec marker,
(b) had double quotes around the script path, and (c) had a
double slash because `${CLAUDE_PLUGIN_ROOT}` already ends with
`/`. Three strikes, the matcher never matched.
Fix: widen to `Bash(node:*)`. The scope is still restricted to
`node <anything>` invocations, and within this slash command the
`!` block is hardcoded to exactly one line calling setup-watchdog.js
or stop-watchdog.js — users cannot inject arbitrary node commands
through /watchdog:start. `hide-from-slash-command-tool: "true"`
keeps the SlashCommand tool from exposing the command to the
agent.
## Also in this release
- **--output-format json**: classifier subprocess now emits a JSON
envelope (result + cost + duration + num_turns) on stdout. The
verdict parser extracts `.result` if parsing succeeds, falls
back to raw stdout otherwise (mock CLI in tests emits plain
tokens, and the fallback keeps tests green unchanged).
- **--effort low**: minimizes reasoning budget. Classifier just
needs to output one token, so there's no reason to burn effort.
- **Verbose diagnostic logs**: every classifier call now logs the
full stdin (tool_uses JSON), full stdout, full stderr, full
args array, parsed envelope, extracted verdict text, and parsed
verdict — gated on CLAUDE_CODE_WATCHDOG_LOG_ENABLED=1. This is
exactly how all the bugs above were diagnosed from the user's
real Windows session without me having a Windows box.
- **Pre-flight file existence checks** for both bundled config
files (system prompt and empty MCP). If either is missing from
the plugin install, we fail the hook loudly (CLI_FAILED) instead
of silently letting Claude Code fall back to its defaults.
- **7 READMEs re-synced** via parallel subagents. The "Why
Watchdog" bullet, Exit Conditions table, Prompt Writing Best
Practices tip, Requirements section, Install dependencies intro,
Plugin Layout tree `judge.js` comment, and Inspired By comparison
table now describe the classifier as a "Claude Code subprocess"
(with Haiku as an implementation detail) rather than
Haiku-branded terminology, and the Requirements section is more
explicit that BOTH `claude` and `node` must be in PATH.
## Tests
- 80 total, 78 active + 2 skipped-inside-Claude-Code, 0 fail
- stop-hook-haiku.test.js mock CLI still works unchanged — it
emits plain tokens, the judge.js fallback handles that path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's broken (diagnosed from a real Windows log)
v1.2.1 fixed the prompt-delivery channel (stdin instead of argv) so Haiku stopped seeing a shredded version of the main prompt. It shipped green across the 9-job CI matrix but on the first real Windows run the user hit TWO fresh symptoms:
```
CMD.EXE does not support UNC paths as the current directory.
Default to the Windows directory.
Error: Invalid MCP configuration:
MCP config file not found: C:\Windows{mcpServers:{}}
```
Diagnosed from the new `CLAUDE_CODE_WATCHDOG_LOG_ENABLED=1` verbose log — turned on in the user's real failing session, read back in WSL2 via `/mnt/c/Users/.../AppData/Local/Temp/claude-code-watchdog.log`. Four real bugs hiding behind those symptoms.
Fix 1 — `--system-prompt-file` with a bundled file, not `--system-prompt` with an argv string
Windows `spawnSync` with `shell: true` (required for claude.cmd PATHEXT resolution) forwards the command line through cmd.exe, which splits multi-word argv on spaces. A long `"You are a binary classifier..."` sentence got chopped at the first space: `"You"` became the `--system-prompt` value and `"are a binary classifier..."` became stray positional args that Claude Code read as a user prompt.
Fix: bundle `lib/classifier-system-prompt.txt` with the plugin. Pass its absolute path via `--system-prompt-file`. cmd.exe happily forwards a short space-free path; Claude Code reads the file as honest text. Zero runtime I/O.
Fix 2 — `--mcp-config` with a bundled JSON file, not an inline string
`--tools ""` disables built-in tools only. MCP servers the user has globally enabled (Datadog, Postgres, Playwright, context7, etc.) were still being loaded and their descriptions injected into the classifier's context. That's why Haiku kept responding as a PallasAI assistant instead of a binary classifier.
First attempt was `--mcp-config '{"mcpServers":{}}'` as an inline string. On Windows, cmd.exe stripped the embedded double quotes, leaving `{mcpServers:{}}` — not valid JSON. Claude Code fell back to treating it as a file path, prefixed it with the subprocess cwd, and tried to read `C:\Windows\{mcpServers:{}}` (see Fix 3 for how it ended up at `C:\Windows`).
Fix: bundle `lib/empty-mcp-config.json` with the plugin and pass its absolute path via `--mcp-config` + `--strict-mcp-config`. cmd.exe preserves the path, Claude Code reads the file, strict flag refuses to merge anything else.
Fix 3 — explicit non-UNC cwd for the classifier subprocess
cmd.exe cannot cd to UNC paths like `\\wsl.localhost\Ubuntu-24.04\home\jonyan\projects\claude-code-watchdog`. When Node spawned claude with that cwd inherited from the hook (the user runs Windows Claude Code but accesses the repo over SMB UNC to WSL2), cmd.exe silently fell back to `C:\Windows` as cwd. Any remaining relative-looking paths in argv got resolved from there. That's the direct cause of the hilarious `C:\Windows\{mcpServers:{}}` in the stderr above.
Fix: pass `cwd: os.tmpdir()` explicitly to `spawnSync`. On Windows this resolves to `C:\Users\\AppData\Local\Temp` — always a real Windows path, never UNC. Bonus: the subprocess runs outside any project directory, so Claude Code definitely won't auto-discover project-level `CLAUDE.md` or `.claude/settings.json` from it. Cleaner context isolation as a side effect.
Fix 4 — broaden `allowed-tools` to `Bash(node:*)`
Running `/watchdog:start` under `acceptEdits` permission mode hit:
```
Error: Shell command permission check failed for pattern
"! node "/home/.../scripts/setup-watchdog.js" "..." --max-iterations 10"
```
The slash command's `allowed-tools` frontmatter had `Bash(node ${CLAUDE_PLUGIN_ROOT}/scripts/setup-watchdog.js:*)`. The actual command (a) carried a leading `!` shell-exec marker, (b) had double quotes around the script path, and (c) had a double slash because `${CLAUDE_PLUGIN_ROOT}` already ends with `/`. Three strikes, the matcher never matched.
Fix: widen to `Bash(node:*)`. Scope is still restricted to `node ` invocations, and within this slash command the `!` block is hardcoded to exactly one line calling `setup-watchdog.js` or `stop-watchdog.js` — users cannot inject arbitrary node through `/watchdog:start`. `hide-from-slash-command-tool: "true"` keeps the SlashCommand tool from exposing the command to the agent.
Also in this release
Test plan
Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com