/** * Hook-block-ineffective detector — the #1 verified hook pain ("false confidence"). * * A safety hook that LOOKS like it blocks but SILENTLY DOESN'T. Two shapes: * * 1. **wrong-event** — the script tries to block (contains `exit 2`, a legacy * `"decision":"block"` JSON, or a `"permissionDecision":"deny"`) but is * registered on an event where a block is SILENTLY IGNORED ENTIRELY — * SessionStart / SessionEnd / Notification / PreCompact, where exit 2 writes * stderr only to the user (no veto, no model feedback). The author believes a * gate is in place; nothing happens. (#19009 names the class.) NB `PostToolUse` * is deliberately NOT flagged: there exit 2 feeds stderr back to the model — a * legitimate FEEDBACK channel (a nudge/lint hook), not a failed block, and the * block-vs-feedback intent isn't deterministically separable. * * 2. **wrong-field** — the hook IS on a permission-gated event (e.g. PreToolUse) * but emits the LEGACY top-level `"decision":"block"` field instead of the * required `hookSpecificOutput.permissionDecision:"deny"`. A copied template * (PostToolUse style → PreToolUse registration) is the usual cause; the deny * is silently discarded, nothing is blocked. * * Both shapes cause identical user-visible behaviour: the hook "works" (exits, * no crash) but never actually stops anything. The only signal is "why did this * run anyway?" after an incident. * * FP-SAFETY — conservative literal patterns only, `warn` severity by default: * - `exit 2` is matched by a shell-context regex that requires surrounding * whitespace/control chars to avoid false-positives on `exit 200` or a * `git status --exit-code 2` argument. * - JSON decision patterns are matched literally (no partial JSON walking). * - Nothing is flagged on an unknown event (we only know which events CAN * block because the caller injected that set). * * HARNESS-NEUTRAL — the sets of blocking events and permission-decision events * are INJECTED from the dialect (never hard-coded here). The caller supplies the * Claude Code sets; a Codex adapter supplies its own. This is the same * dependency-injection pattern used by `verifyHookEvents` and `verifyToolContract` * (core ⊄ adapter — one-detector-no-drift). * * ONE detector reused by scan + the `hook-block-ineffective` lint rule (two * callers, no drift). See `research/hook-pain-points.md` for the verified corpus * and `docs/compiled-hooks.md` for the authoritative fix (compiled hooks make * this whole class unrepresentable). * * Node-free: the `readFileSync` IO is REQUIRED (injected by the caller — the disk * scan passes `node:fs`, the browser engine a map-backed read), so this module * never statically imports `node:fs` and bundles clean in a browser. */ /** The two "looks like it blocks but doesn't" failure shapes. */ export type HookBlockKind = "wrong-event" | "wrong-field"; /** One false-confidence finding: a hook that appears to block but silently won't. */ export interface HookBlockFinding { readonly event: string; readonly kind: HookBlockKind; /** The hook script that was inspected (path if resolved from a script file, else null = inline command). */ readonly scriptPath: string | null; readonly message: string; } /** A single hook registration to inspect. */ export interface HookScriptEntry { readonly event: string; readonly matcher?: string; /** The hook command line as registered. */ readonly command: string; /** Resolved path to the script file the command runs, if known (else null → inspect `command`). */ readonly scriptPath?: string | null; } /** Options for {@link hookBlockIssues}. All fields are injectable for testability. */ export interface HookBlockOptions { /** * Events on this harness where a block decision (`exit 2` / a deny field) is * SILENTLY IGNORED ENTIRELY — no veto AND no feedback to the model (Claude * Code's `SessionStart`/`SessionEnd`/`Notification`/`PreCompact`: exit 2 there * only writes stderr to the user). These are the ONLY events the "wrong-event" * check fires on, so it stays FP-safe. * * Deliberately EXCLUDES `PostToolUse`: a `PostToolUse` exit 2 feeds stderr back * to the model — a legitimate FEEDBACK channel, NOT a failed block — so flagging * it would cry wolf on every nudge/lint hook (the dogfood lesson: vigiles's own * `refs-nudge.sh` is exactly that shape). A hook that exits 2 on `PostToolUse` * intending to BLOCK is misguided, but that intent is not deterministically * distinguishable from feedback, so we don't flag it. */ readonly noEffectEvents: ReadonlySet; /** * Events that require the structured `permissionDecision` field for a deny * (e.g. `PreToolUse`). On these events the legacy top-level `"decision":"block"` * field is ignored; only `hookSpecificOutput.permissionDecision:"deny"` works. */ readonly permissionDecisionEvents: ReadonlySet; /** * REQUIRED file read (the disk caller injects `node:fs` * `readFileSync(p, "utf8")`; the browser engine a map-backed read). Should * return `""` on any error so a missing / unreadable script doesn't crash the * detector — it simply produces no findings for that entry. */ readonly readFileSync: (p: string) => string; } export declare function hookBlockIssues(entries: readonly HookScriptEntry[], opts: HookBlockOptions): HookBlockFinding[]; //# sourceMappingURL=hook-block-ineffective.d.ts.map