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.
@@ -144,6 +144,28 @@ export declare const SHORT_TO_LONG: Readonly<Record<string, string>>;
144
144
  export declare const LONG_TO_SHORT: Readonly<Record<string, string>>;
145
145
  /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
146
146
  export declare const WRAPPER_HEADS: Set<string>;
147
+ /**
148
+ * Resolve a wrapped command through one or more wrapper layers. Given a leaf's
149
+ * full normalized argv (`[head, ...args]`), returns the effective argv of the
150
+ * REAL command plus any `NAME=value` words an `env` wrapper consumed (so the
151
+ * caller can fold them into the leaf's assignment map). If the head is not a
152
+ * wrapper, or the wrapper has no following command, the argv is returned
153
+ * unchanged with an empty assignment set.
154
+ *
155
+ * @internal — the SINGLE answer to "which word is the real command head". Read
156
+ * by `bash-equivalents.ts` (to generate wrapper-prefixed spellings) and by
157
+ * `core/command-files.ts` (to find the interpreter behind a wrapper, so
158
+ * `env python3 guard` names its script). That second reader had its own idea of
159
+ * what a wrapper was — namely none — so it reported no script at all for the
160
+ * wrapped form, and a missing script that is never named is a hook that runs,
161
+ * exits 2, and is scored as a block. One list, or the next wrapper is missed in
162
+ * whichever copy nobody remembered.
163
+ */
164
+ export declare function stripWrappers(argv: readonly string[]): {
165
+ argv: readonly string[];
166
+ envAssigns: Map<string, string | null>;
167
+ chdir: string | null;
168
+ };
147
169
  /**
148
170
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
149
171
  * twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested
@@ -28,6 +28,7 @@ exports.WRAPPER_HEADS = exports.LONG_TO_SHORT = exports.SHORT_TO_LONG = void 0;
28
28
  exports.classifyBashCommand = classifyBashCommand;
29
29
  exports.isReadOnlyBash = isReadOnlyBash;
30
30
  exports.leafCommands = leafCommands;
31
+ exports.stripWrappers = stripWrappers;
31
32
  exports.leafCommandsNormalized = leafCommandsNormalized;
32
33
  exports.leafArgvSource = leafArgvSource;
33
34
  exports.commandWords = commandWords;
@@ -698,6 +699,15 @@ function splitAssignmentWord(word) {
698
699
  * caller can fold them into the leaf's assignment map). If the head is not a
699
700
  * wrapper, or the wrapper has no following command, the argv is returned
700
701
  * unchanged with an empty assignment set.
702
+ *
703
+ * @internal — the SINGLE answer to "which word is the real command head". Read
704
+ * by `bash-equivalents.ts` (to generate wrapper-prefixed spellings) and by
705
+ * `core/command-files.ts` (to find the interpreter behind a wrapper, so
706
+ * `env python3 guard` names its script). That second reader had its own idea of
707
+ * what a wrapper was — namely none — so it reported no script at all for the
708
+ * wrapped form, and a missing script that is never named is a hook that runs,
709
+ * exits 2, and is scored as a block. One list, or the next wrapper is missed in
710
+ * whichever copy nobody remembered.
701
711
  */
702
712
  function stripWrappers(argv) {
703
713
  const envAssigns = new Map();
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Which FILES a shell command hands to a program to execute — the parser-backed
3
+ * answer to "is the guard this hook names even on disk?".
4
+ *
5
+ * 🔴 WHY THIS EXISTS, and it is the loudest lie the sweep could tell. A hook
6
+ * registered as `python3 .claude/hooks/guard.py` whose script is not there does
7
+ * not fail in a way anyone can see: `python3` exits **2** on a file it cannot
8
+ * open, and 2 is Claude Code's DENY code. So `decideHook` reads a block, and
9
+ * `experimental_verifyPluginGuards` reports the hook as blocking every disaster
10
+ * in the battery — a perfect score for a guard that does not exist. Measured on
11
+ * this repo's own build:
12
+ *
13
+ * ```
14
+ * hook `python3 .claude/hooks/guard.py`, script absent
15
+ * → MEASURED blocks=7/7 exits=2,2,2,2,2,2,2
16
+ * ```
17
+ *
18
+ * No malformed config is needed to reach it — a relative script path is the
19
+ * commonest shape in the wild — which makes it strictly worse than the
20
+ * uncompilable-matcher lie fixed alongside it, and it is the same false
21
+ * confidence the product sells against, produced by the product.
22
+ *
23
+ * THE EXIT CODE CANNOT DECIDE IT, and that is not a guess: `run-script.ts`
24
+ * already writes down the same fact for its own coverage attribution — "`sh
25
+ * <missing>` exits **2** under dash, which is Claude Code's BLOCK code —
26
+ * indistinguishable from a gate legitimately denying, so it cannot be encoded".
27
+ * The shell's own 126/127 catch a program that never launched; nothing catches a
28
+ * program that launched and could not find its script. That is what this module
29
+ * is for, and why the two halves are both needed rather than either alone.
30
+ *
31
+ * ## What counts as a file reference (MEASURED, not assumed)
32
+ *
33
+ * A word is only reported when it is UNAMBIGUOUSLY a script the command runs:
34
+ * fully resolved (no unexpanded `$`, no substitution, no glob, no whitespace),
35
+ * not a flag, not a `NAME=value`, not a URL, not the inline program text of a
36
+ * `-c`/`-e`, and either the head is a known interpreter or the word carries a
37
+ * script extension UNDER A HEAD THAT COULD EXECUTE IT. The OR is what keeps
38
+ * either list from being load-bearing on its own: an interpreter missing from
39
+ * the table is still caught by the extension, and an extensionless script is
40
+ * still caught by the interpreter.
41
+ *
42
+ * The `UNDER A HEAD` clause is the correction: an extension is evidence about
43
+ * the FILE, not about the command, so on its own it read `rm -f /tmp/stale.sh`
44
+ * — an ordinary cleanup hook whose file is meant to be absent — as a missing
45
+ * script. {@link DATA_ONLY_HEADS} is the set of heads that never execute an
46
+ * operand, and it silences the extension branch for them.
47
+ *
48
+ * The narrowing is measured, not taste. Over the 107 hook registrations in
49
+ * `davila7/claude-code-templates` (the corpus the defect was found in):
50
+ *
51
+ * | rule | hits | wrong |
52
+ * | ------------------------------------------- | ---: | ----: |
53
+ * | any path-shaped operand | 31 | 8 |
54
+ * | interpreter head OR script extension | 23 | 0 |
55
+ *
56
+ * The eight the wide rule got wrong are files a hook WRITES or later reads
57
+ * (`rm ~/.claude/session_start.tmp`, `mv ~/.claude/performance.csv`) and one
58
+ * outright absurdity (`echo N/A`, which contains a slash). Reporting those would
59
+ * be crying wolf on hooks that are perfectly fine, and a check read once and
60
+ * distrusted is a check that is off.
61
+ *
62
+ * ⚠️ THE `wrong: 0` ROW IS WHAT THE `DATA_ONLY_HEADS` CLAUSE CORRECTS, and the
63
+ * two measurements are of different corpora — say so rather than quoting one
64
+ * number. `rm -f /tmp/stale.sh` is exactly the eighth shape above wearing a
65
+ * script extension, and it was not in the pinned 107. Re-measured 2026-09-08
66
+ * against today's `davila7/claude-code-templates` (134 registrations, 120
67
+ * distinct commands — the repo has moved since the pin, so this is a second
68
+ * sample, not a reproduction of the first):
69
+ *
70
+ * | rule | hits | lost to the clause |
71
+ * | ------------------------------------------- | ---: | -----------------: |
72
+ * | interpreter head OR script extension | 5 | 0 |
73
+ * | …with the `DATA_ONLY_HEADS` clause | 5 | 0 |
74
+ *
75
+ * All five are true positives reached by an INTERPRETER head or a head that is
76
+ * itself a path, so the extension-under-an-unknown-head branch contributes zero
77
+ * hits on this sample and narrowing it costs nothing measurable. That is the
78
+ * evidence the clause does not trade a real detection for a false-positive fix;
79
+ * it is not evidence the branch is dead, which is why it is kept.
80
+ *
81
+ * CONSERVATIVE IN THE OTHER DIRECTION THAN {@link shellVarReads}, on purpose.
82
+ * Over-reporting a missing FILE costs a real guard its measurement AND accuses
83
+ * its author of shipping a broken hook, so here silence is the safe error: a
84
+ * word this module cannot fully resolve is skipped, never guessed at. The
85
+ * missing-program half of the same question is answered after the fact by the
86
+ * shell's 126/127, so a false negative here is not the last line of defence.
87
+ */
88
+ /** What a command would execute, as far as this module can resolve it. */
89
+ export interface CommandFileRefs {
90
+ /**
91
+ * Files the command hands to a program to run, in first-seen order, exactly
92
+ * as the shell would spell them (relative stays relative — the caller resolves
93
+ * against the cwd the hook will run in).
94
+ */
95
+ readonly refs: readonly string[];
96
+ /** Whether the shell parser accepted the command. `false` ⇒ `refs` is empty. */
97
+ readonly parsed: boolean;
98
+ }
99
+ /**
100
+ * The files `command` would hand to a program to execute.
101
+ *
102
+ * @param command - the shell command, exactly as the hook registers it.
103
+ * @param values - variables whose value is known at run time, for expansion. A
104
+ * word naming anything absent from this map is skipped, not guessed.
105
+ */
106
+ export declare function commandFileRefs(command: string, values?: Readonly<Record<string, string>>): CommandFileRefs;
107
+ //# sourceMappingURL=command-files.d.ts.map
@@ -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
@@ -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