Files
zernie__vigiles/docs/rules/hook-block-ineffective.md
zernie 900dc7e06a refactor(spec)!: name things for what they are; retire the vigiles/experimental subpath (#169)
Three exported symbols were named for something other than what they are, and
two mechanisms were doing one job.

RENAMES (old names kept one major, @deprecated, removed one major AFTER this one):
  claude()          → instructionFile()     compiles CLAUDE.md AND AGENTS.md, so a
                                            name from one harness was never right
  instructions``    → prose``               builds a prose FRAGMENT; the plural read
                                            as "the instruction file", which is what
                                            instructionFile() builds
  agent()           → experimental_agent()  the shape is not settled, and the tag was
                                            never there (git log -S empty)
  SkillSpec.result: → postcondition:        `result` already meant the subagent output
                                            contract one screen down; the doc comment
                                            had to spend a line disambiguating them

The alias window is not politeness. Measured: the previous rename shipped with
ZERO overlap (18.1.1 old names only, 19.0.0 new only). A container rollback put
the old node_modules under new sources, PreToolUse stopped loading, and a
PreToolUse that does not load denies EVERY Bash command — including the one that
fixes it. One window per major makes that pair unrepresentable.

SUBPATH RETIRED. vigiles/experimental is gone; its members moved to the door
their feature already uses. Of two mechanisms for one promise the subpath was the
weaker: it marks the import line, out of view by the time anyone reads the call.
Measured on the only user-facing example — with the prefix aliased away at
import, the marker survived at 0 of 5 call sites. So the prefix stays, the advice
to alias it away became an explicit prohibition, and the six define* entry points
are DECLARED prefixed rather than gaining it at re-export.

That de-aliasing is what unblocked moving the naming check into ESLint, and the
ORDER was load-bearing: measured before it, dropping the public/internal
exemption produced 14 findings, all from the re-export indirection. Measured
after, across all of src/ with no exemption: 22 tagged declarations, 0
unprefixed. The rule turns on silent.

EXPORT CLEANUP. vigiles/linting export * → curated, 103 → 79 symbols; its header
claimed to be curated with an `export *` one line below, and claimed the builders
were also at the package root (191 exports there, zero matches). ClaudeTool and
HookEvent deleted — zero references, and both had drifted off the dialect they
claimed to mirror; HookEvent listed 5 events, two of which do not exist, against
the 31 the vendor documents.

DOCS. experimental.md deleted and distributed per feature: the skill half into
skills.md §Status, the hook half into a new compiled-hooks.md §Status, and the
emit half — 111 of its 177 lines, a topic doc wearing a stability page's clothes
— into docs/emit-channel.md, which gains the "which channel when" comparison it
never had. The one cross-cutting sentence went to the README.

TWO GATES WERE GREEN AND WRONG, both found by this work:
  - api:check reported "verified for 11 entries — no drift" while reading
    dist/experimental.d.ts from a PREVIOUS build (tsc does not clean dist/) for a
    source file that no longer existed. Now asserted against package.json
    exports, with a mutation proving exit 1 on a phantom entry.
  - check-export-prefixes caught MY error: the R3 services first landed on
    vigiles/eval, where `paid_` means "this call can bill you" — and
    startServices takes a ContainerRuntime and calls no model. Naming it
    paid_experimental_startServices would have redefined paid_ as "expensive in
    some sense". They went to the package root.

SEVENTEEN review findings landed on this branch, all real. Two mechanical
closures came out of them: src/example-imports.test.ts resolves every relative
import under examples/ instead of matching a string, and the naming rule now
ENUMERATES the three export paths it visits rather than describing its coverage
in the abstract. One attempt is recorded as REFUTED: teaching the doc gate to
flag our own exports as free variables measured 66 findings across 93 blocks, so
that gate's original scoping was right.

BREAKING CHANGE: vigiles/experimental is removed. Its exports are unchanged in
name and behaviour and are now reached from `vigiles` (the emit channel, the R3
disposable-service tier). Every renamed symbol keeps a working @deprecated alias
for this major.
2026-08-21 07:54:51 +05:00

5.1 KiB

hook-block-ineffective

Flag a hook that appears to block or deny but silently doesn't — the #1 verified hook pain in the wild (see research/hook-pain-points.md). The author wires up a guard, the guard "runs" without error, but nothing is ever prevented.

Two distinct failure shapes are detected:

What it flags

wrong-event — blocking mechanism on a NO-EFFECT event

A hook registered on SessionStart, SessionEnd, Notification, or PreCompact that contains a blocking mechanism (exit 2, "decision":"block", "permissionDecision":"deny"). On these events a block decision is silently ignored entirely — it can neither veto nor feed the model back (exit 2 there writes stderr only to the user). The author believes a gate is in place; nothing happens.

#!/bin/sh
# Registered on SessionStart — this exit 2 does nothing (stderr → user only).
exit 2

→ flagged as wrong-event. Move the gate to a blocking event (PreToolUse, UserPromptSubmit, Stop, or SubagentStop) so the deny fires before the action.

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. A hook that exits 2 on PostToolUse intending to block is misguided, but that intent isn't deterministically distinguishable from feedback, so flagging it would cry wolf on every nudge hook (including vigiles's own refs-nudge.sh). The don't-cry-wolf line: flag only events where a block does nothing at all.

wrong-field — correct event, legacy JSON field

A hook on a permission-gated event (PreToolUse) that emits the legacy top-level "decision":"block" field instead of the required structured form. Claude Code ignores the legacy field on permission events; the hook appears to block but nothing is denied:

#!/bin/sh
# On PreToolUse the harness only reads permissionDecision — this
# top-level "decision" field is silently discarded.
echo '{"decision":"block"}'

→ flagged as wrong-field. The correct output is:

echo '{"hookSpecificOutput":{"permissionDecision":"deny"}}'

FP-safety

The detector is conservative by design — default severity is warn, never error out of the box:

  • exit 2 is matched with a shell-context regex that requires a separator before exit and a word boundary after 2 — so exit 200 and --exit-code 2 as an argument are NOT matched.
  • JSON patterns ("decision":"block", "permissionDecision":"deny") are matched as literal substrings — no partial JSON walking.
  • Only events in the dialect's no-effect set (SessionStart / SessionEnd / Notification / PreCompact) are flagged for wrong-event; everything else — including PostToolUse (feedback) and the genuinely-blocking events — is left alone.
  • The no-effect event set is injected from the dialect (noEffectHookEvents, never hard-coded): a Codex adapter injects its own, so the detector is harness-neutral (same core ⊄ adapter pattern as verifyHookEvents). Absent ⇒ the check doesn't run for that harness.

Same detector used by vigiles audit (scan) and the hook-block-ineffective lint rule — one detector, two callers, no drift.

Configuration

{ "rules": { "hook-block-ineffective": "warn" } }

Severity

Value Behavior
"error" vigiles lint exits non-zero (2) on a false-confidence block
"warn" (default) Prints a warning, exits 0
false Skip the check

Scope

Hook commands in .claude/settings.json, .claude-plugin/plugin.json, and hooks/hooks.json. Both script-file hooks (content read from disk) and inline commands (content read from the command field directly) are inspected.

The durable fix

Patching the field or the event assignment removes the immediate finding. But the root cause is that hand-written shell hooks require the author to correctly choose the exit code, the JSON field, and the event — each a footgun the compiled-hooks design makes unrepresentable:

  • You never write the exit code or the field (the compiler emits them from a closed vocabulary).
  • The role (experimental_defineHook / experimental_definePromptGate / experimental_defineStopGate) constrains which events can carry a block decision — a category mistake is a tsc error.

Rewriting the hook as a compiled hook (vigiles/hook) is the durable solution. See docs/compiled-hooks.md.

See also