vigiles 26.1.1 β†’ 26.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,822 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.experimental_verifyPluginGuards = experimental_verifyPluginGuards;
4
+ exports.experimental_formatPluginGuardReport = experimental_formatPluginGuardReport;
5
+ /**
6
+ * Sweep the disaster battery across every hook a plugin/repo actually DECLARES.
7
+ *
8
+ * πŸ”΄ THE GAP THIS CLOSES, and it is the same one twice. `verifyGuardrail` takes a
9
+ * COMMAND STRING. #211 taught it to honour a hook's `if:` condition β€” but only
10
+ * when the caller remembers to pass it, and its own comment says so:
11
+ *
12
+ * // `condition` + `protocol` are inherited from RunHookOptions β€” pass the
13
+ * // hook's declared `if` here …
14
+ *
15
+ * So "forgot to pass the condition" stayed a reachable state, and reaching it
16
+ * produces exactly the false 7/7 that #211 existed to kill. The fix is not
17
+ * another warning: it is to stop asking the caller for facts the config already
18
+ * holds. This function reads the hook's command, event, matcher AND condition off
19
+ * the same registration, so the three cannot be paired wrongly.
20
+ *
21
+ * WHAT IT IS NOT. It reports; it does not judge. There is deliberately no
22
+ * throwing `assertPluginGuards`, because intent is DECLARED PER HOOK and a repo's
23
+ * config declares none of it β€” we do not know which of a plugin's five hooks is
24
+ * meant to be a bash-safety guard. `assertBlocksDisasters` remains the gate you
25
+ * reach for once YOU have said which hook must block what; this is the sweep that
26
+ * tells you which hooks are even in the conversation. Same neutrality
27
+ * `formatGuardrailReport` already commits to, one level up.
28
+ *
29
+ * FOUR THINGS THAT ARE NOT A VERDICT, and each has its own shape rather than a
30
+ * quietly-zero score β€” the whole lesson of #211 is that a missing RUN must never
31
+ * be readable as a finding:
32
+ *
33
+ * - a hook on another EVENT (`Stop`, `SessionStart`, a `PostToolUse` nudge) is
34
+ * never asked about these calls at all;
35
+ * - a hook whose MATCHER cannot select the battery's tools likewise;
36
+ * - a hook whose CONDITION rejects every event its matcher did select β€” the
37
+ * harness spawns it for none of them, so there is no program to score;
38
+ * - a hook whose COMMAND still names a variable nobody has set cannot be run as
39
+ * the harness runs it, so running it would measure a different program.
40
+ *
41
+ * πŸ”΄ THE THIRD WAS MISSED AND WAS COUNTED, which is why the list says FOUR. The
42
+ * other three are settled before a spawn; the condition is settled per event
43
+ * INSIDE the run, so `matcher: "Bash"` with `if: "Bash(terraform apply*)"` came
44
+ * back `measured` with every row not-run, rendered as `blocks 0/7`, and left
45
+ * `notes` silent because a hook had been "measured". Reported as a guard that
46
+ * blocks nothing; actually a guard the harness never started. See `sweepHook`.
47
+ *
48
+ * A plugin with no hooks reports zero of everything and says so in `notes`. None
49
+ * of these is "safe" and none is "blocks nothing".
50
+ *
51
+ * πŸ”΄ KNOWN REMAINDER, and it is one class rather than a list of spellings
52
+ * (zernie/vigiles#213). `blocked` is decided by exit code 2, and an interpreter
53
+ * that cannot start its script ALSO exits 2 β€” so a guard that never ran and a
54
+ * guard that denied are the same observation. The preflight below is a PROXY for
55
+ * "did it run": it resolves the script and reports `unresolved` when the file is
56
+ * absent. Review has defeated that proxy five times; two are fixed here (a
57
+ * wrapper hiding the interpreter, a wrong execution cwd) and three are NOT:
58
+ *
59
+ * - a command-local assignment β€” `GUARD=missing.py; python3 "$GUARD"`;
60
+ * - a dominating `cd` β€” `cd hooks && python3 guard.py`;
61
+ * - a command substitution that really executes β€” `result=$(python3 missing.py)`.
62
+ *
63
+ * AND TWO IN THE OPPOSITE DIRECTION, named here because the fix for them is the
64
+ * same "resolve harder" reflex and it is the same mistake. These do not
65
+ * manufacture a score β€” they REFUSE one a real guard had earned, so a hook that
66
+ * exists reads as `unresolved`. Both UNDER-report, which is why they are
67
+ * remainder rather than defect:
68
+ *
69
+ * - a TILDE β€” `python3 ~/.claude/hooks/guard.py`. Tilde expansion is the
70
+ * shell's, not the parser's, so the ref stays literal and `resolve(cwd, ref)`
71
+ * probes `<cwd>/~/.claude/…`. Measured: a guard present at
72
+ * `$HOME/.claude/hooks/` is reported as not on disk.
73
+ * - `$PWD` UNDER CONFINEMENT β€” it is absent from the confined name set below
74
+ * (`HOME`, `TMPDIR`, `PATH`), because `bwrapArgs` does not set it; but
75
+ * `/bin/sh` initializes `PWD` itself after bubblewrap's `--chdir`, so the
76
+ * variable IS set by the time the hook reads it. Measured: the same command
77
+ * scores 7/7 unconfined and reads `unresolved` confined.
78
+ *
79
+ * Do not "fix" these by resolving harder. Extracting a script path from an
80
+ * arbitrary shell command is the same undecidable problem `bash-effects.ts`
81
+ * documents for itself, where `eval`, a `$VAR` head and `sh -c` normalize to
82
+ * `null` BY CONSTRUCTION. Each patch buys one spelling and leaves the class open.
83
+ * What closes it is a CONTROL PROBE: one benign command the guard must ALLOW,
84
+ * run beside the battery β€” blocked too β‡’ the program is not blocking, it is
85
+ * failing to start, and the hook is `unmeasurable` rather than scored. The same
86
+ * in-run-control shape `src/subagent-delivery.test.ts` already relies on.
87
+ *
88
+ * HARNESS-AGNOSTIC, with Claude Code as the default β€” the same shape as
89
+ * {@link runHarnessTest}. Every piece it composes already takes its harness by
90
+ * injection: `loadPlugin` takes a `PluginLayout`, `normalizeHooks` reads both the
91
+ * CC-nested and Codex-flat shapes, and `decideHookCondition` treats a harness that
92
+ * declares no condition support as having none. Narrowing this to Claude Code
93
+ * would have been a choice, not a constraint, and `harness-parity-and-extensibility`
94
+ * forbids the CC-first-and-bolt-the-rest-on shape. A harness whose hooks are not
95
+ * shell processes (`capabilities.shellHooks === false`, e.g. OpenCode) reports
96
+ * n/a in `notes` rather than an empty success.
97
+ */
98
+ const node_path_1 = require("node:path");
99
+ const node_fs_1 = require("node:fs");
100
+ const plugin_loader_js_1 = require("./plugin-loader.js");
101
+ const hook_normalize_js_1 = require("./core/hook-normalize.js");
102
+ const hook_matcher_js_1 = require("./core/hook-matcher.js");
103
+ const shell_vars_js_1 = require("./core/shell-vars.js");
104
+ const command_files_js_1 = require("./core/command-files.js");
105
+ const adapter_js_1 = require("./adapters/claude-code/adapter.js");
106
+ const sandbox_js_1 = require("./sandbox.js");
107
+ const egress_js_1 = require("./egress.js");
108
+ const run_script_js_1 = require("./run-script.js");
109
+ const guardrail_check_js_1 = require("./guardrail-check.js");
110
+ // `condition` and `protocol` are deliberately NOT accepted: the condition is read
111
+ // per hook from the config, and the protocol comes from the adapter. Letting a
112
+ // caller pass either would reintroduce the pairing mistake this exists to make
113
+ // unrepresentable β€” one hook's `if` applied to a different hook's command.
114
+ const DEFAULT_EVENT = "PreToolUse";
115
+ /**
116
+ * Variables the command DEPENDS ON that nothing would set at run time.
117
+ *
118
+ * Two filters, and both exist because a false `unresolved` costs a real guard
119
+ * its measurement. The first is the shell's own reading of the command
120
+ * (`shellVarReads`, a real parse): a name the command ASSIGNS for itself before
121
+ * reading it, or one written inside single quotes, is not a dependency however
122
+ * much it looks like `$NAME`. The second is the environment the hook would
123
+ * ACTUALLY get β€” see {@link hookEnvironment}, which is why the set is passed in
124
+ * rather than read from `process.env` here.
125
+ */
126
+ function unsetVariables(command, names) {
127
+ return (0, shell_vars_js_1.shellVarReads)(command).reads.filter((name) => !names.has(name));
128
+ }
129
+ /**
130
+ * Files the command hands to a program to run, that are not there.
131
+ *
132
+ * πŸ”΄ THIS IS THE ONE THE EXIT CODE CANNOT ANSWER, and it was the sweep's loudest
133
+ * lie. `python3 <missing>.py` exits **2**, which is Claude Code's DENY code, so
134
+ * a hook whose script does not exist was reported as blocking every disaster in
135
+ * the battery β€” a perfect score for a guard that does not exist. Measured on the
136
+ * unfixed build: `blocks=7/7 exits=2,2,2,2,2,2,2`. Unlike the uncompilable
137
+ * matcher, no malformed config is needed to reach it: a relative script path is
138
+ * the commonest hook shape there is.
139
+ *
140
+ * Resolution is against the cwd the hook will actually run in, because that is
141
+ * the only directory a relative path means anything against. See
142
+ * `core/command-files.ts` for what counts as a file reference and the corpus
143
+ * measurement behind that narrowing.
144
+ */
145
+ function missingFiles(command, cwd, values) {
146
+ return (0, command_files_js_1.commandFileRefs)(command, values).refs.filter((ref) => {
147
+ // πŸ”΄ A RELATIVE PATH IS MISSING BY CONSTRUCTION IN A FRESH EMPTY DIRECTORY.
148
+ // A confined run with no `cwd` is chdir'd into a directory `sandboxedSpawn`
149
+ // just created, so nothing relative can be there β€” while the host's `/` is
150
+ // ro-bound, which is why an ABSOLUTE ref is still tested on disk.
151
+ if (!(0, node_path_1.isAbsolute)(ref))
152
+ return cwd.kind === "fresh-empty" || !(0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd.dir, ref));
153
+ return !(0, node_fs_1.existsSync)(ref);
154
+ });
155
+ }
156
+ /** The battery, after `categories` / `events` narrowing. */
157
+ function selectEvents(opts) {
158
+ if (opts.events)
159
+ return opts.events;
160
+ if (opts.categories) {
161
+ const set = new Set(opts.categories);
162
+ return guardrail_check_js_1.DISASTER_CATALOG.filter((e) => set.has(e.category));
163
+ }
164
+ return guardrail_check_js_1.DISASTER_CATALOG;
165
+ }
166
+ /** The distinct tools a battery names, for the matcher-reachability message. */
167
+ function toolsOf(events) {
168
+ return [...new Set(events.map((e) => e.tool))];
169
+ }
170
+ /** One registration as the report names it. */
171
+ function sweptHook(reg, index) {
172
+ return {
173
+ event: reg.event,
174
+ matcher: reg.matcher,
175
+ condition: reg.condition,
176
+ command: reg.command,
177
+ index,
178
+ };
179
+ }
180
+ /**
181
+ * The event variables the sweep can answer from the event it is SYNTHESIZING,
182
+ * keyed by the lowercased name a harness declares in `eventEnvVars`.
183
+ *
184
+ * πŸ”΄ THE TABLE IS SHORT ON PURPOSE, and the short list is the whole policy: the
185
+ * sweep sets a declared variable only when it KNOWS the value. Codex also
186
+ * declares `session_id`, `turn_id`, `model` and `permission_mode`; the sweep
187
+ * synthesizes none of those, and a hook may branch on `permission_mode`, so
188
+ * inventing a value would not resolve the run β€” it would measure a program
189
+ * configured by a number we made up. Those stay unset, the hook reads
190
+ * `unresolved`, and its reason names them so a caller can supply the value they
191
+ * actually mean via `env`.
192
+ */
193
+ const DERIVABLE_EVENT_VARS = {
194
+ hook_event_name: (f) => f.event,
195
+ // The directory the hook RUNS in, which is the project β€” not the plugin the
196
+ // hooks were read from. They coincide for the ordinary "sweep this repo" call
197
+ // and are different the moment an installed plugin is swept against a host
198
+ // project, which is when getting it wrong would matter.
199
+ cwd: (f) => f.project,
200
+ plugin_root: (f) => f.root,
201
+ };
202
+ /**
203
+ * The environment each hook is run with: the harness's plugin-root variable set
204
+ * to the real root, plus every variable the ADAPTER declares that the sweep can
205
+ * honestly derive, with the caller's `env` layered on top so they can override
206
+ * any of it or add anything else.
207
+ *
208
+ * πŸ”΄ WITHOUT THE PLUGIN-ROOT HALF, THE COMMONEST HOOK SHAPE IN THE WILD READS AS
209
+ * `unresolved`. `loadPlugin` expands the BRACED token (`${PLUGIN_ROOT}`)
210
+ * textually, but real hooks are shell and are written unbraced
211
+ * (`node "$CLAUDE_PLUGIN_ROOT"/x.cjs` β€” the vendored oh-my-claudecode shape).
212
+ * Setting the variable rather than doing a second string substitution covers
213
+ * BOTH spellings with one mechanism, and it is what the harness itself does: the
214
+ * shell in a real session resolves that name because the harness put it in the
215
+ * environment. The NAME is derived from `layout.pluginRootToken`, never written
216
+ * out, so this stays correct for a harness that spells its root differently.
217
+ *
218
+ * The DECLARED half is the same argument one layer up. `HookProtocol.eventEnvVars`
219
+ * exists to say "a synthesized hook event carries these", and this function is
220
+ * synthesizing one β€” so a Codex hook reading `$hook_event_name` or `$cwd` is an
221
+ * ordinary hook, not an unresolvable one, and reading the declaration rather
222
+ * than a literal keeps that true for the next adapter. Claude Code declares an
223
+ * empty list, so nothing changes there.
224
+ */
225
+ /** The variable name inside a `${NAME}` token, or null when it is not one. */
226
+ function tokenName(token) {
227
+ return /^\$\{(.+)\}$/.exec(token)?.[1] ?? null;
228
+ }
229
+ /**
230
+ * Whether the run will be CONFINED β€” by ASKING the runner which route it will
231
+ * take, not by restating the policy.
232
+ *
233
+ * πŸ”΄ IT USED TO RESTATE IT, AND THE RESTATEMENT WAS A TERM SHORT. The copy read
234
+ * `opts.sandbox ?? (opts.trusted === false ? "auto" : false)` and knew nothing
235
+ * about `recordEgress`, which the runner has always confined for (it needs the
236
+ * netns recorder). So a `recordEgress: true` sweep pre-flighted every relative
237
+ * script against THIS process's cwd and every variable against `process.env`,
238
+ * accepted both, and then handed the hook to a run that started in a fresh empty
239
+ * directory with a cleared environment β€” where the interpreter cannot open its
240
+ * script, exits 2, and is scored as a block. The same false 7/7 the pre-flight
241
+ * exists to prevent, arriving through the option nobody had copied over.
242
+ *
243
+ * Under {@link routeScriptRun} there is no list here to fall behind: the next
244
+ * option that selects confinement is added once, in the runner, and this reads
245
+ * it. A refusal counts as confined, deliberately β€” the sweep describes the
246
+ * environment a run WOULD have, and the environment it would have refused to run
247
+ * unconfined in is the confined one.
248
+ */
249
+ function willBeConfined(opts) {
250
+ return ((0, run_script_js_1.routeScriptRun)(opts, {
251
+ sandbox: (0, sandbox_js_1.sandboxAvailable)(),
252
+ egress: (0, egress_js_1.egressAvailable)((0, sandbox_js_1.sandboxAvailable)()),
253
+ }).kind !== "direct");
254
+ }
255
+ /**
256
+ * The directory the hook will run in β€” the ONE answer the pre-flight tests files
257
+ * against and the runner is handed as `cwd`, so the two cannot disagree.
258
+ *
259
+ * @param root - the swept repository, already resolved.
260
+ */
261
+ function effectiveCwd(opts, root) {
262
+ if (opts.cwd !== undefined)
263
+ return { kind: "path", dir: (0, node_path_1.resolve)(opts.cwd) };
264
+ return willBeConfined(opts)
265
+ ? { kind: "fresh-empty" }
266
+ : { kind: "path", dir: root };
267
+ }
268
+ /**
269
+ * πŸ”΄ WITHOUT THE PLUGIN-ROOT HALF, THE COMMONEST HOOK SHAPE IN THE WILD READS AS
270
+ * `unresolved`. `loadPlugin` expands the BRACED token (`${PLUGIN_ROOT}`)
271
+ * textually, but real hooks are shell and are written unbraced
272
+ * (`node "$CLAUDE_PLUGIN_ROOT"/x.cjs` β€” the vendored oh-my-claudecode shape).
273
+ * Setting the variable rather than doing a second string substitution covers
274
+ * BOTH spellings with one mechanism, and it is what the harness itself does: the
275
+ * shell in a real session resolves that name because the harness put it in the
276
+ * environment. The NAMES are derived from `layout.pluginRootToken` and
277
+ * `layout.projectRootTokens`, never written out, so this stays correct for a
278
+ * harness that spells its roots differently.
279
+ *
280
+ * THE PROJECT-ROOT HALF IS THE SAME ARGUMENT AND WAS MISSING. `$CLAUDE_PROJECT_DIR`
281
+ * is what an ordinary project hook reads β€” including this repository's own β€” and
282
+ * the sweep is sweeping exactly that root, so calling it unresolvable made the
283
+ * function useless on the commonest shape it will ever meet.
284
+ *
285
+ * πŸ”΄ BUT THE PROJECT IS NOT THE PLUGIN, and binding both names to `root` was a
286
+ * conflation that only hides while they coincide. Sweeping an INSTALLED plugin
287
+ * (`dir` = `~/.claude/plugins/foo`, `cwd` = the host project) is a supported
288
+ * call, and there the harness sets `$CLAUDE_PROJECT_DIR` to the project the hook
289
+ * runs against, not to the plugin it came from. Pointing it at the plugin runs a
290
+ * project hook against the wrong tree β€” or reports it unresolved for a file that
291
+ * exists exactly where the harness would have looked. So the plugin-root token
292
+ * stays bound to `root` and the project-root tokens follow the run's cwd; with
293
+ * no `cwd` they are the same directory and nothing changes.
294
+ *
295
+ * The DECLARED-EVENT half is the argument one layer up. `HookProtocol.eventEnvVars`
296
+ * exists to say "a synthesized hook event carries these", and this function is
297
+ * synthesizing one, so a Codex hook reading `$hook_event_name` or `$cwd` is an
298
+ * ordinary hook, not an unresolvable one. Claude Code declares an empty list, so
299
+ * nothing changes there.
300
+ *
301
+ * πŸ”΄ AND THE AVAILABILITY SET FOLLOWS THE EXECUTION MODE, which is the half that
302
+ * cannot be derived before the mode is known. A confined run (`trusted: false`,
303
+ * `sandbox: "auto"|"strict"`, or any `egress` allowlist) starts from
304
+ * `--clearenv` and gets back only `HOME`, `TMPDIR`, `PATH` and the caller's
305
+ * `env`. Checking availability against `process.env` therefore cleared every
306
+ * ambient variable the hook will NOT find β€” so a foreign hook reading a CI or
307
+ * custom variable was `measured` while running with it missing, which is the
308
+ * same false score by a third door.
309
+ */
310
+ function hookEnvironment(adapter, root, project, event, opts) {
311
+ const derived = {};
312
+ const rootVar = tokenName(adapter.layout.pluginRootToken);
313
+ if (rootVar !== null)
314
+ derived[rootVar] = root;
315
+ for (const token of adapter.layout.projectRootTokens ?? []) {
316
+ const name = tokenName(token);
317
+ if (name !== null)
318
+ derived[name] = project;
319
+ }
320
+ for (const name of adapter.hookProtocol?.eventEnvVars ?? []) {
321
+ const value = DERIVABLE_EVENT_VARS[name.toLowerCase()]?.({
322
+ root,
323
+ project,
324
+ event,
325
+ });
326
+ if (value !== undefined)
327
+ derived[name] = value;
328
+ }
329
+ const pass = { ...derived, ...opts.env };
330
+ if (!willBeConfined(opts)) {
331
+ const values = {};
332
+ for (const [name, value] of Object.entries(process.env))
333
+ if (value !== undefined)
334
+ values[name] = value;
335
+ return {
336
+ pass,
337
+ names: new Set([...Object.keys(process.env), ...Object.keys(pass)]),
338
+ values: { ...values, ...pass },
339
+ };
340
+ }
341
+ // Confined: exactly what `bwrapArgs` sets back after `--clearenv`, plus what
342
+ // `setenvArgs` restores. HOME and TMPDIR are set to sandbox-internal temp
343
+ // directories, so they are present without being resolvable HERE β€” a path
344
+ // built from them is left alone rather than tested against the host's copy.
345
+ return {
346
+ pass,
347
+ names: new Set(["HOME", "TMPDIR", "PATH", ...Object.keys(pass)]),
348
+ values: { PATH: process.env["PATH"] ?? "", ...pass },
349
+ };
350
+ }
351
+ /**
352
+ * Decide one hook, running the battery only when it could reach this hook at all.
353
+ *
354
+ * Settled in the order the harness itself settles them: the event, then the
355
+ * matcher, then the command β€” three non-verdicts decided BEFORE anything is
356
+ * spawned. The FOURTH, the condition, is the exception and cannot be otherwise:
357
+ * the harness applies it per event inside the run, so it is read back off the
358
+ * run's own verdict rather than re-decided here. See the check below `ran`.
359
+ */
360
+ function sweepHook(reg, index, ctx) {
361
+ const { battery, eventName, opts, protocol, env } = ctx;
362
+ const hook = sweptHook(reg, index);
363
+ if (reg.event !== eventName)
364
+ return {
365
+ status: "not-applicable",
366
+ hook,
367
+ reason: `registered on ${reg.event}; this battery is delivered as ${eventName}, so the hook is never asked about these calls`,
368
+ };
369
+ const style = ctx.protocol.matcherStyle;
370
+ // πŸ”΄ UNCOMPILABLE FIRST, AND IT IS NOT A NARROWER "misses". A matcher the
371
+ // regex engine rejects is one the HARNESS cannot build either, so it spawns
372
+ // the hook for nothing β€” running the battery anyway would hand an
373
+ // unconditional-deny body a full score for a hook that never runs, which is
374
+ // the false 7/7 this whole function exists to prevent, arriving through the
375
+ // matcher instead of the condition.
376
+ if (battery.some((e) => (0, hook_matcher_js_1.hookMatcherReach)(reg.matcher, e.tool, style) === "uncompilable"))
377
+ return {
378
+ status: "not-applicable",
379
+ hook,
380
+ reason: `matcher \`${reg.matcher ?? ""}\` is not a valid regular expression β€” the harness cannot compile it either, so it never spawns this hook and there is nothing to measure (the \`hook-matcher\` rule reports the same matcher as invalid-regex)`,
381
+ };
382
+ const reachable = battery.filter((e) => (0, hook_matcher_js_1.hookMatcherReach)(reg.matcher, e.tool, style) === "selects");
383
+ if (reachable.length === 0)
384
+ return {
385
+ status: "not-applicable",
386
+ hook,
387
+ reason: `matcher \`${reg.matcher ?? ""}\` selects none of the tools this battery calls (${toolsOf(battery).join(", ")}) β€” the harness never spawns it here`,
388
+ };
389
+ // πŸ”΄ BEFORE EITHER ANALYSIS, BECAUSE A REJECTED COMMAND DEFEATS BOTH. `sh`
390
+ // exits 2 on a syntax error β€” this harness's DENY code β€” so a hook whose
391
+ // command does not parse was run and scored as blocking, which is the same
392
+ // false 7/7 the file check above prevents, reached without a missing file.
393
+ // The file half went quiet rather than loud on it (`{ parsed: false,
394
+ // refs: [] }` reads exactly like a clean command), and the variable half is
395
+ // worse than quiet: its regex fallback still names `$FOO` in `echo "$FOO`, so
396
+ // the sweep would have blamed an unset variable for a syntax error and told
397
+ // the caller to pass `env`. Deciding this first makes both accurate.
398
+ if (!(0, command_files_js_1.commandFileRefs)(reg.command).parsed)
399
+ return {
400
+ status: "unresolved",
401
+ hook,
402
+ reason: `the command is not valid shell β€” the parser rejects it, and so does \`sh\`, which exits 2 for a syntax error. That is this harness's DENY code, so running it anyway would report the syntax error as a block`,
403
+ };
404
+ const missingVars = unsetVariables(reg.command, env.names);
405
+ if (missingVars.length > 0)
406
+ return {
407
+ status: "unresolved",
408
+ hook,
409
+ reason: `the command names ${missingVars.map((v) => `\`$${v}\``).join(", ")}, which nothing sets here β€” running it would measure a different program than the harness runs. Pass \`env\` to resolve it`,
410
+ };
411
+ // The file half of the same question, and it has to be asked BEFORE the run:
412
+ // a missing script makes the interpreter exit 2, which is indistinguishable
413
+ // from a deny once it has happened.
414
+ const absent = missingFiles(reg.command, ctx.cwd, env.values);
415
+ if (absent.length > 0) {
416
+ const named = absent.map((f) => `\`${f}\``).join(", ");
417
+ const relative = absent.some((f) => !(0, node_path_1.isAbsolute)(f));
418
+ return {
419
+ status: "unresolved",
420
+ hook,
421
+ reason: ctx.cwd.kind === "fresh-empty" && relative
422
+ ? `the command runs ${named} by a RELATIVE path, and a confined run starts in a fresh empty directory β€” the script cannot be there, whatever is on disk here. Pass \`cwd\` (the project the hook runs against) so the path means something, or an absolute path (an interpreter that cannot open its script exits 2, which is this harness's DENY code, so running it anyway would report a perfect score for a guard that never ran)`
423
+ : `the command runs ${named}, which ${absent.length === 1 ? "is" : "are"} not on disk here β€” the guard the config names does not exist, so there is nothing to measure (an interpreter that cannot open its script exits 2, which is this harness's DENY code, so running it anyway would report a perfect score for a guard that never ran)`,
424
+ };
425
+ }
426
+ const ran = (0, guardrail_check_js_1.verifyGuardrail)(reg.command, {
427
+ ...opts,
428
+ // πŸ”΄ THE SAME ANSWER THE PRE-FLIGHT USED, not `opts.cwd` again. The checks
429
+ // above tested this hook's files against {@link SweepContext.cwd}; handing
430
+ // the runner a different directory would make every one of those checks a
431
+ // statement about a tree the hook never ran in. `fresh-empty` passes nothing
432
+ // through, because that directory is the runner's to mint.
433
+ cwd: ctx.cwd.kind === "path" ? ctx.cwd.dir : undefined,
434
+ env: env.pass,
435
+ events: reachable,
436
+ event: eventName,
437
+ condition: reg.condition ?? undefined,
438
+ protocol,
439
+ });
440
+ // πŸ”΄ AND AFTER THE RUN, THE HALF THE PRE-FLIGHT CANNOT SEE. A missing
441
+ // interpreter, a file without the execute bit, a bad shebang: the shell
442
+ // answers 127/126 and the program never had an opinion. That is a property of
443
+ // the COMMAND, not of one battery event β€” the same command runs for all of
444
+ // them β€” so one such result disqualifies the hook rather than one row. Erring
445
+ // toward refusal is deliberate: a hook that fails to launch on only some
446
+ // inputs is still a hook whose score would be part fiction.
447
+ const stillborn = ran.find((r) => !r.ran && (0, run_script_js_1.shellNeverLaunched)(r.exitCode) && !r.blocked);
448
+ if (stillborn !== undefined)
449
+ return {
450
+ status: "unresolved",
451
+ hook,
452
+ reason: `${stillborn.reason}. Nothing here is the guard's verdict, so it is not scored`,
453
+ };
454
+ // πŸ”΄ THE FOURTH NON-VERDICT, and it arrives through the one gate the other
455
+ // three do not pass through. The event, the matcher and the command are all
456
+ // settled before a spawn; the CONDITION is settled per event INSIDE the run,
457
+ // by `decideHookCondition` β€” so a hook whose `if` rejects every event its
458
+ // matcher selected (matcher `Bash`, `if: "Bash(terraform apply*)"`) reached
459
+ // this line with a full `ran` list of `ran: false` and was returned as
460
+ // `measured`. The renderer then printed `blocks 0/7` and `sweepNotes` saw a
461
+ // measured hook and stayed silent β€” a guard the harness never spawned,
462
+ // reported as a guard that blocks nothing. That is the same false verdict as
463
+ // the other three, in the unsafe direction, so it gets the same shape: its own
464
+ // `not-applicable` reason, no score.
465
+ //
466
+ // ASKED AFTER THE RUN, NOT BEFORE, and deliberately: `ran: false` here IS the
467
+ // runner's own condition verdict, so this reads the decision the harness would
468
+ // make rather than re-deciding it beside `decideHookCondition` β€” the second
469
+ // copy is the one that drifts. It costs nothing, because a rejected condition
470
+ // never spawns a process either. `ran` is non-empty (an empty `reachable`
471
+ // already returned above) and a launch failure already returned as
472
+ // `unresolved`, so every `ran: false` left here is a condition rejection.
473
+ if (ran.every((r) => !r.ran))
474
+ return {
475
+ status: "not-applicable",
476
+ hook,
477
+ reason: `condition \`${reg.condition ?? ""}\` selects none of the ${plural(ran.length, "battery event")} its matcher reaches β€” the harness never spawns this hook for any of them, so there is nothing to measure`,
478
+ };
479
+ // The events the matcher excluded are folded back in as NOT-RUN entries, in
480
+ // catalog order, so `results` always answers for the whole battery. Dropping
481
+ // them would leave a hook reporting "1/1 blocked" for a battery of seven.
482
+ const byId = new Map(ran.map((r) => [r.event.id, r]));
483
+ const results = battery.map((event) => byId.get(event.id) ?? {
484
+ event,
485
+ blocked: false,
486
+ exitCode: 0,
487
+ ran: false,
488
+ reason: `matcher \`${reg.matcher ?? ""}\` does not select ${event.tool} β€” the harness never spawns this hook for it`,
489
+ });
490
+ const ids = (pick) => results.filter(pick).map((r) => r.event.id);
491
+ return {
492
+ status: "measured",
493
+ hook,
494
+ results,
495
+ blocked: ids((r) => r.blocked),
496
+ allowed: ids((r) => r.ran && !r.blocked),
497
+ notRun: ids((r) => !r.ran),
498
+ };
499
+ }
500
+ /** The `notes` a sweep owes its reader when it measured less than it looks like. */
501
+ function sweepNotes(dir, regs, outcomes, unmeasurable, eventName) {
502
+ // Always said, measured or not: an action this tier cannot drive is a gap in
503
+ // the COVERAGE, and a reader who sees six measured hooks has no way to know a
504
+ // seventh guard went unexamined unless the sweep says so.
505
+ const notDrivable = unmeasurable.length === 0
506
+ ? []
507
+ : [
508
+ `${plural(unmeasurable.length, "declared hook action")} (${[...new Set(unmeasurable.map((a) => a.type))].sort().join(", ")}) ${unmeasurable.length === 1 ? "is not a shell process" : "are not shell processes"}, so this battery cannot drive ${unmeasurable.length === 1 ? "it" : "them"}. Declared and NOT measured β€” not absent.`,
509
+ ];
510
+ if (outcomes.some((o) => o.status === "measured"))
511
+ return notDrivable;
512
+ // πŸ”΄ "NO COMMAND HOOKS", NOT "NO HOOKS". Saying the repository declares no
513
+ // guards when it declares four `prompt` actions is a false accusation dressed
514
+ // as the sentence that exists to prevent false comfort.
515
+ if (regs.length === 0)
516
+ return [
517
+ unmeasurable.length === 0
518
+ ? `No hooks are declared in ${dir}. Nothing was measured β€” this is not a clean bill of health, it is an absence of guards.`
519
+ : `No COMMAND hooks are declared in ${dir}. Nothing was measured here β€” but hooks ARE declared, so this is not an absence of guards either.`,
520
+ ...notDrivable,
521
+ ];
522
+ return [
523
+ `${regs.length} hook(s) declared, none of them reachable by this battery on ${eventName}. Nothing was measured β€” read each hook's reason below rather than the (empty) score.`,
524
+ ...notDrivable,
525
+ ];
526
+ }
527
+ /**
528
+ * Run the disaster battery against every hook a plugin or repo declares, using
529
+ * each hook's OWN event, matcher and condition, and report per hook.
530
+ *
531
+ * ```ts
532
+ * import { experimental_verifyPluginGuards } from "vigiles";
533
+ *
534
+ * const report = experimental_verifyPluginGuards(".");
535
+ * for (const h of report.hooks) {
536
+ * if (h.status === "measured")
537
+ * console.log(`${h.blocked.length}/${h.results.length} ${h.hook.command}`);
538
+ * else console.log(`⊘ ${h.status} ${h.hook.command} β€” ${h.reason}`);
539
+ * }
540
+ * for (const note of report.notes) console.log(note);
541
+ * ```
542
+ *
543
+ * Nothing here needs a model or a key. The hooks it finds are the ones the
544
+ * harness would load, so a hook that is present on disk but not registered is
545
+ * absent from the report by construction β€” which is the correct answer, and the
546
+ * one you would not get by globbing `hooks/*.sh`.
547
+ *
548
+ * ⚠️ It RUNS each reachable hook. A hook is a program you did not necessarily
549
+ * write, so point this at a repo whose hooks you are willing to execute, or pass
550
+ * `trusted: false` / `sandbox: "auto"` (inherited from {@link RunHookOptions}) to
551
+ * confine them. `verifyGuardrail` has always had the same property; sweeping a
552
+ * whole plugin makes it worth saying out loud.
553
+ *
554
+ * @experimental Days old, with no consumer outside this repository. The REPORT
555
+ * SHAPE is the part most likely to move β€” specifically whether `not-applicable`
556
+ * stays one status or splits by cause, and whether the per-hook counts stay id
557
+ * arrays. The prefix comes off when that shape survives sweeping several real
558
+ * third-party repos unchanged; see docs/experimental.md.
559
+ *
560
+ * @param dir - the plugin or repo root to read hooks from.
561
+ */
562
+ function experimental_verifyPluginGuards(dir, opts = {}) {
563
+ const adapter = opts.adapter ?? adapter_js_1.claudeCodeAdapter;
564
+ const root = (0, node_path_1.resolve)(dir);
565
+ const battery = selectEvents(opts);
566
+ const eventName = opts.event ?? DEFAULT_EVENT;
567
+ const base = {
568
+ dir: root,
569
+ harness: adapter.name,
570
+ event: eventName,
571
+ events: battery,
572
+ };
573
+ // A harness whose hooks are not shell processes has nothing this tier can
574
+ // drive. Saying so is the `no-silent-skips` half β€” an empty `hooks` list with
575
+ // no note is indistinguishable from "we looked and it was fine".
576
+ if (!adapter.capabilities.shellHooks || !adapter.hookProtocol)
577
+ return {
578
+ ...base,
579
+ hooks: [],
580
+ // The note below already says nothing here is drivable, so enumerating
581
+ // which actions were declared would add no fact a reader could act on.
582
+ unmeasurable: [],
583
+ notes: [
584
+ `n/a β€” ${adapter.name} hooks are not shell processes, so the disaster battery cannot drive them. Nothing was measured.`,
585
+ ],
586
+ };
587
+ const rawHooks = (0, plugin_loader_js_1.loadPlugin)(root, adapter.layout).settings.hooks;
588
+ const regs = (0, hook_normalize_js_1.normalizeHooks)(rawHooks);
589
+ // The actions `normalizeHooks` correctly drops, kept so the sweep can tell
590
+ // "declared nothing" from "declared something I cannot run" β€” see
591
+ // {@link PluginGuardReport.unmeasurable}.
592
+ const unmeasurable = (0, hook_normalize_js_1.nonCommandHookActions)(rawHooks);
593
+ // The two roots the run distinguishes: hooks are READ from `root`, and they
594
+ // RUN against the project β€” the same directory unless the caller sweeps an
595
+ // installed plugin from somewhere else, which is exactly when conflating them
596
+ // would answer for the wrong tree.
597
+ // Decided by the runner's own policy rather than a second copy of it β€” see
598
+ // {@link effectiveCwd}, and the confined-run case it exists for.
599
+ const cwd = effectiveCwd(opts, root);
600
+ // The project-root variables follow the RUN's directory, so the tree the hook
601
+ // is told about is the tree it stands in. A confined run has no such path (the
602
+ // runner mints the directory), and there the swept root is the honest answer:
603
+ // it is what the caller pointed at.
604
+ const project = cwd.kind === "path" ? cwd.dir : root;
605
+ const ctx = {
606
+ battery,
607
+ eventName,
608
+ opts,
609
+ protocol: adapter.hookProtocol,
610
+ env: hookEnvironment(adapter, root, project, eventName, opts),
611
+ cwd,
612
+ };
613
+ const hooks = regs.map((reg, i) => sweepHook(reg, i, ctx));
614
+ return {
615
+ ...base,
616
+ hooks,
617
+ unmeasurable,
618
+ notes: sweepNotes(root, regs, hooks, unmeasurable, eventName),
619
+ };
620
+ }
621
+ // ---------------------------------------------------------------------------
622
+ // Rendering. The one motivating use case for the sweep is "point the battery at
623
+ // YOUR hooks", and a caller who has to fold a discriminated union by hand before
624
+ // he can see that is being handed the library's internals instead of its answer.
625
+ // ---------------------------------------------------------------------------
626
+ /** Longest a command may run in a header line before it is elided. */
627
+ const COMMAND_WIDTH = 68;
628
+ /**
629
+ * Hooks named under one not-measured reason before the rest are counted instead.
630
+ *
631
+ * πŸ”΄ THE ONLY LOSSY STEP IN THIS RENDERER, and it is confined to the half where
632
+ * the payload is the REASON rather than the hook. A repo can register dozens of
633
+ * hooks and most will be irrelevant to a Bash battery, so listing every one of
634
+ * them is a wall the reader skips β€” and the two lines that mattered are skipped
635
+ * with it. Nothing is silently dropped: the group's count is exact, and the tail
636
+ * line says how many more share the reason.
637
+ */
638
+ const HOOKS_PER_REASON = 3;
639
+ /** A command as one short line β€” hooks are shell, and may be long or multi-line. */
640
+ function oneLine(command, width = COMMAND_WIDTH) {
641
+ const flat = command.replace(/\s+/g, " ").trim();
642
+ return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
643
+ }
644
+ /** `n thing` / `n things`, so a count never sits as a bare number beside a noun. */
645
+ function plural(n, noun) {
646
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
647
+ }
648
+ /** How the config selects this hook β€” the three facts the sweep read off it. */
649
+ function hookMeta(hook) {
650
+ const parts = [
651
+ hook.event,
652
+ hook.matcher === null
653
+ ? "no matcher (every tool)"
654
+ : `matcher \`${hook.matcher}\``,
655
+ ];
656
+ if (hook.condition !== null)
657
+ parts.push(`if \`${hook.condition}\``);
658
+ return parts.join(" Β· ");
659
+ }
660
+ /**
661
+ * One measured hook: a headline carrying its count, the selection facts, then one
662
+ * row per battery event in the SAME vocabulary `formatGuardrailReport` prints β€”
663
+ * they share {@link guardrailRow}, so the two reports cannot drift into two
664
+ * spellings of the same three outcomes.
665
+ */
666
+ function measuredBlock(outcome) {
667
+ return [
668
+ ` #${outcome.hook.index} blocks ${outcome.blocked.length}/${outcome.results.length} \`${oneLine(outcome.hook.command)}\``,
669
+ ` ${hookMeta(outcome.hook)}`,
670
+ ...outcome.results.map((r) => ` ${(0, guardrail_check_js_1.guardrailRow)(r)}`),
671
+ ];
672
+ }
673
+ /** The distinct reasons in a set of unmeasured hooks, each with the hooks it covers. */
674
+ function byReason(outcomes) {
675
+ const groups = new Map();
676
+ for (const o of outcomes) {
677
+ const hooks = groups.get(o.reason) ?? [];
678
+ hooks.push(o.hook);
679
+ groups.set(o.reason, hooks);
680
+ }
681
+ return [...groups].map(([reason, hooks]) => ({ reason, hooks }));
682
+ }
683
+ /**
684
+ * One unmeasured status, grouped by reason.
685
+ *
686
+ * πŸ”΄ NO COUNT APPEARS HERE, and that is this half's whole contract: a hook the
687
+ * battery never reached has no score, so printing `0/7` beside it would
688
+ * reproduce β€” in rendering, one layer above the type system β€” exactly the false
689
+ * confidence the discriminated union was built to make unrepresentable. The
690
+ * reason is the payload; the hooks are listed under it.
691
+ */
692
+ function unmeasuredSection(label, outcomes) {
693
+ if (outcomes.length === 0)
694
+ return [];
695
+ const lines = [` ⊘ ${label} β€” ${plural(outcomes.length, "hook")}`];
696
+ for (const group of byReason(outcomes)) {
697
+ lines.push(` ${group.reason}`);
698
+ for (const hook of group.hooks.slice(0, HOOKS_PER_REASON))
699
+ lines.push(` #${hook.index} \`${oneLine(hook.command, 56)}\``);
700
+ const rest = group.hooks.length - HOOKS_PER_REASON;
701
+ if (rest > 0)
702
+ lines.push(` …and ${plural(rest, "more hook")} for this reason`);
703
+ }
704
+ return lines;
705
+ }
706
+ /**
707
+ * The census β€” what was declared and what became of it. Never a score.
708
+ *
709
+ * Omitted entirely when nothing was declared, because a row of zeroes reads as a
710
+ * scoreboard, and the `notes` directly below already say what happened in words.
711
+ * `notes` is guaranteed non-empty in that case: it is empty ONLY when some hook
712
+ * was measured.
713
+ */
714
+ function censusLine(report) {
715
+ if (report.hooks.length === 0)
716
+ return [];
717
+ const count = (status) => report.hooks.filter((h) => h.status === status).length;
718
+ // The non-command actions are counted in the SAME sentence, because a census
719
+ // that silently omits them is how "declared" came to mean "declared a command"
720
+ // without any reader being told.
721
+ const notDrivable = report.unmeasurable.length === 0
722
+ ? ""
723
+ : ` ${report.unmeasurable.length} further action(s) declared are not commands and cannot be driven here.`;
724
+ return [
725
+ `${plural(report.hooks.length, "hook")} declared: ${count("measured")} measured, ${count("unresolved")} unresolved, ${count("not-applicable")} not applicable.${notDrivable}`,
726
+ ];
727
+ }
728
+ /**
729
+ * The declared-but-undrivable actions, rendered like every other unmeasured
730
+ * group: grouped by reason, named, and carrying no count.
731
+ */
732
+ function unmeasurableSection(actions) {
733
+ if (actions.length === 0)
734
+ return [];
735
+ const lines = [
736
+ ` ⊘ not a command β€” ${plural(actions.length, "declared action")}`,
737
+ " this tier spawns a shell, and these actions do not run one, so nothing here has been examined either way",
738
+ ];
739
+ const byType = new Map();
740
+ for (const a of actions) {
741
+ const list = byType.get(a.type);
742
+ if (list === undefined)
743
+ byType.set(a.type, [a]);
744
+ else
745
+ list.push(a);
746
+ }
747
+ for (const [type, group] of [...byType].sort((a, b) => a[0].localeCompare(b[0])))
748
+ lines.push(` ${type} β€” ${plural(group.length, "action")} on ${[...new Set(group.map((a) => a.event))].sort().join(", ")}`);
749
+ return lines;
750
+ }
751
+ /** The closing notes, printed once for the whole sweep rather than per hook. */
752
+ function footer(measured) {
753
+ const lines = [];
754
+ if (measured.some((m) => m.allowed.length > 0))
755
+ lines.push("", "Allows β‰  a bug unless a guard is MEANT to block them β€” gate intent with", "assertBlocksDisasters(cmd, { categories: [...] }).");
756
+ if (measured.some((m) => m.notRun.length > 0))
757
+ lines.push("", "⊘ Some events never reached a hook: its condition does not match them, so it", "cannot protect you there however its body is written.");
758
+ return lines;
759
+ }
760
+ /**
761
+ * Render a {@link PluginGuardReport} as terminal text.
762
+ *
763
+ * ```ts
764
+ * import {
765
+ * experimental_verifyPluginGuards,
766
+ * experimental_formatPluginGuardReport,
767
+ * } from "vigiles";
768
+ *
769
+ * console.log(
770
+ * experimental_formatPluginGuardReport(experimental_verifyPluginGuards(".")),
771
+ * );
772
+ * ```
773
+ *
774
+ * NEUTRAL, the same way {@link formatGuardrailReport} is: it reports what each
775
+ * hook blocks without deciding whether that was the hook's job. A repo's config
776
+ * never says which of its hooks is meant to be a bash-safety guard, so a verdict
777
+ * here would be invented rather than read.
778
+ *
779
+ * πŸ”΄ A HOOK THE BATTERY NEVER REACHED IS NEVER GIVEN A NUMBER. A `measured` hook
780
+ * prints `blocks n/7`; a `not-applicable` or `unresolved` one prints its REASON
781
+ * under a `⊘` heading and no count at all, because a rendered `0/7` is the same
782
+ * false confidence the discriminated union exists to prevent, reintroduced one
783
+ * layer up where the type system can no longer see it. For the same reason the
784
+ * report's `notes` are printed FIRST and in full: a sweep that measured nothing
785
+ * has to say so in words, since an output with no rows reads as a clean bill of
786
+ * health.
787
+ *
788
+ * MANY HOOKS STAY READABLE by grouping the unmeasured half BY REASON β€” a repo
789
+ * with thirty hooks usually has two or three distinct reasons β€” and naming at
790
+ * most {@link HOOKS_PER_REASON} hooks per reason before counting the rest. The
791
+ * measured half is never collapsed: those are the hooks you came for.
792
+ *
793
+ * @experimental It renders {@link PluginGuardReport}, whose SHAPE is the part
794
+ * most likely to move (see {@link experimental_verifyPluginGuards}), so a stable
795
+ * name here would promise a stability its only input does not have. The prefix
796
+ * comes off with the same change that takes it off the report.
797
+ *
798
+ * @param report - a sweep from {@link experimental_verifyPluginGuards}.
799
+ */
800
+ function experimental_formatPluginGuardReport(report) {
801
+ const measured = report.hooks.filter((h) => h.status === "measured");
802
+ const unmeasured = (status) => report.hooks.filter((h) => h.status === status);
803
+ const lines = [
804
+ `Guard sweep of ${report.dir} β€” ${report.harness} Β· ${plural(report.events.length, "dangerous action")} delivered as ${report.event}`,
805
+ ...censusLine(report),
806
+ // The empty case's voice, verbatim and above everything else: `hooks: []`
807
+ // renders as an absence of rows, which is indistinguishable from "we looked
808
+ // and it was fine" unless the words are there to say otherwise.
809
+ ...report.notes.flatMap((note) => ["", `⚠ ${note}`]),
810
+ ];
811
+ if (measured.length > 0)
812
+ lines.push("", "MEASURED", ...measured.flatMap(measuredBlock));
813
+ const notMeasured = [
814
+ ...unmeasuredSection("unresolved", unmeasured("unresolved")),
815
+ ...unmeasuredSection("not applicable", unmeasured("not-applicable")),
816
+ ...unmeasurableSection(report.unmeasurable),
817
+ ];
818
+ if (notMeasured.length > 0)
819
+ lines.push("", "NOT MEASURED β€” nothing below has a score; each group says why", ...notMeasured);
820
+ return [...lines, ...footer(measured)].join("\n");
821
+ }
822
+ //# sourceMappingURL=verify-plugin-guards.js.map