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. |
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. |
branch-switch-gate-invalidation.sh |
PostToolUse | yes | GAP — possible, not built. Deletes stale .review-gate.md / .test-gate.md on branch switch. Without it a FAIL branch can present a stale PASS gate — a correctness risk for the workflow gates, not merely a tidiness one. post-checkout is the natural host, but that file is currently owned by git-lfs. Tracked below. |
quality-gate.sh |
Stop | no | Prompts fixes at end of turn. Advisory; /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. |
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: 15 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): 15 is the union and belongs to this table alone.
hooks.json holds 14 of them and the shipped settings.json holds the fifteenth, 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 fourteen of the plugin's — but it does not make any of them survive the plugin being disabled again.
Two of the four (commit-size-monitor, branch-switch-gate-invalidation) are 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 two (venv-guard, supply-chain-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 two buildable hooks, and a recorded decision for the two 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."