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.
- package/dist/core/bash-effects.d.ts +22 -0
- package/dist/core/bash-effects.js +10 -0
- package/dist/core/command-files.d.ts +107 -0
- package/dist/core/command-files.js +407 -0
- package/dist/core/hook-matcher.d.ts +50 -0
- package/dist/core/hook-matcher.js +77 -2
- package/dist/core/hook-normalize.d.ts +33 -0
- package/dist/core/hook-normalize.js +45 -0
- package/dist/core/shell-vars.d.ts +74 -0
- package/dist/core/shell-vars.js +270 -0
- package/dist/guardrail-check.d.ts +15 -0
- package/dist/guardrail-check.js +43 -9
- package/dist/run-script.d.ts +94 -0
- package/dist/run-script.js +47 -26
- package/dist/test.d.ts +2 -0
- package/dist/test.js +14 -2
- package/dist/verify-plugin-guards.d.ts +194 -0
- package/dist/verify-plugin-guards.js +822 -0
- package/package.json +1 -1
|
@@ -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
|