import { type NonCommandHookAction } from "./core/hook-normalize.js"; export type { NonCommandHookAction } from "./core/hook-normalize.js"; import type { HarnessAdapter } from "./core/adapter.js"; import { type DisasterCategory, type DisasterEvent, type GuardrailResult } from "./guardrail-check.js"; import type { RunHookOptions } from "./run-hook.js"; /** The hook a sweep looked at, as its config declares it. */ export interface SweptHook { /** The event it registers under, e.g. `"PreToolUse"`. */ readonly event: string; /** Its tool matcher, or `null` when it declares none (matches everything). */ readonly matcher: string | null; /** Its condition as written (Claude Code's `if`), or `null` when unconditional. */ readonly condition: string | null; /** The command, with the harness's plugin-root token already expanded. */ readonly command: string; /** * Its position in the flattened registration list, so two hooks sharing a * command are still distinguishable in a report. Stable for one sweep of one * directory; not an identity across versions. */ readonly index: number; } /** * What happened to one hook. A DISCRIMINATED UNION, not a result list plus three * nullable fields: `results` exists on exactly the outcome that has them, so * "read the score of a hook that was never run" is a type error rather than a * `0/7` someone quotes. */ export type SweptHookOutcome = { /** The battery reached this hook; `results` holds one entry per event. */ readonly status: "measured"; readonly hook: SweptHook; /** One result per battery event, in catalog order. */ readonly results: readonly GuardrailResult[]; /** Ids of the events it denied. */ readonly blocked: readonly string[]; /** Ids it ran on and let through. */ readonly allowed: readonly string[]; /** Ids the harness would never have handed it (condition did not match). */ readonly notRun: readonly string[]; } | { /** * The battery does not apply to this hook — a different event, or a matcher * that selects none of the battery's tools. NOT a score of zero. */ readonly status: "not-applicable"; readonly hook: SweptHook; /** One line naming which of the two it is, and against what. */ readonly reason: string; } | { /** * The command names a variable nothing has set, so the program we would run * is not the program the harness runs. Refusing is the honest answer; the * fix is in the caller's hands (pass `env`). */ readonly status: "unresolved"; readonly hook: SweptHook; /** One line naming the unset variables. */ readonly reason: string; }; /** The whole sweep. */ export interface PluginGuardReport { /** The directory swept, resolved to an absolute path. */ readonly dir: string; /** The adapter that read it, e.g. `"claude-code"`. */ readonly harness: string; /** The event each disaster was delivered as (default `"PreToolUse"`). */ readonly event: string; /** The battery that was used, so a report says what it measured against. */ readonly events: readonly DisasterEvent[]; /** One outcome per declared COMMAND hook, in config order. */ readonly hooks: readonly SweptHookOutcome[]; /** * Declared actions this tier cannot drive because they are not commands — * `prompt`, `http`, `mcp_tool`, `agent`. * * 🔴 THEY USED TO BE DROPPED, AND DROPPING THEM MANUFACTURED THE FALSE EMPTY * `notes` exists to prevent. A repository whose hooks are all `prompt` actions * has declared guards; it was reported as declaring none, in the words of the * one sentence this report writes to be sure nobody reads an empty result as a * clean bill of health. Not measured is a limit of the tier and says so; not * declared is an accusation about the repository, and it was not true. * * They carry no score and never will here — a shell battery cannot drive a * prompt — so they are a separate list rather than a fourth outcome status * with an invented command. */ readonly unmeasurable: readonly NonCommandHookAction[]; /** * Why the sweep measured less than a reader might assume — no COMMAND hooks * declared, a harness with no shell hooks, every hook on another event, or an * action this tier cannot drive. Empty only when at least one hook was * measured AND nothing was left undrivable: an undrivable action is a gap in * COVERAGE, so it is said even when other hooks scored. * * 🔴 THIS IS THE EMPTY CASE'S VOICE. `hooks: []` on its own reads as a clean * bill of health, which is the exact false confidence the battery exists to * remove — so a sweep that measured nothing always says so in words. */ readonly notes: readonly string[]; } /** Options for {@link experimental_verifyPluginGuards}. */ export interface VerifyPluginGuardsOptions extends Omit { /** * The harness to read the repo as. Defaults to Claude Code, so an existing * Claude Code repo needs nothing. The condition grammar and the block protocol * both come from this adapter's `hookProtocol`. */ readonly adapter?: HarnessAdapter; /** Restrict the battery to these categories (default: the whole catalog). */ readonly categories?: readonly DisasterCategory[]; /** Override the battery entirely. */ readonly events?: readonly DisasterEvent[]; /** The event each disaster is delivered as (default `"PreToolUse"`). */ readonly event?: string; } /** * Run the disaster battery against every hook a plugin or repo declares, using * each hook's OWN event, matcher and condition, and report per hook. * * ```ts * import { experimental_verifyPluginGuards } from "vigiles"; * * const report = experimental_verifyPluginGuards("."); * for (const h of report.hooks) { * if (h.status === "measured") * console.log(`${h.blocked.length}/${h.results.length} ${h.hook.command}`); * else console.log(`⊘ ${h.status} ${h.hook.command} — ${h.reason}`); * } * for (const note of report.notes) console.log(note); * ``` * * Nothing here needs a model or a key. The hooks it finds are the ones the * harness would load, so a hook that is present on disk but not registered is * absent from the report by construction — which is the correct answer, and the * one you would not get by globbing `hooks/*.sh`. * * ⚠️ It RUNS each reachable hook. A hook is a program you did not necessarily * write, so point this at a repo whose hooks you are willing to execute, or pass * `trusted: false` / `sandbox: "auto"` (inherited from {@link RunHookOptions}) to * confine them. `verifyGuardrail` has always had the same property; sweeping a * whole plugin makes it worth saying out loud. * * @experimental Days old, with no consumer outside this repository. The REPORT * SHAPE is the part most likely to move — specifically whether `not-applicable` * stays one status or splits by cause, and whether the per-hook counts stay id * arrays. The prefix comes off when that shape survives sweeping several real * third-party repos unchanged; see docs/experimental.md. * * @param dir - the plugin or repo root to read hooks from. */ export declare function experimental_verifyPluginGuards(dir: string, opts?: VerifyPluginGuardsOptions): PluginGuardReport; /** * Render a {@link PluginGuardReport} as terminal text. * * ```ts * import { * experimental_verifyPluginGuards, * experimental_formatPluginGuardReport, * } from "vigiles"; * * console.log( * experimental_formatPluginGuardReport(experimental_verifyPluginGuards(".")), * ); * ``` * * NEUTRAL, the same way {@link formatGuardrailReport} is: it reports what each * hook blocks without deciding whether that was the hook's job. A repo's config * never says which of its hooks is meant to be a bash-safety guard, so a verdict * here would be invented rather than read. * * 🔴 A HOOK THE BATTERY NEVER REACHED IS NEVER GIVEN A NUMBER. A `measured` hook * prints `blocks n/7`; a `not-applicable` or `unresolved` one prints its REASON * under a `⊘` heading and no count at all, because a rendered `0/7` is the same * false confidence the discriminated union exists to prevent, reintroduced one * layer up where the type system can no longer see it. For the same reason the * report's `notes` are printed FIRST and in full: a sweep that measured nothing * has to say so in words, since an output with no rows reads as a clean bill of * health. * * MANY HOOKS STAY READABLE by grouping the unmeasured half BY REASON — a repo * with thirty hooks usually has two or three distinct reasons — and naming at * most {@link HOOKS_PER_REASON} hooks per reason before counting the rest. The * measured half is never collapsed: those are the hooks you came for. * * @experimental It renders {@link PluginGuardReport}, whose SHAPE is the part * most likely to move (see {@link experimental_verifyPluginGuards}), so a stable * name here would promise a stability its only input does not have. The prefix * comes off with the same change that takes it off the report. * * @param report - a sweep from {@link experimental_verifyPluginGuards}. */ export declare function experimental_formatPluginGuardReport(report: PluginGuardReport): string; //# sourceMappingURL=verify-plugin-guards.d.ts.map