Enforcement delivery classification¶
Source: SPEC-171 FR-4. Gated by: scripts/tests/setup/37-enforcement-survives-disable.sh.
Why this file exists¶
Nearly every toolkit enforcement hook lives in plugins/vt-base/hooks/hooks.json, and that file is
read only while the plugin is enabled. Between 5.0.0 and 2026-08-08 it was not, on the
maintainer's own machine, and nobody noticed for six months.
There is exactly one exception, and it is the one this outage argues for: the update reminder ships
in configs/user-global/settings.json instead, because a hook that reports "the plugin did not
load" cannot itself be inside the plugin. SPEC-177 made that deliberate rather than incidental, and
widened the guard to pin this table against both files.
The reason nobody noticed is not that the outage was subtle. It is that nothing anywhere said which enforcement the toolkit was relying on, and which of it had a second way in. The install reported green, the hooks were on disk, and the only thing missing was a statement of what depended on what.
This file is that statement. It is generated by hand and pinned mechanically: the guard asserts
its row set equals the command set in hooks.json, in both directions, so a hook added without a
classification fails the build rather than sitting unclassified.
The two questions are not the same question¶
A classification that asks only "must this run when the plugin is disabled?" overstates its own coverage, because for several hooks the honest answer to "can it?" is no.
- A git hook sees commits. It can inspect what is about to enter history.
- A
PreToolUsehook sees tool calls. It can refusepip installbefore it runs.
Nothing bridges those. venv-guard cannot be delivered as a git hook, because by the time git is
involved the interpreter has already been polluted and there is no file change to inspect. Saying
"must-run: yes" without saying "possible: no" would turn a known gap into an assumed control,
which is the failure mode this whole spec is about.
So each row answers both, and a yes / not-possible row is an honest open risk, not an omission.
The precedent¶
secret-scanner.sh ships twice: as a PreToolUse hook in the plugin, and as Check C of
plugins/vt-base/scripts/pre-commit, invoked on a plain-file path with fail-closed semantics. The
asymmetry is deliberate — credentials reaching a commit are the one failure here that is not
recoverable by re-running anything, since it costs a history rewrite and a credential rotation.
That duplication is why the last six months were not a catastrophe, and it is the shape every must-run row is measured against.
Classification¶
Must-run = would its absence create a risk that no other layer catches?
Plugin-independent path = what actually delivers it when the plugin is off.
⚠ The last row inverts this table's semantics, so read its fourth cell differently from the
others. Everywhere above it, "plugin-independent path" names a second delivery beside the plugin
hook. For session-start-toolkit-check.sh there is no plugin hook at all: the shipped
settings.json is its only surface, by design.
| Hook | Event | Must-run | Plugin-independent path |
|---|---|---|---|
secret-scanner.sh |
PreToolUse | yes | Exists — Check C of plugins/vt-base/scripts/pre-commit, fail-closed. The precedent above. |
supply-chain-guard.sh |
PreToolUse | yes | GAP — partially possible. Install-time CVE/malicious-package interception cannot be a git hook. A manifest-diff check at commit time would catch a newly added dependency but not an ad-hoc npm i. Not built; tracked below. |
venv-guard.sh |
PreToolUse | yes | GAP — not possible. Blocks bare pip outside a venv. By the time git runs, the interpreter is already polluted and there is no file change to inspect. In-session interception is the only surface. |
claudeignore-guard.sh |
PreToolUse | yes | GAP — not possible. Blocks Read/Grep/Glob against .claudeignore-matching paths. Git hooks see commits; this sees reads, and a read leaves nothing for a commit-time surface to inspect. In-session interception is the only one. ⚠ Registered 2026-08-17 by BUG-087 — it had a script, a passing unit test, a written contract, a SHA-256 pin in trusted-hooks.yaml and an enforcement claim in SECURITY-CONFIG.md, but had never appeared in hooks.json on any branch, so it had never run. |
partition-guard.sh |
PreToolUse | no | Warn-only on write. Its commit-time concern is covered by commit-size-monitor.sh's partition check. |
security-lint.sh |
PostToolUse | no | Advisory lint on written files; /vt-d-4-review is the enforcing layer and does not depend on the plugin's hooks. |
commit-size-monitor.sh |
PostToolUse | yes | GAP — possible, not built. Oversize / missing-LFS / partition checks at commit. It currently fires only via PostToolUse [Bash] when a commit happens through the Bash tool, so it already misses commits made any other way. pre-commit is its natural host. Tracked below. |
auto-format.sh |
PostToolUse | no | Convenience. Absence costs formatting, nothing else. |
quality-gate.sh |
Stop | no | Prompts fixes at end of turn: on findings it blocks the stop once (exit 2, report on stderr) and lets the next stop through (stop_hook_active, BUG-292). Not a must-run control — /vt-d-4-review is the enforcing layer. |
autonomy-continue.sh |
Stop | no | Workflow convenience. |
intent-skill-matcher.sh |
UserPromptSubmit | no | Discovery aid. Absence costs suggestions. |
precompact-skill-inventory.sh |
PreCompact | no | Context preservation. Absence costs recall, not safety. |
session-start.sh |
SessionStart | no | Informational session banner. |
postcompact-recovery.sh |
SessionStart | no | Context recovery after compaction. |
openbrain-optin-prompt.sh |
SessionStart | no | Asks once per project whether to opt into proactive Open Brain capture. If the plugin is off, capture is off anyway, so the question is moot — there is nothing for a plugin-independent path to preserve. |
session-start-toolkit-check.sh |
SessionStart | yes | IS the plugin-independent path — there is no plugin hook to be independent of. Shipped in configs/user-global/settings.json, not in hooks.json, because it reports that the plugin failed to load. Inverts this column's meaning; see the note above the table. Its rationale is pinned by scripts/tests/setup/59-shipped-hooks-is-one-deliberate-entry.sh, which fails if the entry is ever tidied away. |
Totals: 16 hook commands across 6 events. 6 classified must-run; 2 of those have a plugin-independent path today.
On the counts, since this table now spans two files (see
count-reconciliation-single-source.md): 16 is the union and belongs to this table alone.
hooks.json holds 15 of them and the shipped settings.json holds the sixteenth, so the two
numbers are supposed to differ and neither is a stale mirror of the other. The guard derives both
sides itself rather than reading a number from anywhere, which is the point — seven files once
claimed 13, and that hand-maintained figure is what prompted the guard in the first place.
The gap this classification exposes¶
Four must-run hooks have no plugin-independent delivery. That is the finding, and it is a real one: SPEC-171 makes the plugin load, which restores all fifteen of the plugin's — but it does not make any of them survive the plugin being disabled again.
One of the four (commit-size-monitor) is buildable today and
were deliberately not built here. Adding new dual-delivery paths is enforcement engineering with
its own blast radius — a new pre-commit check fires on every commit in every consuming repo —
and it is not what FR-4 asked for. FR-4 asked for the classification, and for must-run items to
keep a plugin-independent path where one exists. That is satisfied.
The remaining three (venv-guard, supply-chain-guard, claudeignore-guard) cannot be closed by a git hook at all and
need a different mechanism, which is a design question rather than an implementation task.
Follow-up: SPEC-178 — dual-delivery for the one buildable hook, and a recorded decision for the three that need another surface. Until it ships, this table is the record that the risk is known rather than assumed away.
The transferable lesson¶
When enforcement is delivered by a single mechanism, the mechanism's own availability becomes an unmonitored dependency of every control it carries. The fix is not more hooks — it is a written statement of which controls tolerate that mechanism being absent, checked mechanically against the mechanism's actual contents, so that "we have a secret scanner" can never quietly mean "we have a secret scanner while a config flag happens to be set."