fix(#3662): resolve managed hook node runners at hook-fire time (#3790)

* test(#3662): failing-first suite for runtime-resolving hook runners

* fix(#3662): resolve managed hook node runners at hook-fire time

* fix(#3662): close review findings and document the resolver

* fix(#3662): close adversarial and security review findings

* chore(#3662): backfill changeset pr number

* test(#3662): honor win32 skip return and platform-aware sh runner pin

* test(#3662): pin the bare win32-claude sh-hook shape omitting the bash runner

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-24 00:07:06 -04:00
committed by GitHub
parent cf15682d1c
commit 4af59f8dd3
25 changed files with 896 additions and 46 deletions

View File

@@ -205,6 +205,13 @@ const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
'gsd-read-injection-scanner.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
// #3662: the three PreToolUse guards are GSD-managed JS hooks (registered
// only-if-absent by applySettingsJsonHooks) but sat outside this set, so
// the #2979 runner rewriter never reached them — the issue's reported
// mixed state lived on exactly these hooks.
'gsd-write-guard.js',
'gsd-agent-isolation-guard.js',
'gsd-worktree-path-guard.js',
]),
'codex-toml': new Set([
'gsd-check-update.js',
@@ -225,6 +232,14 @@ const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
// #3662: same three guards as MANAGED_HOOK_BASENAMES_BY_SURFACE above —
// their absence here meant isManagedHookCommand never recognized them, so
// the settings.json→settings.local.json migration filter (and uninstall
// cleanup) skipped GSD's own guard entries. Folded-in fix, surfaced by the
// #3662 issue thread.
'gsd-write-guard.js',
'gsd-agent-isolation-guard.js',
'gsd-worktree-path-guard.js',
]),
'codex-toml': new Set([
'gsd-check-update.js',
@@ -320,21 +335,27 @@ const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
* Non-Windows keeps the original script token shape when provided (single
* quote / bareword / quoted), while Windows normalizes to double-quoted
* forward-slash path tokens for stable cross-shell behavior.
*
* #3662: `runnerToken` is the runner the current install would emit — since
* #3662 that is the runtime-resolving chain token (see
* buildNodeRunnerChainToken), not a bare absolute path, so re-projected
* entries converge onto a runner that resolves in every environment sharing
* the config root.
*/
export function projectLegacySettingsHookCommand({
absoluteRunner,
runnerToken,
scriptPath,
scriptToken,
runtime = 'generic',
platform = process.platform,
}: {
absoluteRunner?: string | null;
runnerToken?: string | null;
scriptPath?: string | null;
scriptToken?: string | null;
runtime?: string;
platform?: string;
}): string | null {
if (!absoluteRunner || !scriptPath) return null;
if (!runnerToken || !scriptPath) return null;
const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
// #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
// quoted prefix (local installs) is already a valid shell token — only the
@@ -352,7 +373,7 @@ export function projectLegacySettingsHookCommand({
: JSON.stringify(normalizedScriptPath))
: (scriptToken || JSON.stringify(normalizedScriptPath));
return projectShellCommandText({
runnerToken: absoluteRunner,
runnerToken,
argTokens: [commandScriptToken],
runtime,
platform,