Skip to content

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 PreToolUse hook sees tool calls. It can refuse pip install before 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."