From bd36aa0b46a346af15e18e210e73cc5d56244772 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 6 Nov 2026 05:22:18 -0800 Subject: [PATCH] fix: skill gate hooks outlive the gate deadline, so a slow gate no longer fails open (#4072) * skill gates: the hook timeout outlives the clamped gate deadline (GST-1615) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: #4045/#4047 narrow screens select the bin/gstack-config gate case once setup runs gstack-skill-gates (GST-1615) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-skill-gates | 12 ++++--- changelog.d/gst-1615-gate-hook-timeout.md | 1 + docs/skill-gates.md | 11 +++++- lib/skill-gates.ts | 19 ++++++++++- setup | 9 +++-- test/pr-shared-input-selection.test.ts | 8 +++-- test/setup-skill-hooks-gate.test.ts | 26 +++++++++++--- test/skill-gates.test.ts | 41 ++++++++++++++++++++++- 8 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 changelog.d/gst-1615-gate-hook-timeout.md diff --git a/bin/gstack-skill-gates b/bin/gstack-skill-gates index 602e5db6a..189267e07 100755 --- a/bin/gstack-skill-gates +++ b/bin/gstack-skill-gates @@ -6,6 +6,8 @@ * Print a Claude Code settings JSON that registers the named gates (the * /investigate gate, the /plan-ceo-review gate) and turns their switches on, for \`claude -p --settings \` or the Agent * SDK's settings, so a headless run needs no settings.json edit. + * gstack-skill-gates hook-timeout + * Print the hook timeout in seconds (HOOK_TIMEOUT_S) that ./setup registers. * gstack-skill-gates report [--session ] [--expect armed] [--json] * Count fired, blocked, released, exhausted, observed and error markers per session * from $GSTACK_STATE_ROOT/skill-gates//record.json. @@ -16,12 +18,12 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import { gatesRoot, readGateRecord, recordPath, type GateHook, type GateRecord } from '../lib/skill-gates'; +import { HOOK_TIMEOUT_S, gatesRoot, readGateRecord, recordPath, type GateHook, type GateRecord } from '../lib/skill-gates'; const ROOT = path.resolve(import.meta.dir, '..'); /** Claude Code runs a hook command through a shell: quote the path as gstack-settings-hook's gsQuoteCmd does. */ const shellCommand = (p: string) => (/[\s$`"\\]/.test(p) ? `"${p.replace(/[\\"$`]/g, ch => `\\${ch}`)}"` : p); -const USAGE = 'Usage: gstack-skill-gates settings [--investigate] [--ceo-review]\n gstack-skill-gates report [--session ] [--expect armed] [--json]'; +const USAGE = 'Usage: gstack-skill-gates settings [--investigate] [--ceo-review]\n gstack-skill-gates hook-timeout\n gstack-skill-gates report [--session ] [--expect armed] [--json]'; const HOOKS: Record }> = { '--investigate': { env: 'GSTACK_INVESTIGATE_GATE', @@ -59,14 +61,16 @@ if (args[0] === 'settings') { const h = HOOKS[flag]; settings.env[h.env] = 'on'; for (const e of h.events) { - (settings.hooks[e.event] ??= []).push({ ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [{ type: 'command', command: shellCommand(path.join(ROOT, 'hosts', 'claude', 'hooks', e.shim)), timeout: 10 }] }); + (settings.hooks[e.event] ??= []).push({ ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [{ type: 'command', command: shellCommand(path.join(ROOT, 'hosts', 'claude', 'hooks', e.shim)), timeout: HOOK_TIMEOUT_S }] }); } } process.stdout.write(`${JSON.stringify(settings, null, 2)}\n`); process.exit(0); } -if (args[0] !== 'report') usage('expected settings or report'); +if (args[0] === 'hook-timeout') { process.stdout.write(`${HOOK_TIMEOUT_S}\n`); process.exit(0); } + +if (args[0] !== 'report') usage('expected settings, hook-timeout or report'); const flag = (name: string) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] ?? usage(`${name} needs a value`)) : undefined; }; const session = flag('--session'); const expect = flag('--expect'); diff --git a/changelog.d/gst-1615-gate-hook-timeout.md b/changelog.d/gst-1615-gate-hook-timeout.md new file mode 100644 index 000000000..9dca1833f --- /dev/null +++ b/changelog.d/gst-1615-gate-hook-timeout.md @@ -0,0 +1 @@ +- **The /investigate and /plan-ceo-review gates no longer pass a slow scan unchecked.** Their hooks were registered with a 10 second timeout while `GSTACK_SKILL_GATE_DEADLINE_MS` could set a longer gate deadline, and Claude Code kills a hook at its timeout and lets the stop through with no marker. `gstack-skill-gates settings` and `./setup` now register a timeout derived from the longest deadline plus a margin (25 seconds), and a deadline above 20 seconds is clamped to 20. A killed hook still cannot fail closed; docs/skill-gates.md#hook-timeout says why. Re-run `./setup` to update an existing registration. (GST-1615) diff --git a/docs/skill-gates.md b/docs/skill-gates.md index f3eabda3c..c27af1ee9 100644 --- a/docs/skill-gates.md +++ b/docs/skill-gates.md @@ -107,7 +107,7 @@ A gate never blocks because it broke. On any error it writes an `error` marker w |---|---|---| | `GATE_BAD_PAYLOAD` | the hook input is not a JSON object with `session_id` and `transcript_path`, or is over 1 MiB | a Claude Code version change; update gstack | | `GATE_TRANSCRIPT` | the transcript is missing, over 32 MiB, or not a shape the parser knows, or the /plan-ceo-review load record has no timestamp | see [skill-routing-and-contracts.md](skill-routing-and-contracts.md#exit-4-unknown-shape) | -| `GATE_TIMEOUT` | the hook ran past its 5 second deadline (`GSTACK_SKILL_GATE_DEADLINE_MS` changes it) | a very large dirty tree or a slow disk | +| `GATE_TIMEOUT` | the hook ran past its 5 second deadline (`GSTACK_SKILL_GATE_DEADLINE_MS` changes it, up to 20 seconds; a larger value is clamped to 20) | a very large dirty tree or a slow disk | | `GATE_GIT` | a git command failed or HEAD stopped resolving | run the named git command in the repository | | `GATE_STATE` | the session record or baseline could not be read or written | make `$GSTACK_STATE_ROOT` writable, or delete `skill-gates//` | | `GATE_LOCKED` | another hook held the session lock for over 3 seconds | transient; a lock older than 30 seconds is taken over | @@ -115,6 +115,15 @@ A gate never blocks because it broke. On any error it writes an `error` marker w | `GATE_RESTORE_FAILED` | a changed file could not be put back | the block lists restored and failed files; run `git diff HEAD -- ` and fix them by hand | | `GATE_UNEXPECTED` | any other throw | report it with the trace log line | + +### Hook timeout: a killed hook cannot fail closed + +Claude Code kills a hook that runs past the `timeout` in its registration and then lets the stop or tool call through as if the hook had allowed it. It writes nothing to the stream output about the kill, and the hook settings have no option that makes a timed-out hook block. A gate killed this way leaves no `error` marker either, because its own deadline never fired, so `gstack-skill-gates report` cannot see it. There is no way for the gate to fail closed after a kill. + +So every gate registration outlives the gate. `HOOK_TIMEOUT_S` in `lib/skill-gates.ts` is the longest deadline (20 seconds) plus a 5 second margin for Bun startup and the `GATE_TIMEOUT` marker write, rounded up: 25 seconds. `gstack-skill-gates settings` writes it on every hook, and `./setup` reads it from `gstack-skill-gates hook-timeout`; re-running setup updates an older 10 second registration. A slow scan then ends in the gate's own `GATE_TIMEOUT`, which is recorded and makes `report` exit 3. Keep your own settings files at or above this number. + +Tested with Claude Code 2.1.318 through `claude -p --settings` only: a Stop hook killed at a 3 second timeout let the stop through, and the same hook with a 15 second timeout blocked it (GST-1615). + ## Exit codes `gstack-skill-gates report [--session ] [--expect armed] [--json]` lists each session's gate counts: runs, `fired`, `blocked`, `released`, `exhausted`, `observed` and `errors`. diff --git a/lib/skill-gates.ts b/lib/skill-gates.ts index 841405327..c84795d17 100644 --- a/lib/skill-gates.ts +++ b/lib/skill-gates.ts @@ -22,9 +22,26 @@ export type GateHook = 'investigate-gate' | 'ceo-review-gate'; export type GateEvent = 'fired' | 'blocked' | 'released' | 'observed' | 'exhausted' | 'skipped' | 'error'; export const GATE_DOC = 'docs/skill-gates.md'; export const DEADLINE_MS = 5000; +/** The most GSTACK_SKILL_GATE_DEADLINE_MS can set; a larger value is clamped to it. */ +export const DEADLINE_MAX_MS = 20_000; const STDIN_CAP_BYTES = 1024 * 1024; const STDIN_DEADLINE_MS = 500; const LOCK_WAIT_MS = 3000; +/** What a hook needs beyond its deadline: Bun startup before the timer starts, and the GATE_TIMEOUT marker write, which can wait out the session lock. */ +export const HOOK_MARGIN_MS = LOCK_WAIT_MS + 2000; +/** + * The timeout, in seconds, on every gate hook registration (gstack-skill-gates + * settings, and ./setup through `gstack-skill-gates hook-timeout`). Claude Code + * kills a hook at its timeout and lets the stop or call through with no marker, + * so the registration must outlive the longest deadline plus the margin. + */ +export const HOOK_TIMEOUT_S = Math.ceil((DEADLINE_MAX_MS + HOOK_MARGIN_MS) / 1000); + +/** The hook's deadline: GSTACK_SKILL_GATE_DEADLINE_MS when positive, clamped to DEADLINE_MAX_MS, else DEADLINE_MS. */ +export function deadlineFromEnv(env: NodeJS.ProcessEnv = process.env): number { + const ms = Number(env.GSTACK_SKILL_GATE_DEADLINE_MS); + return ms > 0 ? Math.min(ms, DEADLINE_MAX_MS) : DEADLINE_MS; +} const LOCK_STALE_MS = 30_000; const SESSION_DIRS_KEPT_MS = 7 * 24 * 60 * 60 * 1000; @@ -184,7 +201,7 @@ export async function runGateHook(hook: GateHook, isOn: () => boolean, handler: process.stderr.write(`${marker} (stop and edits allowed; see ${GATE_DOC}#${code.toLowerCase().replace(/_/g, '-')})\n`); process.stdout.write(JSON.stringify({ systemMessage: marker })); }; - deadlineMs = Number(process.env.GSTACK_SKILL_GATE_DEADLINE_MS) || DEADLINE_MS; + deadlineMs = deadlineFromEnv(); deadlineAt = Date.now() + deadlineMs; const timer = setTimeout(() => { fail('GATE_TIMEOUT', `timed out after ${deadlineMs} ms`); process.exit(0); }, deadlineMs); try { diff --git a/setup b/setup index a65b1c461..1ccf04e36 100755 --- a/setup +++ b/setup @@ -4971,6 +4971,11 @@ _skill_hook_gate skill_routing "$SKILL_ROUTING_MODE" GSTACK_SKILL_ROUTING UserPr _skill_hook_gate skill_contracts "$SKILL_CONTRACTS_MODE" GSTACK_SKILL_CONTRACTS Stop \ "$SKILL_CONTRACT_HOOK" gstack-skill-contract 10 \ "a contract skill that ends without its required outputs is blocked once" +# Both skill gates register HOOK_TIMEOUT_S from lib/skill-gates.ts, the longest +# gate deadline plus a margin (GST-1615): Claude Code kills a hook at its +# timeout and lets the stop through unchecked. Empty if bun cannot run, which +# omits the timeout and leaves Claude Code's default. +SKILL_GATE_HOOK_TIMEOUT="$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-skill-gates" hook-timeout 2>/dev/null || true)" # The /investigate gate (GST-1461, docs/skill-gates.md) registers one shim per # event; each basename has its own KNOWN_HOOKS row under one source tag. INVESTIGATE_GATE_PRE="$(_hook_command_path hosts/claude/hooks/investigate-gate-pre || true)" @@ -4979,7 +4984,7 @@ INVESTIGATE_GATE_POST_FAILURE="$(_hook_command_path hosts/claude/hooks/investiga INVESTIGATE_GATE_STOP="$(_hook_command_path hosts/claude/hooks/investigate-gate-stop || true)" INVESTIGATE_GATE_EDIT_MATCHER="Edit|Write|MultiEdit|NotebookEdit|Bash" _skill_hook_gate investigate_gate "$INVESTIGATE_GATE_MODE" GSTACK_INVESTIGATE_GATE PreToolUse \ - "$INVESTIGATE_GATE_PRE" gstack-investigate-gate 10 \ + "$INVESTIGATE_GATE_PRE" gstack-investigate-gate "$SKILL_GATE_HOOK_TIMEOUT" \ "after /investigate loads, changes to tracked source wait for a stated root cause" \ "$INVESTIGATE_GATE_EDIT_MATCHER" \ PostToolUse "$INVESTIGATE_GATE_POST" Bash \ @@ -4990,7 +4995,7 @@ _skill_hook_gate investigate_gate "$INVESTIGATE_GATE_MODE" GSTACK_INVESTIGATE_GA CEO_REVIEW_GATE_STOP="$(_hook_command_path hosts/claude/hooks/ceo-review-gate-stop || true)" CEO_REVIEW_GATE_SUBAGENT_STOP="$(_hook_command_path hosts/claude/hooks/ceo-review-gate-subagent-stop || true)" _skill_hook_gate ceo_review_gate "$CEO_REVIEW_GATE_MODE" GSTACK_CEO_REVIEW_GATE Stop \ - "$CEO_REVIEW_GATE_STOP" gstack-ceo-review-gate 10 \ + "$CEO_REVIEW_GATE_STOP" gstack-ceo-review-gate "$SKILL_GATE_HOOK_TIMEOUT" \ "after /plan-ceo-review loads, its final stop waits for ranked top risks in the output file" "" \ SubagentStop "$CEO_REVIEW_GATE_SUBAGENT_STOP" "" diff --git a/test/pr-shared-input-selection.test.ts b/test/pr-shared-input-selection.test.ts index c01eff941..a94195369 100644 --- a/test/pr-shared-input-selection.test.ts +++ b/test/pr-shared-input-selection.test.ts @@ -87,9 +87,11 @@ test('cumulative shared, native and prompt edits retain every gate case and judg // GST-1575: no paid run executes ./setup (evals.yml only builds), so a narrow screen derives it like any // other file. Diffs of #4045 (9e809ab) and #4047 (4743132), whose narrow screens (runs 42093259982 and -// 42103721891) fell back to all gate cases on setup alone. +// 42103721891) fell back to all gate cases on setup alone. GST-1615: setup now runs bin/gstack-skill-gates, so +// that file derives setup's consumers and the screen runs in dependents mode, which also keeps the gate case +// whose touchfiles name bin/gstack-config (ship-skipped-queued-finding) instead of intersecting with the profile. const setupHookCases = ['gstack-upgrade-happy-path', 'ship-local-hook-preservation', 'ship-managed-hook-refresh', - 'ship-unmanaged-hook-consent', 'skillmd-setup-discovery']; + 'ship-skipped-queued-finding', 'ship-unmanaged-hook-consent', 'skillmd-setup-discovery']; const setupScreens: Record = { '#4045': ['bin/gstack-config', 'bin/gstack-settings-hook', 'bin/gstack-skill-gates', 'bin/gstack-skill-route', 'changelog.d/capy-gst-1461-investigate-gate.md', 'docs/skill-gates.md', 'docs/skill-routing-and-contracts.md', @@ -112,7 +114,7 @@ const setupScreens: Record = { const screen = (policy: 'narrow' | 'broad', changedFiles: string[]) => computePaidCaseSelection({ profile: 'pr', env: { EVALS_BASE: 'fixture-base', EVALS_SCREEN_POLICY: policy }, changedFiles, packageVersionOnly: false }); -test.each(Object.entries(setupScreens))('a narrow screen of %s derives setup and selects its five cases', (_pr, files) => { +test.each(Object.entries(setupScreens))('a narrow screen of %s derives setup and selects its six cases', (_pr, files) => { const result = screen('narrow', files); expect(result.coverage?.mode).not.toBe('full-fallback'); expect(result.selection.e2e).toEqual(setupHookCases); diff --git a/test/setup-skill-hooks-gate.test.ts b/test/setup-skill-hooks-gate.test.ts index 11d848bc1..a033018b4 100644 --- a/test/setup-skill-hooks-gate.test.ts +++ b/test/setup-skill-hooks-gate.test.ts @@ -13,6 +13,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { HOOK_TIMEOUT_S } from '../lib/skill-gates'; import { SWITCH_DEFAULT } from '../lib/skill-route'; import { J1_TRANSCRIPT, ROOT, hookEnv, runHook, skillsDir, tmpDir, @@ -21,6 +22,7 @@ import { const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8'); const settingsHookSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), 'utf8'); const gateFn = setupSrc.slice(setupSrc.indexOf('_skill_hook_gate() {'), setupSrc.indexOf('\n}\n', setupSrc.indexOf('_skill_hook_gate() {')) + 3); +const gateTimeoutLine = setupSrc.match(/^SKILL_GATE_HOOK_TIMEOUT=.*$/m)?.[0] ?? 'echo "setup has no SKILL_GATE_HOOK_TIMEOUT line" >&2; exit 9'; let state = '', home = ''; beforeEach(() => { state = tmpDir('gstack-gate-state-'); home = tmpDir('gstack-gate-home-'); }); @@ -32,9 +34,9 @@ const settings = () => (fs.existsSync(settingsFile()) ? JSON.parse(fs.readFileSy const GATES = { skill_routing: 'GSTACK_SKILL_ROUTING UserPromptSubmit "$2/hosts/claude/hooks/skill-route-hook" gstack-skill-route 5 "routing hint"', skill_contracts: 'GSTACK_SKILL_CONTRACTS Stop "$2/hosts/claude/hooks/skill-contract-stop-hook" gstack-skill-contract 10 "contract check"', - investigate_gate: 'GSTACK_INVESTIGATE_GATE PreToolUse "$2/hosts/claude/hooks/investigate-gate-pre" gstack-investigate-gate 10 "investigate gate" "Edit|Write|MultiEdit|NotebookEdit|Bash"' + investigate_gate: 'GSTACK_INVESTIGATE_GATE PreToolUse "$2/hosts/claude/hooks/investigate-gate-pre" gstack-investigate-gate "$SKILL_GATE_HOOK_TIMEOUT" "investigate gate" "Edit|Write|MultiEdit|NotebookEdit|Bash"' + ' PostToolUse "$2/hosts/claude/hooks/investigate-gate-post" Bash PostToolUseFailure "$2/hosts/claude/hooks/investigate-gate-post-failure" Bash Stop "$2/hosts/claude/hooks/investigate-gate-stop" ""', - ceo_review_gate: 'GSTACK_CEO_REVIEW_GATE Stop "$2/hosts/claude/hooks/ceo-review-gate-stop" gstack-ceo-review-gate 10 "ceo review gate" ""' + ceo_review_gate: 'GSTACK_CEO_REVIEW_GATE Stop "$2/hosts/claude/hooks/ceo-review-gate-stop" gstack-ceo-review-gate "$SKILL_GATE_HOOK_TIMEOUT" "ceo review gate" ""' + ' SubagentStop "$2/hosts/claude/hooks/ceo-review-gate-subagent-stop" ""', }; @@ -47,6 +49,8 @@ function gate(mode: string, env: Record = {}, noTeam = 0, root = `SETTINGS_HOOK="${path.join(ROOT, 'bin', 'gstack-settings-hook')}"`, 'SKILL_ROUTE_BIN="$2/bin/gstack-skill-route"', `IS_WINDOWS=0; NO_TEAM_MODE=${noTeam}`, + 'SOURCE_GSTACK_DIR="$2"', + gateTimeoutLine, gateFn, `_skill_hook_gate ${key} "$1" ${GATES[key]}`, ].join('\n'); @@ -273,7 +277,7 @@ describe('setup: the investigate_gate switch (GST-1461) registers one shim per e expect(entries).toHaveLength(1); expect(entries[0].matcher ?? undefined).toBe(matcher); expect(entries[0].hooks[0].command).toEndWith(`hosts/claude/hooks/${shim}`); - expect(entries[0].hooks[0].timeout).toBe(10); + expect(entries[0].hooks[0].timeout).toBe(HOOK_TIMEOUT_S); } expect(gate('', {}, 0, ROOT, 'investigate_gate')).toEqual({ code: 0, out: '' }); gate('off', {}, 0, ROOT, 'investigate_gate'); @@ -318,7 +322,7 @@ describe('setup: the ceo_review_gate switch (GST-1461) registers Stop and Subage expect(entries).toHaveLength(1); expect(entries[0].matcher ?? undefined).toBeUndefined(); expect(entries[0].hooks[0].command).toEndWith(`hosts/claude/hooks/${shim}`); - expect(entries[0].hooks[0].timeout).toBe(10); + expect(entries[0].hooks[0].timeout).toBe(HOOK_TIMEOUT_S); } expect(gate('', {}, 0, ROOT, 'ceo_review_gate')).toEqual({ code: 0, out: '' }); gate('off', {}, 0, ROOT, 'ceo_review_gate'); @@ -330,3 +334,17 @@ describe('setup: the ceo_review_gate switch (GST-1461) registers Stop and Subage expect(r.stdout).toMatch(/^ {2}ceo_review_gate: +off$/m); }); }); + +describe('setup: both skill gates register the hook timeout lib/skill-gates.ts derives (GST-1615)', () => { + test('source: one SKILL_GATE_HOOK_TIMEOUT from gstack-skill-gates hook-timeout, passed to both gates', () => { + expect(gateTimeoutLine).toBe('SKILL_GATE_HOOK_TIMEOUT="$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-skill-gates" hook-timeout 2>/dev/null || true)"'); + expect(setupSrc).toContain('"$INVESTIGATE_GATE_PRE" gstack-investigate-gate "$SKILL_GATE_HOOK_TIMEOUT" \\'); + expect(setupSrc).toContain('"$CEO_REVIEW_GATE_STOP" gstack-ceo-review-gate "$SKILL_GATE_HOOK_TIMEOUT" \\'); + expect(setupSrc).not.toMatch(/gstack-(investigate|ceo-review)-gate \d/); + }); + + test('the setup line evaluates to HOOK_TIMEOUT_S', () => { + const r = spawnSync('bash', ['-c', `SOURCE_GSTACK_DIR="$1"\n${gateTimeoutLine}\nprintf %s "$SKILL_GATE_HOOK_TIMEOUT"`, 'gate', ROOT], { env: { PATH: process.env.PATH ?? '', HOME: home }, encoding: 'utf8', timeout: 30_000 }); + expect(r.stdout).toBe(String(HOOK_TIMEOUT_S)); + }); +}); diff --git a/test/skill-gates.test.ts b/test/skill-gates.test.ts index e2197d160..b8f921ea6 100644 --- a/test/skill-gates.test.ts +++ b/test/skill-gates.test.ts @@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { bunIsolationArgs } from '../lib/bun-isolation'; -import { gateMarker } from '../lib/skill-gates'; +import { DEADLINE_MAX_MS, DEADLINE_MS, HOOK_MARGIN_MS, HOOK_TIMEOUT_S, deadlineFromEnv, gateMarker } from '../lib/skill-gates'; import { ROOT, tmpDir } from './helpers/skill-contract-fixture'; const BIN = path.join(ROOT, 'bin', 'gstack-skill-gates'); @@ -89,6 +89,45 @@ describe('settings', () => { }); }); +describe('hook timeout covers the gate deadline (GST-1615)', () => { + test('every hook settings registers for both gates outlives the deadline ceiling plus the margin', () => { + const r = cli('settings', '--investigate', '--ceo-review'); + expect(r.status).toBe(0); + const hooks = JSON.parse(r.stdout).hooks as Record }>>; + const timeouts = Object.values(hooks).flatMap(entries => entries.flatMap(e => e.hooks.map(h => h.timeout))); + expect(timeouts).toHaveLength(6); + for (const t of timeouts) { + expect(t * 1000).toBeGreaterThanOrEqual(DEADLINE_MAX_MS + HOOK_MARGIN_MS); + expect(t).toBe(HOOK_TIMEOUT_S); + } + }); + + test('hook-timeout prints HOOK_TIMEOUT_S, the number ./setup registers', () => { + const r = cli('hook-timeout'); + expect(r.status).toBe(0); + expect(r.stdout).toBe(`${HOOK_TIMEOUT_S}\n`); + }); + + test('a deadline env above the ceiling is clamped to it; zero, negative or junk falls back to the default', () => { + expect(deadlineFromEnv({ GSTACK_SKILL_GATE_DEADLINE_MS: String(DEADLINE_MAX_MS * 3) })).toBe(DEADLINE_MAX_MS); + expect(deadlineFromEnv({ GSTACK_SKILL_GATE_DEADLINE_MS: '1500' })).toBe(1500); + for (const v of [undefined, '', '0', '-1', 'abc']) expect(deadlineFromEnv({ GSTACK_SKILL_GATE_DEADLINE_MS: v })).toBe(DEADLINE_MS); + }); + + test('runGateHook runs a handler under the clamped deadline, not the raw env value', () => { + const script = `import { remainingMs, runGateHook } from ${JSON.stringify(path.join(ROOT, 'lib', 'skill-gates.ts'))}; +await runGateHook('ceo-review-gate', () => true, () => ({ left: remainingMs() }));`; + const r = spawnSync(process.execPath, [...bunIsolationArgs(), '-e', script], { + env: { PATH: process.env.PATH ?? '', HOME: state, GSTACK_STATE_ROOT: state, GSTACK_SKILL_GATE_DEADLINE_MS: '600000' }, + input: JSON.stringify({ session_id: 's1', transcript_path: '/t' }), encoding: 'utf8', timeout: 20_000, + }); + expect(r.status).toBe(0); + const { left } = JSON.parse(r.stdout); + expect(left).toBeLessThanOrEqual(DEADLINE_MAX_MS); + expect(left).toBeGreaterThan(DEADLINE_MAX_MS - 5000); + }); +}); + describe('report', () => { test('counts fired, blocked and released per session', () => { seed('s1', { blocked: 2, released_at: '2026-11-05T00:01:00Z' });