fix(#3981): give blocking guards a host-stall-proof timeout budget (#4175)

* test(#3981): blocking-guard timeout budget and 5→120 migration

Fresh registration must carry a host-stall-proof 120 s budget on the
six blocking PreToolUse guards; existing managed timeout:5 entries
are migrated; non-managed entries and advisory budgets are untouched.

* fix(#3981): give blocking guards a host-stall-proof timeout budget

Claude Code treats a timed-out hook as non-blocking, so the 5 s
budget on the six blocking PreToolUse guards silently dropped the
gates exactly when the host stalled under load. Registers them at
120 s (covers every observed stall, max 84.3 s) and migrates existing
managed timeout:5 entries in place, context-monitor-backfill shape.
Advisory hook budgets are unchanged.

* chore(#3981): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-02 01:26:37 -04:00
committed by GitHub
parent 7960374d15
commit 9eef1b791b
3 changed files with 178 additions and 6 deletions

View File

@@ -664,6 +664,7 @@ function resolveBashRunner(opts?: BashRunnerOpts): string | null {
interface HookEntry {
command?: string;
args?: unknown[];
timeout?: number;
}
interface HookGroup {
@@ -2048,6 +2049,37 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
settings.hooks.SessionStart = [];
}
// #3981: Claude Code treats a timed-out hook as NON-blocking — the tool
// call continues through the normal permission flow. The blocking
// PreToolUse guards therefore need a budget a host stall cannot exceed,
// not one sized to the hook's own ~0.1 s runtime. Observed stalls reached
// 84.3 s; 120 s is the top of the issue's prescribed 60–120 range and
// returns every observed verdict. Registration below uses this constant,
// and the migration pass right here raises existing managed entries.
const BLOCKING_GUARD_TIMEOUT_S = 120;
const blockingGuardNames = [
'gsd-prompt-guard',
'gsd-workflow-guard',
'gsd-worktree-path-guard',
'gsd-agent-isolation-guard',
'gsd-write-guard',
'gsd-validate-commit',
];
for (const entries of Object.values(settings.hooks as Record<string, HookGroup[]>)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!entry || !Array.isArray(entry.hooks)) continue;
for (const h of entry.hooks) {
if (
blockingGuardNames.some((name) => referencesHook(h as Record<string, unknown>, name)) &&
h.timeout === 5
) {
h.timeout = BLOCKING_GUARD_TIMEOUT_S;
}
}
}
}
const hasGsdUpdateHook = settings.hooks.SessionStart.some((entry: HookGroup) =>
entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record<string, unknown>, 'gsd-check-update'))
);
@@ -2138,7 +2170,7 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
{
type: 'command',
command: promptGuardCommand,
timeout: 5
timeout: BLOCKING_GUARD_TIMEOUT_S
}
]
});
@@ -2224,7 +2256,7 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
{
type: 'command',
command: workflowGuardCommand,
timeout: 5
timeout: BLOCKING_GUARD_TIMEOUT_S
}
]
});
@@ -2251,7 +2283,7 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
{
type: 'command',
command: worktreePathGuardCommand,
timeout: 5
timeout: BLOCKING_GUARD_TIMEOUT_S
}
]
});
@@ -2283,7 +2315,7 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
{
type: 'command',
command: agentIsolationGuardCommand,
timeout: 5
timeout: BLOCKING_GUARD_TIMEOUT_S
}
]
});
@@ -2312,7 +2344,7 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
{
type: 'command',
command: writeGuardCommand,
timeout: 5
timeout: BLOCKING_GUARD_TIMEOUT_S
}
]
});
@@ -2339,7 +2371,7 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
{
type: 'command',
command: validateCommitCommand,
timeout: 5
timeout: BLOCKING_GUARD_TIMEOUT_S
}
]
});