Skip to content

fix: bulletproof classifier subprocess isolation on Windows (v1.2.2)#11

Merged
JonyanDunh merged 1 commit into
mainfrom
fix/windows-classifier-isolation-v1.2.2
Apr 11, 2026
Merged

fix: bulletproof classifier subprocess isolation on Windows (v1.2.2)#11
JonyanDunh merged 1 commit into
mainfrom
fix/windows-classifier-isolation-v1.2.2

Conversation

@JonyanDunh
Copy link
Copy Markdown
Owner

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:

  1. Haiku kept replying "I've received the context about the PallasAI project structure, Datadog/PostgreSQL MCP integration details, and your global instructions..." — i.e. the classifier subprocess was being briefed on the whole project despite `--system-prompt`, `--tools ""`, and `--disable-slash-commands`.
  2. Later, subprocess exit 1 with stderr:
    ```
    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

  • `--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 still emits plain tokens and the fallback keeps tests green unchanged).
  • `--effort low`: minimizes reasoning budget. Classifier just needs to output one token.
  • 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 literally how all four bugs above were diagnosed from the user's real Windows session.
  • Pre-flight existence checks for both bundled config files. 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. "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), and Requirements is explicit that BOTH `claude` and `node` must be in `PATH`.

Test plan

  • 80 tests locally, 78 active + 2 skipped-inside-Claude-Code, 0 fail
  • New bundled files (`lib/classifier-system-prompt.txt`, `lib/empty-mcp-config.json`) ship in the plugin
  • Pre-flight checks catch missing bundled files
  • mock-CLI integration tests (stop-hook-haiku.test.js) still work — they emit plain tokens, the JSON-parse fallback handles that path
  • CI will confirm across 9 matrix jobs (ubuntu/macos/windows × Node 18/20/22)

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

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>
@JonyanDunh JonyanDunh merged commit d994f47 into main Apr 11, 2026
11 checks passed
@JonyanDunh JonyanDunh deleted the fix/windows-classifier-isolation-v1.2.2 branch April 11, 2026 02:34
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