vigiles 26.0.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/README.md +5 -4
- package/dist/adapters/claude-code/hook-condition.d.ts +46 -0
- package/dist/adapters/claude-code/hook-condition.js +142 -0
- package/dist/adapters/claude-code/hook-protocol.js +5 -0
- package/dist/audit-report.template.html +2 -2
- package/dist/cli.js +67 -115
- 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-condition.d.ts +96 -0
- package/dist/core/hook-condition.js +63 -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 +51 -0
- package/dist/core/hook-normalize.js +61 -1
- package/dist/core/hook-program.d.ts +62 -1
- package/dist/core/hook-program.js +15 -1
- package/dist/core/hook-protocol.d.ts +16 -0
- package/dist/core/linters.js +97 -58
- package/dist/core/shell-vars.d.ts +74 -0
- package/dist/core/shell-vars.js +270 -0
- package/dist/core/skill-resources.d.ts +22 -1
- package/dist/core/skill-resources.js +2 -1
- package/dist/doc-test-script-coverage.d.ts +52 -0
- package/dist/doc-test-script-coverage.js +66 -0
- package/dist/guardrail-check.d.ts +29 -0
- package/dist/guardrail-check.js +69 -10
- package/dist/harness-assert.d.ts +8 -5
- package/dist/harness-assert.js +8 -5
- package/dist/harness-resolve-hooks.mjs +14 -37
- package/dist/hook-state-store.d.ts +143 -0
- package/dist/hook-state-store.js +241 -0
- package/dist/hook.d.ts +3 -1
- package/dist/hook.js +3 -1
- package/dist/run-hook.d.ts +33 -1
- package/dist/run-hook.js +46 -2
- package/dist/run-script.d.ts +94 -0
- package/dist/run-script.js +47 -26
- package/dist/scan-core.js +21 -3
- package/dist/score-core.d.ts +21 -1
- package/dist/score-core.js +30 -6
- package/dist/self-resolve.d.mts +20 -0
- package/dist/self-resolve.mjs +75 -0
- package/dist/spec-hooks.d.mts +10 -0
- package/dist/spec-hooks.mjs +17 -0
- package/dist/test.d.ts +5 -0
- package/dist/test.js +23 -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,407 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Which FILES a shell command hands to a program to execute — the parser-backed
|
|
4
|
+
* answer to "is the guard this hook names even on disk?".
|
|
5
|
+
*
|
|
6
|
+
* 🔴 WHY THIS EXISTS, and it is the loudest lie the sweep could tell. A hook
|
|
7
|
+
* registered as `python3 .claude/hooks/guard.py` whose script is not there does
|
|
8
|
+
* not fail in a way anyone can see: `python3` exits **2** on a file it cannot
|
|
9
|
+
* open, and 2 is Claude Code's DENY code. So `decideHook` reads a block, and
|
|
10
|
+
* `experimental_verifyPluginGuards` reports the hook as blocking every disaster
|
|
11
|
+
* in the battery — a perfect score for a guard that does not exist. Measured on
|
|
12
|
+
* this repo's own build:
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* hook `python3 .claude/hooks/guard.py`, script absent
|
|
16
|
+
* → MEASURED blocks=7/7 exits=2,2,2,2,2,2,2
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* No malformed config is needed to reach it — a relative script path is the
|
|
20
|
+
* commonest shape in the wild — which makes it strictly worse than the
|
|
21
|
+
* uncompilable-matcher lie fixed alongside it, and it is the same false
|
|
22
|
+
* confidence the product sells against, produced by the product.
|
|
23
|
+
*
|
|
24
|
+
* THE EXIT CODE CANNOT DECIDE IT, and that is not a guess: `run-script.ts`
|
|
25
|
+
* already writes down the same fact for its own coverage attribution — "`sh
|
|
26
|
+
* <missing>` exits **2** under dash, which is Claude Code's BLOCK code —
|
|
27
|
+
* indistinguishable from a gate legitimately denying, so it cannot be encoded".
|
|
28
|
+
* The shell's own 126/127 catch a program that never launched; nothing catches a
|
|
29
|
+
* program that launched and could not find its script. That is what this module
|
|
30
|
+
* is for, and why the two halves are both needed rather than either alone.
|
|
31
|
+
*
|
|
32
|
+
* ## What counts as a file reference (MEASURED, not assumed)
|
|
33
|
+
*
|
|
34
|
+
* A word is only reported when it is UNAMBIGUOUSLY a script the command runs:
|
|
35
|
+
* fully resolved (no unexpanded `$`, no substitution, no glob, no whitespace),
|
|
36
|
+
* not a flag, not a `NAME=value`, not a URL, not the inline program text of a
|
|
37
|
+
* `-c`/`-e`, and either the head is a known interpreter or the word carries a
|
|
38
|
+
* script extension UNDER A HEAD THAT COULD EXECUTE IT. The OR is what keeps
|
|
39
|
+
* either list from being load-bearing on its own: an interpreter missing from
|
|
40
|
+
* the table is still caught by the extension, and an extensionless script is
|
|
41
|
+
* still caught by the interpreter.
|
|
42
|
+
*
|
|
43
|
+
* The `UNDER A HEAD` clause is the correction: an extension is evidence about
|
|
44
|
+
* the FILE, not about the command, so on its own it read `rm -f /tmp/stale.sh`
|
|
45
|
+
* — an ordinary cleanup hook whose file is meant to be absent — as a missing
|
|
46
|
+
* script. {@link DATA_ONLY_HEADS} is the set of heads that never execute an
|
|
47
|
+
* operand, and it silences the extension branch for them.
|
|
48
|
+
*
|
|
49
|
+
* The narrowing is measured, not taste. Over the 107 hook registrations in
|
|
50
|
+
* `davila7/claude-code-templates` (the corpus the defect was found in):
|
|
51
|
+
*
|
|
52
|
+
* | rule | hits | wrong |
|
|
53
|
+
* | ------------------------------------------- | ---: | ----: |
|
|
54
|
+
* | any path-shaped operand | 31 | 8 |
|
|
55
|
+
* | interpreter head OR script extension | 23 | 0 |
|
|
56
|
+
*
|
|
57
|
+
* The eight the wide rule got wrong are files a hook WRITES or later reads
|
|
58
|
+
* (`rm ~/.claude/session_start.tmp`, `mv ~/.claude/performance.csv`) and one
|
|
59
|
+
* outright absurdity (`echo N/A`, which contains a slash). Reporting those would
|
|
60
|
+
* be crying wolf on hooks that are perfectly fine, and a check read once and
|
|
61
|
+
* distrusted is a check that is off.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ THE `wrong: 0` ROW IS WHAT THE `DATA_ONLY_HEADS` CLAUSE CORRECTS, and the
|
|
64
|
+
* two measurements are of different corpora — say so rather than quoting one
|
|
65
|
+
* number. `rm -f /tmp/stale.sh` is exactly the eighth shape above wearing a
|
|
66
|
+
* script extension, and it was not in the pinned 107. Re-measured 2026-09-08
|
|
67
|
+
* against today's `davila7/claude-code-templates` (134 registrations, 120
|
|
68
|
+
* distinct commands — the repo has moved since the pin, so this is a second
|
|
69
|
+
* sample, not a reproduction of the first):
|
|
70
|
+
*
|
|
71
|
+
* | rule | hits | lost to the clause |
|
|
72
|
+
* | ------------------------------------------- | ---: | -----------------: |
|
|
73
|
+
* | interpreter head OR script extension | 5 | 0 |
|
|
74
|
+
* | …with the `DATA_ONLY_HEADS` clause | 5 | 0 |
|
|
75
|
+
*
|
|
76
|
+
* All five are true positives reached by an INTERPRETER head or a head that is
|
|
77
|
+
* itself a path, so the extension-under-an-unknown-head branch contributes zero
|
|
78
|
+
* hits on this sample and narrowing it costs nothing measurable. That is the
|
|
79
|
+
* evidence the clause does not trade a real detection for a false-positive fix;
|
|
80
|
+
* it is not evidence the branch is dead, which is why it is kept.
|
|
81
|
+
*
|
|
82
|
+
* CONSERVATIVE IN THE OTHER DIRECTION THAN {@link shellVarReads}, on purpose.
|
|
83
|
+
* Over-reporting a missing FILE costs a real guard its measurement AND accuses
|
|
84
|
+
* its author of shipping a broken hook, so here silence is the safe error: a
|
|
85
|
+
* word this module cannot fully resolve is skipped, never guessed at. The
|
|
86
|
+
* missing-program half of the same question is answered after the fact by the
|
|
87
|
+
* shell's 126/127, so a false negative here is not the last line of defence.
|
|
88
|
+
*/
|
|
89
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
90
|
+
exports.commandFileRefs = commandFileRefs;
|
|
91
|
+
const bash_effects_js_1 = require("./bash-effects.js");
|
|
92
|
+
// mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types —
|
|
93
|
+
// the same require() `core/shell-vars.ts` and `core/bash-effects.ts` use, for
|
|
94
|
+
// the same parser. The shared thing is the dependency, not the code: this module
|
|
95
|
+
// reads WORDS (expanding what it can), which neither of those two models.
|
|
96
|
+
const _sh = require("mvdan-sh");
|
|
97
|
+
const sh = _sh;
|
|
98
|
+
/**
|
|
99
|
+
* Programs whose job is to RUN A FILE named on their command line. Deliberately
|
|
100
|
+
* small: every entry is a language runtime whose first non-flag operand is a
|
|
101
|
+
* script, and nothing else. It is not a closed set and does not need to be — a
|
|
102
|
+
* runtime missing from it is still reached through {@link SCRIPT_EXTENSION}, and
|
|
103
|
+
* a wrong entry can only cost a measurement, never invent a score.
|
|
104
|
+
*/
|
|
105
|
+
const INTERPRETERS = new Set([
|
|
106
|
+
"sh",
|
|
107
|
+
"bash",
|
|
108
|
+
"dash",
|
|
109
|
+
"zsh",
|
|
110
|
+
"ksh",
|
|
111
|
+
"fish",
|
|
112
|
+
"pwsh",
|
|
113
|
+
"powershell",
|
|
114
|
+
"python",
|
|
115
|
+
"python2",
|
|
116
|
+
"python3",
|
|
117
|
+
"node",
|
|
118
|
+
"deno",
|
|
119
|
+
"bun",
|
|
120
|
+
"ruby",
|
|
121
|
+
"perl",
|
|
122
|
+
"php",
|
|
123
|
+
"lua",
|
|
124
|
+
"Rscript",
|
|
125
|
+
"osascript",
|
|
126
|
+
"tsx",
|
|
127
|
+
"ts-node",
|
|
128
|
+
]);
|
|
129
|
+
/** Extensions that name a script whatever runs it. */
|
|
130
|
+
const SCRIPT_EXTENSION = /\.(sh|bash|zsh|fish|ps1|py|js|cjs|mjs|ts|mts|cts|rb|pl|php|lua|R|applescript|scpt)$/;
|
|
131
|
+
/**
|
|
132
|
+
* Heads whose operands are DATA — they name a file, they never run one.
|
|
133
|
+
*
|
|
134
|
+
* 🔴 THE EXTENSION RULE NEEDS A HEAD, and without one it accused working hooks.
|
|
135
|
+
* `SCRIPT_EXTENSION` is evidence about the FILE ("this is a script"), not about
|
|
136
|
+
* the COMMAND ("this command executes it"), and on its own it reported
|
|
137
|
+
* `rm -f /tmp/stale.sh` — an ordinary cleanup hook — as running a file that is
|
|
138
|
+
* INTENTIONALLY absent. The whole justification for the extension branch is "the
|
|
139
|
+
* head might be an interpreter we did not list", so it must not fire for a head
|
|
140
|
+
* we DID list and know is not one.
|
|
141
|
+
*
|
|
142
|
+
* The reach is deliberately asymmetric with {@link INTERPRETERS}. A missing
|
|
143
|
+
* entry there costs a measurement; a missing entry HERE costs a false
|
|
144
|
+
* accusation, which the module header names as the error it will not make. So
|
|
145
|
+
* the set is generous: `git bisect run g.sh` really does execute its operand and
|
|
146
|
+
* is silenced by the `git` entry, and that is the correct trade — silence is
|
|
147
|
+
* this module's safe error, and the shell's own 127 still answers after the
|
|
148
|
+
* fact.
|
|
149
|
+
*
|
|
150
|
+
* A head that is neither here nor in `INTERPRETERS` is unknown, and the
|
|
151
|
+
* extension rule still speaks for it.
|
|
152
|
+
*/
|
|
153
|
+
const DATA_ONLY_HEADS = new Set([
|
|
154
|
+
// Naming or moving a file, never executing it.
|
|
155
|
+
"rm",
|
|
156
|
+
"mv",
|
|
157
|
+
"cp",
|
|
158
|
+
"ln",
|
|
159
|
+
"touch",
|
|
160
|
+
"mkdir",
|
|
161
|
+
"rmdir",
|
|
162
|
+
"chmod",
|
|
163
|
+
"chown",
|
|
164
|
+
"chgrp",
|
|
165
|
+
"stat",
|
|
166
|
+
"ls",
|
|
167
|
+
"shred",
|
|
168
|
+
"truncate",
|
|
169
|
+
"install",
|
|
170
|
+
"realpath",
|
|
171
|
+
"dirname",
|
|
172
|
+
"basename",
|
|
173
|
+
// Reading or writing its contents.
|
|
174
|
+
"cat",
|
|
175
|
+
"head",
|
|
176
|
+
"tail",
|
|
177
|
+
"echo",
|
|
178
|
+
"printf",
|
|
179
|
+
"tee",
|
|
180
|
+
"wc",
|
|
181
|
+
"sort",
|
|
182
|
+
"uniq",
|
|
183
|
+
"cut",
|
|
184
|
+
"tr",
|
|
185
|
+
"diff",
|
|
186
|
+
"cmp",
|
|
187
|
+
"grep",
|
|
188
|
+
"egrep",
|
|
189
|
+
"fgrep",
|
|
190
|
+
"rg",
|
|
191
|
+
"ag",
|
|
192
|
+
"sed",
|
|
193
|
+
"jq",
|
|
194
|
+
"yq",
|
|
195
|
+
"md5sum",
|
|
196
|
+
"sha256sum",
|
|
197
|
+
// Moving it somewhere else.
|
|
198
|
+
"tar",
|
|
199
|
+
"zip",
|
|
200
|
+
"unzip",
|
|
201
|
+
"gzip",
|
|
202
|
+
"gunzip",
|
|
203
|
+
"scp",
|
|
204
|
+
"rsync",
|
|
205
|
+
"curl",
|
|
206
|
+
"wget",
|
|
207
|
+
"git",
|
|
208
|
+
]);
|
|
209
|
+
/**
|
|
210
|
+
* Flags whose VALUE is not a path — program text (`sh -c '…'`, `node -e '…'`)
|
|
211
|
+
* or a module name (`python3 -m json.tool`). The operand after one of these is
|
|
212
|
+
* something the shell never looks for on disk, so testing it as a file would be
|
|
213
|
+
* a category error. (Most program bodies are already excluded by carrying
|
|
214
|
+
* whitespace; this covers the one-word ones, e.g. `bash -c exit`.)
|
|
215
|
+
*/
|
|
216
|
+
const NON_PATH_FLAGS = new Set([
|
|
217
|
+
"-c",
|
|
218
|
+
"-e",
|
|
219
|
+
"-E",
|
|
220
|
+
"-m",
|
|
221
|
+
"--command",
|
|
222
|
+
"--eval",
|
|
223
|
+
"--module",
|
|
224
|
+
]);
|
|
225
|
+
/**
|
|
226
|
+
* Anything that makes a word something other than one plain filename: shell or
|
|
227
|
+
* glob syntax, whitespace, a quote that survived expansion. A word carrying any
|
|
228
|
+
* of these is not resolvable to a single path here, so it is skipped.
|
|
229
|
+
*/
|
|
230
|
+
const NOT_A_PLAIN_PATH = /[*?[\]{}()$`|;&<>\s'"\\]/;
|
|
231
|
+
/** A word's text with the variables we know expanded, or null when we cannot. */
|
|
232
|
+
function wordText(word, values) {
|
|
233
|
+
let text = "";
|
|
234
|
+
for (const part of word?.Parts ?? []) {
|
|
235
|
+
const kind = sh.syntax.NodeType(part);
|
|
236
|
+
if (kind === "Lit" || kind === "SglQuoted")
|
|
237
|
+
text += part.Value ?? "";
|
|
238
|
+
else if (kind === "DblQuoted") {
|
|
239
|
+
const inner = wordText(part, values);
|
|
240
|
+
if (inner === null)
|
|
241
|
+
return null;
|
|
242
|
+
text += inner;
|
|
243
|
+
}
|
|
244
|
+
else if (kind === "ParamExp") {
|
|
245
|
+
// A parameter whose value we do not know makes the WHOLE word unresolved.
|
|
246
|
+
// Substituting a placeholder would manufacture a path and then report it
|
|
247
|
+
// missing — an accusation built out of our own guess.
|
|
248
|
+
const name = part.Param?.Value;
|
|
249
|
+
const value = name === undefined ? undefined : values[name];
|
|
250
|
+
if (value === undefined)
|
|
251
|
+
return null;
|
|
252
|
+
text += value;
|
|
253
|
+
}
|
|
254
|
+
else
|
|
255
|
+
return null; // command substitution, arithmetic, process subst, …
|
|
256
|
+
}
|
|
257
|
+
return text;
|
|
258
|
+
}
|
|
259
|
+
/** Could this word be a filename at all — before asking whose script it is? */
|
|
260
|
+
function isPlainPath(word) {
|
|
261
|
+
if (word === "")
|
|
262
|
+
return false;
|
|
263
|
+
if (word.startsWith("-"))
|
|
264
|
+
return false;
|
|
265
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))
|
|
266
|
+
return false;
|
|
267
|
+
if (word.includes("://"))
|
|
268
|
+
return false;
|
|
269
|
+
return !NOT_A_PLAIN_PATH.test(word);
|
|
270
|
+
}
|
|
271
|
+
/** The last path segment, for matching a head like `/usr/bin/python3`. */
|
|
272
|
+
function basename(head) {
|
|
273
|
+
const cut = head.lastIndexOf("/");
|
|
274
|
+
return cut === -1 ? head : head.slice(cut + 1);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* A word this module could not resolve, standing in for it while the wrapper
|
|
278
|
+
* prefix is measured. Chosen so it can be neither a wrapper head, a flag, nor an
|
|
279
|
+
* `env` assignment word: an unresolvable word therefore STOPS the unwrapping
|
|
280
|
+
* rather than being unwrapped through, which is the conservative direction.
|
|
281
|
+
*/
|
|
282
|
+
const UNRESOLVED_WORD = "\u0000";
|
|
283
|
+
/**
|
|
284
|
+
* The script references of one simple command.
|
|
285
|
+
*
|
|
286
|
+
* TWO independent reasons a word is a script, and the difference in how far each
|
|
287
|
+
* reaches is deliberate. A SCRIPT EXTENSION speaks for itself, so any operand
|
|
288
|
+
* carrying one is reported. An INTERPRETER HEAD speaks only for its FIRST
|
|
289
|
+
* non-flag operand — everything after that is the script's own arguments, and
|
|
290
|
+
* reporting `bash hooks/g.sh production` as naming a missing file `production`
|
|
291
|
+
* would accuse a working hook.
|
|
292
|
+
*
|
|
293
|
+
* 🔴 AND THE HEAD IS THE REAL HEAD, NOT THE WRAPPER. `env python3 guard` hands
|
|
294
|
+
* the script to `python3`, but reading `args[0]` finds `env` — not an
|
|
295
|
+
* interpreter — and `guard` carries no extension, so the command was reported as
|
|
296
|
+
* naming NO file. That is the quiet half of this module's own opening: the
|
|
297
|
+
* pre-flight sees a clean command, runs it, python cannot open `guard`, exits 2,
|
|
298
|
+
* and the sweep prints a perfect score for a guard that never ran. Which words
|
|
299
|
+
* are a pass-through wrapper is {@link stripWrappers}'s question and it already
|
|
300
|
+
* answers it for the effect classifier (`env`, `command`, `nice`, `timeout`,
|
|
301
|
+
* `sudo`, `xargs`, `nohup`, recursively, skipping each wrapper's own options and
|
|
302
|
+
* `NAME=value` words) — so this ASKS it rather than keeping a second list that
|
|
303
|
+
* would fall behind the first.
|
|
304
|
+
*
|
|
305
|
+
* ⚠️ NAMED, NOT FIXED: `exec` is a pass-through the shared list does not carry,
|
|
306
|
+
* so `exec python3 guard` still reports nothing here — and `stripWrappers` has
|
|
307
|
+
* its own recorded remainder, an escaped head behind a wrapper (`sudo g\it push`).
|
|
308
|
+
* Adding either belongs in that module, where the safety matcher reads the same
|
|
309
|
+
* list, not in a private copy here.
|
|
310
|
+
*
|
|
311
|
+
* ⚠️ A WRAPPER THAT CHANGES DIRECTORY REPORTS NOTHING. `env -C /elsewhere python3
|
|
312
|
+
* guard` runs `guard` relative to a directory this module was not told about, so
|
|
313
|
+
* every relative operand would be tested against the wrong tree. Saying nothing
|
|
314
|
+
* costs the hook its measurement; naming a path we cannot resolve would accuse a
|
|
315
|
+
* working guard.
|
|
316
|
+
*/
|
|
317
|
+
function leafRefs(allArgs, values, into) {
|
|
318
|
+
// Basenamed for the probe only, because that is the form the shared list keys
|
|
319
|
+
// on (`bash-effects` reduces a head to its basename before asking), so
|
|
320
|
+
// `/usr/bin/env python3 guard` unwraps like the bare spelling. Flags and
|
|
321
|
+
// `NAME=value` words are passed through untouched — they are what tells
|
|
322
|
+
// `stripWrappers` which words a wrapper consumes. The result is used ONLY to
|
|
323
|
+
// count the prefix, so the words themselves are read from `allArgs` after.
|
|
324
|
+
const texts = allArgs.map((w) => {
|
|
325
|
+
const text = wordText(w, values);
|
|
326
|
+
if (text === null)
|
|
327
|
+
return UNRESOLVED_WORD;
|
|
328
|
+
return text.startsWith("-") || text.includes("=") ? text : basename(text);
|
|
329
|
+
});
|
|
330
|
+
const stripped = (0, bash_effects_js_1.stripWrappers)(texts);
|
|
331
|
+
if (stripped.chdir !== null)
|
|
332
|
+
return;
|
|
333
|
+
const args = allArgs.slice(texts.length - stripped.argv.length);
|
|
334
|
+
const head = wordText(args[0], values);
|
|
335
|
+
const headName = head === null ? null : basename(head);
|
|
336
|
+
let interpreterOperand = headName !== null && INTERPRETERS.has(headName) ? "pending" : "no";
|
|
337
|
+
// The extension rule stands in for an interpreter we failed to list, so it
|
|
338
|
+
// speaks only where the head could BE one. A head we know hands its operands
|
|
339
|
+
// to nothing silences it; an unknown head still carries it.
|
|
340
|
+
const headMayExecute = headName === null || !DATA_ONLY_HEADS.has(headName);
|
|
341
|
+
let skipNext = false;
|
|
342
|
+
for (const word of args.slice(1)) {
|
|
343
|
+
const text = wordText(word, values);
|
|
344
|
+
if (skipNext) {
|
|
345
|
+
skipNext = false;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (text !== null && NON_PATH_FLAGS.has(text)) {
|
|
349
|
+
skipNext = true;
|
|
350
|
+
// The interpreter is running program text or a module, not a file: it has
|
|
351
|
+
// no script operand left to name.
|
|
352
|
+
interpreterOperand = "no";
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (text !== null && text.startsWith("-"))
|
|
356
|
+
continue;
|
|
357
|
+
const script = text !== null &&
|
|
358
|
+
isPlainPath(text) &&
|
|
359
|
+
((headMayExecute && SCRIPT_EXTENSION.test(text)) ||
|
|
360
|
+
interpreterOperand === "pending");
|
|
361
|
+
// The first non-flag operand IS the interpreter's script, whether or not we
|
|
362
|
+
// could resolve it — everything after it belongs to the script, not to us.
|
|
363
|
+
if (interpreterOperand === "pending")
|
|
364
|
+
interpreterOperand = "no";
|
|
365
|
+
if (script && text !== null)
|
|
366
|
+
into.add(text);
|
|
367
|
+
}
|
|
368
|
+
// A head that is itself a path (`./hooks/guard.sh`, `"$ROOT"/g.sh`) is a file
|
|
369
|
+
// the shell must find. A BARE head is deliberately not reported: a shell
|
|
370
|
+
// builtin or function (`echo`, `true`, `command`) has no file at all, and
|
|
371
|
+
// calling those missing would flag most hooks in the corpus. The shell's own
|
|
372
|
+
// exit 127 answers that half after the fact.
|
|
373
|
+
if (head !== null && head.includes("/") && !NOT_A_PLAIN_PATH.test(head))
|
|
374
|
+
into.add(head);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The files `command` would hand to a program to execute.
|
|
378
|
+
*
|
|
379
|
+
* @param command - the shell command, exactly as the hook registers it.
|
|
380
|
+
* @param values - variables whose value is known at run time, for expansion. A
|
|
381
|
+
* word naming anything absent from this map is skipped, not guessed.
|
|
382
|
+
*/
|
|
383
|
+
function commandFileRefs(command, values = {}) {
|
|
384
|
+
let file;
|
|
385
|
+
try {
|
|
386
|
+
file = sh.syntax.NewParser().Parse(command, "hook.sh");
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return { refs: [], parsed: false };
|
|
390
|
+
}
|
|
391
|
+
const refs = new Set();
|
|
392
|
+
sh.syntax.Walk(file, (node) => {
|
|
393
|
+
const kind = sh.syntax.NodeType(node);
|
|
394
|
+
// 🔴 NEVER DESCEND INTO A SUBSTITUTION. `bash $(which guard.sh)` runs
|
|
395
|
+
// `which`, which does not execute `guard.sh` — it prints where it lives. The
|
|
396
|
+
// inner command's operands are the OUTER command's data, so reading them as
|
|
397
|
+
// files it runs invents a reference nobody named. Returning false stops the
|
|
398
|
+
// walk at this node, which is the only reason the callback returns a boolean.
|
|
399
|
+
if (kind === "CmdSubst" || kind === "ProcSubst")
|
|
400
|
+
return false;
|
|
401
|
+
if (kind === "CallExpr" && node.Args)
|
|
402
|
+
leafRefs(node.Args, values, refs);
|
|
403
|
+
return true;
|
|
404
|
+
});
|
|
405
|
+
return { refs: [...refs], parsed: true };
|
|
406
|
+
}
|
|
407
|
+
//# sourceMappingURL=command-files.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOOK CONDITIONS — the neutral shape of "this hook only runs when the tool call
|
|
3
|
+
* matches", and the reason `verifyGuardrail` can no longer fabricate a green.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 THE DEFECT THIS EXISTS FOR, measured 2026-09-07. Claude Code hooks support a
|
|
6
|
+
* per-action `if` field: a permission-rule pattern (`"Bash(git push *--force*)"`)
|
|
7
|
+
* that decides whether the hook command is spawned AT ALL. vigiles knew nothing
|
|
8
|
+
* about it — `normalizeHooks` dropped the key and `runHook` took a bare command
|
|
9
|
+
* string — so a real published guard
|
|
10
|
+
* (test/dogfood/davila7-force-push-blocker@869640b) whose body is an
|
|
11
|
+
* unconditional `echo …"permissionDecision":"deny"…` was reported by
|
|
12
|
+
* `verifyGuardrail` as blocking **7/7** of the disaster battery, including
|
|
13
|
+
* `rm -rf /`, `cat ~/.ssh/id_rsa` and `curl | sh` — actions its `if` means it can
|
|
14
|
+
* never even see. That is the FALSE CONFIDENCE class this product sells against,
|
|
15
|
+
* produced by the product's own verifier.
|
|
16
|
+
*
|
|
17
|
+
* WHY EVALUATE RATHER THAN ABSTAIN. Three options were on the table: evaluate the
|
|
18
|
+
* condition, report every conditional hook as "unknown", or refuse. Evaluating
|
|
19
|
+
* wins because the question is DECIDABLE on exactly the inputs that matter — a
|
|
20
|
+
* static command with resolvable command names, which is what a battery feeds —
|
|
21
|
+
* and because the harness's own matcher is CONSERVATIVE: where Claude Code cannot
|
|
22
|
+
* tell, it RUNS the hook. Mirroring that means uncertainty can only ever ADD a
|
|
23
|
+
* run, never subtract one, so this can turn a true green into a miss in no case
|
|
24
|
+
* (no crying wolf) while still refusing to invent the green the davila7 hook got.
|
|
25
|
+
* "Unknown" would have degraded a genuinely matching guard's true result, and
|
|
26
|
+
* refusing would break every existing caller.
|
|
27
|
+
*
|
|
28
|
+
* WHAT IS NEUTRAL AND WHAT IS NOT. That a hook may carry a condition, and that a
|
|
29
|
+
* condition is only meaningful on the events the harness evaluates it on, are
|
|
30
|
+
* neutral facts modelled here. The GRAMMAR (`Tool(pattern)`, glob semantics, how
|
|
31
|
+
* a shell command is decomposed before matching) is Claude Code's permission-rule
|
|
32
|
+
* syntax and lives in its adapter, reached through {@link HookProtocol.condition}.
|
|
33
|
+
* A harness with no such feature simply leaves the field unset and every hook is
|
|
34
|
+
* unconditional — the behaviour every consumer had before this existed.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The tool call a condition is evaluated against — the parts of a hook event that
|
|
38
|
+
* can decide whether the hook runs.
|
|
39
|
+
*/
|
|
40
|
+
export interface HookConditionCall {
|
|
41
|
+
/** The hook event name, e.g. `"PreToolUse"`. */
|
|
42
|
+
readonly event: string;
|
|
43
|
+
/** The tool being called, e.g. `"Bash"`. */
|
|
44
|
+
readonly tool: string;
|
|
45
|
+
/** The tool's input payload (`tool_input`) — the command, path, … */
|
|
46
|
+
readonly input: Readonly<Record<string, unknown>>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether a conditional hook runs for one call, and the reason.
|
|
50
|
+
*
|
|
51
|
+
* Both fields are always present, so there is no state where a caller reads a
|
|
52
|
+
* verdict without knowing why — the reason is what a guardrail report needs in
|
|
53
|
+
* order to say "not run" instead of the indistinguishable "did not block".
|
|
54
|
+
*/
|
|
55
|
+
export interface HookConditionVerdict {
|
|
56
|
+
/** True iff the harness would spawn the hook for this call. */
|
|
57
|
+
readonly runs: boolean;
|
|
58
|
+
/** One line naming why — rendered by the guardrail report. */
|
|
59
|
+
readonly why: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A harness's support for hook conditions — carried on {@link HookProtocol} as an
|
|
63
|
+
* optional, additive field. Absent ⇒ the harness has no such feature and every
|
|
64
|
+
* hook is unconditional.
|
|
65
|
+
*/
|
|
66
|
+
export interface HookConditionSupport {
|
|
67
|
+
/**
|
|
68
|
+
* The config key the condition is written under (Claude Code: `"if"`). Used to
|
|
69
|
+
* read it out of raw settings and to name it in a report; a test binds this to
|
|
70
|
+
* the key `normalizeHooks` actually reads, so the two cannot drift.
|
|
71
|
+
*/
|
|
72
|
+
readonly field: string;
|
|
73
|
+
/**
|
|
74
|
+
* The events on which the harness evaluates the condition. A hook that declares
|
|
75
|
+
* a condition and registers on any OTHER event never runs at all — so the list
|
|
76
|
+
* is not decoration, it decides.
|
|
77
|
+
*/
|
|
78
|
+
readonly evaluatedOn: readonly string[];
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether a hook declaring `condition` runs for this call. MUST be
|
|
81
|
+
* conservative in the direction of `runs: true` — an undecidable input is a
|
|
82
|
+
* reason to run the hook, never a reason to claim it was skipped.
|
|
83
|
+
*/
|
|
84
|
+
matches(condition: string, call: HookConditionCall): HookConditionVerdict;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The one entry point a runner uses: apply a harness's condition support to a
|
|
88
|
+
* call, including the event rule the port's `evaluatedOn` list implies.
|
|
89
|
+
*
|
|
90
|
+
* `undefined`/empty condition ⇒ unconditional (runs). No support declared ⇒ the
|
|
91
|
+
* harness has no conditions, so a hook that carries one is not something we can
|
|
92
|
+
* reason about; we RUN it, which is the pre-existing behaviour and the safe
|
|
93
|
+
* direction (see the module header on why uncertainty must only add runs).
|
|
94
|
+
*/
|
|
95
|
+
export declare function decideHookCondition(condition: string | null | undefined, call: HookConditionCall, support: HookConditionSupport | undefined): HookConditionVerdict;
|
|
96
|
+
//# sourceMappingURL=hook-condition.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HOOK CONDITIONS — the neutral shape of "this hook only runs when the tool call
|
|
4
|
+
* matches", and the reason `verifyGuardrail` can no longer fabricate a green.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 THE DEFECT THIS EXISTS FOR, measured 2026-09-07. Claude Code hooks support a
|
|
7
|
+
* per-action `if` field: a permission-rule pattern (`"Bash(git push *--force*)"`)
|
|
8
|
+
* that decides whether the hook command is spawned AT ALL. vigiles knew nothing
|
|
9
|
+
* about it — `normalizeHooks` dropped the key and `runHook` took a bare command
|
|
10
|
+
* string — so a real published guard
|
|
11
|
+
* (test/dogfood/davila7-force-push-blocker@869640b) whose body is an
|
|
12
|
+
* unconditional `echo …"permissionDecision":"deny"…` was reported by
|
|
13
|
+
* `verifyGuardrail` as blocking **7/7** of the disaster battery, including
|
|
14
|
+
* `rm -rf /`, `cat ~/.ssh/id_rsa` and `curl | sh` — actions its `if` means it can
|
|
15
|
+
* never even see. That is the FALSE CONFIDENCE class this product sells against,
|
|
16
|
+
* produced by the product's own verifier.
|
|
17
|
+
*
|
|
18
|
+
* WHY EVALUATE RATHER THAN ABSTAIN. Three options were on the table: evaluate the
|
|
19
|
+
* condition, report every conditional hook as "unknown", or refuse. Evaluating
|
|
20
|
+
* wins because the question is DECIDABLE on exactly the inputs that matter — a
|
|
21
|
+
* static command with resolvable command names, which is what a battery feeds —
|
|
22
|
+
* and because the harness's own matcher is CONSERVATIVE: where Claude Code cannot
|
|
23
|
+
* tell, it RUNS the hook. Mirroring that means uncertainty can only ever ADD a
|
|
24
|
+
* run, never subtract one, so this can turn a true green into a miss in no case
|
|
25
|
+
* (no crying wolf) while still refusing to invent the green the davila7 hook got.
|
|
26
|
+
* "Unknown" would have degraded a genuinely matching guard's true result, and
|
|
27
|
+
* refusing would break every existing caller.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IS NEUTRAL AND WHAT IS NOT. That a hook may carry a condition, and that a
|
|
30
|
+
* condition is only meaningful on the events the harness evaluates it on, are
|
|
31
|
+
* neutral facts modelled here. The GRAMMAR (`Tool(pattern)`, glob semantics, how
|
|
32
|
+
* a shell command is decomposed before matching) is Claude Code's permission-rule
|
|
33
|
+
* syntax and lives in its adapter, reached through {@link HookProtocol.condition}.
|
|
34
|
+
* A harness with no such feature simply leaves the field unset and every hook is
|
|
35
|
+
* unconditional — the behaviour every consumer had before this existed.
|
|
36
|
+
*/
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.decideHookCondition = decideHookCondition;
|
|
39
|
+
/**
|
|
40
|
+
* The one entry point a runner uses: apply a harness's condition support to a
|
|
41
|
+
* call, including the event rule the port's `evaluatedOn` list implies.
|
|
42
|
+
*
|
|
43
|
+
* `undefined`/empty condition ⇒ unconditional (runs). No support declared ⇒ the
|
|
44
|
+
* harness has no conditions, so a hook that carries one is not something we can
|
|
45
|
+
* reason about; we RUN it, which is the pre-existing behaviour and the safe
|
|
46
|
+
* direction (see the module header on why uncertainty must only add runs).
|
|
47
|
+
*/
|
|
48
|
+
function decideHookCondition(condition, call, support) {
|
|
49
|
+
if (condition === null || condition === undefined || condition.trim() === "")
|
|
50
|
+
return { runs: true, why: "no condition declared" };
|
|
51
|
+
if (!support)
|
|
52
|
+
return {
|
|
53
|
+
runs: true,
|
|
54
|
+
why: "this harness declares no hook-condition support — treated as unconditional",
|
|
55
|
+
};
|
|
56
|
+
if (!support.evaluatedOn.includes(call.event))
|
|
57
|
+
return {
|
|
58
|
+
runs: false,
|
|
59
|
+
why: `\`${support.field}\` is only evaluated on ${support.evaluatedOn.join("/")}, not ${call.event} — the hook never runs`,
|
|
60
|
+
};
|
|
61
|
+
return support.matches(condition, call);
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=hook-condition.js.map
|
|
@@ -105,4 +105,54 @@ export interface HookMatcherEntry {
|
|
|
105
105
|
* correct.
|
|
106
106
|
*/
|
|
107
107
|
export declare function hookMatcherIssues(entries: readonly HookMatcherEntry[], declaredServers: readonly string[], dialect: HarnessDialect): HookMatcherFinding[];
|
|
108
|
+
/**
|
|
109
|
+
* How a matcher relates to one tool call — the answer a boolean could not carry.
|
|
110
|
+
*
|
|
111
|
+
* - `"selects"` — the harness spawns the hook for this call.
|
|
112
|
+
* - `"misses"` — it does not, because the matcher names something else.
|
|
113
|
+
* - `"uncompilable"` — it does not, because the harness cannot BUILD the matcher.
|
|
114
|
+
*
|
|
115
|
+
* The last two both mean "the hook does not run", and a caller that only asks
|
|
116
|
+
* `=== "selects"` still lands on the safe answer; they are separate because only
|
|
117
|
+
* the third is a DEFECT worth naming in a report.
|
|
118
|
+
*/
|
|
119
|
+
export type MatcherReach = "selects" | "misses" | "uncompilable";
|
|
120
|
+
/**
|
|
121
|
+
* Would this matcher select a call to `tool` — i.e. does the harness spawn the
|
|
122
|
+
* hook at all?
|
|
123
|
+
*
|
|
124
|
+
* The same two MEASURED facts the module header pins, asked as a question rather
|
|
125
|
+
* than as a defect: on a harness whose matchers are tool names (Claude Code), a
|
|
126
|
+
* matcher with no regex metacharacter is compared by string EQUALITY and one
|
|
127
|
+
* with metacharacters is an UNANCHORED regex. It lives here and not in the
|
|
128
|
+
* caller so those semantics have one home (one-detector-no-drift) —
|
|
129
|
+
* `hookMatcherIssues` judges a matcher, this one applies it.
|
|
130
|
+
*
|
|
131
|
+
* FAIL-OPEN WHERE THE HARNESS IS, AND NOT ONE STEP FURTHER. An absent matcher or
|
|
132
|
+
* a match-all really does select every tool, so answering `"selects"` there
|
|
133
|
+
* states a fact — the same direction `decideHookCondition`
|
|
134
|
+
* (`core/hook-condition.ts`) fails open, and for the same reason it gives: where
|
|
135
|
+
* Claude Code cannot tell, it RUNS the hook, so mirroring it can only ever add a
|
|
136
|
+
* run, never invent a skip.
|
|
137
|
+
*
|
|
138
|
+
* 🔴 THAT REASONING DOES NOT REACH AN UNCOMPILABLE MATCHER, and this function
|
|
139
|
+
* used to apply it there anyway. `Bash(` is what the `invalid-regex` finding
|
|
140
|
+
* above already reports as "the harness can't compile it, so the hook never
|
|
141
|
+
* fires" — the harness fails CLOSED. Answering `"selects"` therefore does not
|
|
142
|
+
* add a run the harness makes, it MANUFACTURES one: a caller feeds the hook a
|
|
143
|
+
* battery it would never have been handed, and an unconditional-deny body scores
|
|
144
|
+
* a full pass for a hook that cannot run. That is the false-confidence class
|
|
145
|
+
* this module exists to remove, so an uncompilable matcher gets its own answer
|
|
146
|
+
* and the caller declines to score it.
|
|
147
|
+
*
|
|
148
|
+
* @param matcher - the registration's matcher, or `null` when it declares none.
|
|
149
|
+
* @param tool - the tool named by the call, e.g. `"Bash"`.
|
|
150
|
+
* @param style - the active harness's `HookProtocol.matcherStyle`. `"exact"`
|
|
151
|
+
* (the default, Claude Code) applies the literal-equality rule above;
|
|
152
|
+
* `"regex"` (Codex) compiles EVERY matcher, so `ash` matches `Bash` and the
|
|
153
|
+
* glob spellings `*` / `**` — which are Claude Code's documented match-all,
|
|
154
|
+
* not regexes — come back `"uncompilable"` rather than being assumed to be
|
|
155
|
+
* special-cased by a harness nobody measured.
|
|
156
|
+
*/
|
|
157
|
+
export declare function hookMatcherReach(matcher: string | null, tool: string, style?: "exact" | "regex"): MatcherReach;
|
|
108
158
|
//# sourceMappingURL=hook-matcher.d.ts.map
|