From dcd1d7f973ba21eceddc3ffa49c3303d17c2fc92 Mon Sep 17 00:00:00 2001 From: Behruz Nassre Esfahani Date: Fri, 26 Jun 2026 12:19:57 -0700 Subject: [PATCH] fix(#1693): don't double-quote $CLAUDE_PROJECT_DIR-anchored hook paths on Windows (#1746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#1693): don't double-quote $CLAUDE_PROJECT_DIR-anchored hook paths on Windows The installer's #2979 legacy-node rewrite ran every managed node hook path through JSON.stringify on Windows. For local installs the path already carries a "$CLAUDE_PROJECT_DIR"-anchored quoted prefix, so stringifying produced "\"$CLAUDE_PROJECT_DIR\"/...". Node then received an argument starting with a literal " , treated it as relative, and failed MODULE_NOT_FOUND — breaking every node managed hook at once (a self-locking PreToolUse-guard deadlock). projectLegacySettingsHookCommand now emits an already-anchored token verbatim and only JSON.stringify-quotes bare absolute paths (which may contain spaces). Scoped to win32 so non-Windows token-shape behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#1693): add changeset Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/lucky-quails-greet.md | 5 ++ src/shell-command-projection.cts | 23 ++++- ...bug-3413-shell-command-projection.test.cjs | 89 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 .changeset/lucky-quails-greet.md diff --git a/.changeset/lucky-quails-greet.md b/.changeset/lucky-quails-greet.md new file mode 100644 index 000000000..5ef8dc1ea --- /dev/null +++ b/.changeset/lucky-quails-greet.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1746 +--- +Windows: stop double-quoting $CLAUDE_PROJECT_DIR-anchored managed node hook paths during the #2979 legacy rewrite, which produced "\"$CLAUDE_PROJECT_DIR\"/..." and broke every node managed hook with MODULE_NOT_FOUND (PreToolUse-guard deadlock). diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index 3397bb149..761bd6167 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -253,6 +253,15 @@ export function isManagedHookCommand(commandText: unknown, opts: { surface?: str return false; } +/** + * Detect a `"$VAR"/rest` anchored hook-script token — a path whose leading + * shell variable is already double-quoted with the remainder left bare (the + * shape `projectLocalHookPrefix` emits for local installs, e.g. + * `"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-x.js`). Such a token is ALREADY a + * valid, correctly-quoted shell argument and must never be re-quoted. + */ +const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//; + /** * Projection helper for legacy settings.json hook rewrites. * @@ -275,8 +284,20 @@ export function projectLegacySettingsHookCommand({ }): string | null { if (!absoluteRunner || !scriptPath) return null; const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath; + // #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored + // quoted prefix (local installs) is already a valid shell token — only the + // variable is quoted, the rest is bare. JSON.stringify-ing it on Windows + // yields `"\"$CLAUDE_PROJECT_DIR\"/..."` (escaped quotes inside an outer + // quote); node then receives an argument that *starts* with a `"`, treats it + // as relative, and dies with MODULE_NOT_FOUND. Emit anchored tokens verbatim; + // only bare absolute paths (which may contain spaces, e.g. "Program Files") + // need the JSON.stringify quoting. Scoped to win32: the non-Windows branch + // already preserves the caller's `scriptToken` (which is the bare anchored + // token for these inputs), so it never had the double-quote bug. const commandScriptToken = platform === 'win32' - ? JSON.stringify(normalizedScriptPath) + ? (ANCHORED_HOOK_SCRIPT_TOKEN.test(normalizedScriptPath) + ? normalizedScriptPath + : JSON.stringify(normalizedScriptPath)) : (scriptToken || JSON.stringify(normalizedScriptPath)); return projectShellCommandText({ runnerToken: absoluteRunner, diff --git a/tests/bug-3413-shell-command-projection.test.cjs b/tests/bug-3413-shell-command-projection.test.cjs index bbc6a2541..8a793fb01 100644 --- a/tests/bug-3413-shell-command-projection.test.cjs +++ b/tests/bug-3413-shell-command-projection.test.cjs @@ -187,3 +187,92 @@ describe('bug #3439: shell projection module owns managed-hook policy and legacy ); }); }); + +describe('#1693 regression: Windows legacy-node rewrite must not double-quote a "$CLAUDE_PROJECT_DIR"-anchored local hook path', () => { + const winRunner = '"C:/Program Files/nodejs/node.exe"'; + + // WHY: a local-install hook path already carries a `"$CLAUDE_PROJECT_DIR"` + // anchored prefix (only the variable quoted, rest bare). On Windows the legacy + // rewrite previously JSON.stringify'd the whole token, yielding + // `"\"$CLAUDE_PROJECT_DIR\"/..."`. node then received an argument starting with + // a literal `"`, treated it as relative, and died with MODULE_NOT_FOUND — + // breaking every node managed hook at once (self-locking deadlock). + test('projectLegacySettingsHookCommand emits the anchored path verbatim, not re-quoted', () => { + const anchored = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js'; + const command = projectLegacySettingsHookCommand({ + absoluteRunner: winRunner, + scriptPath: anchored, + scriptToken: anchored, + platform: 'win32', + runtime: 'claude', + }); + assert.equal( + command, + '"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', + ); + assert.ok(!command.includes('\\"'), 'must not contain escaped double-quotes'); + }); + + // WHY: the fix must be surgical — a BARE absolute Windows path (no anchored + // prefix) can contain spaces ("Program Files") and still REQUIRES quoting. + test('projectLegacySettingsHookCommand still quotes a bare absolute Windows path', () => { + const abs = 'C:/Program Files App/.claude/hooks/gsd-context-monitor.js'; + const command = projectLegacySettingsHookCommand({ + absoluteRunner: winRunner, + scriptPath: abs, + scriptToken: JSON.stringify(abs), + platform: 'win32', + runtime: 'claude', + }); + assert.equal( + command, + '"C:/Program Files/nodejs/node.exe" "C:/Program Files App/.claude/hooks/gsd-context-monitor.js"', + ); + }); + + // WHY: the anchored short-circuit is scoped to win32. On POSIX the rewrite + // already preserved the caller's original `scriptToken` and never had the + // double-quote bug, so that behavior must be left byte-identical. scriptPath + // is anchored but scriptToken is a DISTINCT single-quoted value: if the + // win32 gate were removed, the anchored short-circuit would emit scriptPath + // and this assertion would fail — that is what pins the gate. + test('projectLegacySettingsHookCommand preserves the original scriptToken for anchored paths on POSIX', () => { + const command = projectLegacySettingsHookCommand({ + absoluteRunner: '"/usr/local/bin/node"', + scriptPath: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-statusline.js', + scriptToken: "'/x/hooks/gsd-statusline.js'", + platform: 'linux', + runtime: 'claude', + }); + assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`); + }); + + // WHY: end-to-end through the installer rewrite — the actual #2979 path that + // ran during the user's 1.5.0 -> 1.6.0 local update. Managed node hooks get + // the absolute runner + clean anchored path; a non-node-prefixed managed .sh + // hook (already correct) is left untouched. + test('rewriteLegacyManagedNodeHookCommands produces clean anchored node commands on Windows', () => { + const settings = { + hooks: { + PostToolUse: [ + { + hooks: [ + { command: 'node "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js' }, + ], + }, + ], + }, + }; + const changed = rewriteLegacyManagedNodeHookCommands(settings, winRunner, { + platform: 'win32', + runtime: 'claude', + }); + assert.equal(changed, true); + const rewritten = settings.hooks.PostToolUse[0].hooks[0].command; + assert.equal( + rewritten, + '"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', + ); + assert.ok(!rewritten.includes('\\"'), 'rewritten command must not contain escaped double-quotes'); + }); +});