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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.hookMatcherIssues = hookMatcherIssues;
4
+ exports.hookMatcherReach = hookMatcherReach;
4
5
  const tool_contract_js_1 = require("./tool-contract.js");
5
6
  const mcp_tool_js_1 = require("./mcp-tool.js");
6
7
  // ---------------------------------------------------------------------------
@@ -32,8 +33,31 @@ const REAL_SHAPE_PROBES = [
32
33
  ];
33
34
  /** The widest correct MCP matcher β€” what a too-narrow one should become. */
34
35
  const WIDE_MCP_MATCHER = "mcp__.*__.*";
35
- /** Match-all matchers the harness special-cases (and `*` isn't even a regex). */
36
- const MATCH_ALL = new Set(["", "*", "**", ".*"]);
36
+ /**
37
+ * Match-all matchers the harness special-cases (and `*` isn't even a regex).
38
+ *
39
+ * πŸ”΄ MEASURED, AND `**` IS NOT ONE OF THEM. It sat in this set on no evidence
40
+ * while the table at the top of this file β€” the measured one β€” never listed it.
41
+ * Against a real `claude` 2.1.263, one hook per run, marker file as the oracle,
42
+ * 3 runs of each:
43
+ *
44
+ * | matcher | a `Bash` call | fired |
45
+ * | ------- | ------------- | ----- |
46
+ * | `*` | `Bash` | yes |
47
+ * | `.*` | `Bash` | yes |
48
+ * | `""` | `Bash` | yes |
49
+ * | `**` | `Bash` | NO |
50
+ *
51
+ * The direction of that mistake is the expensive one: believing `**` selects
52
+ * everything makes a guard registered under it come back "measured, allows
53
+ * 0/7" β€” an ACCUSATION that a repo's guard let seven disasters through, when
54
+ * the harness never invoked it once. The mirror image of scoring a hook that
55
+ * could not start. `**` now falls through to the regex path, where it does not
56
+ * compile, and both the sweep and the `hook-matcher` rule report a hook that
57
+ * never fires. Pinned by `src/hook-matcher-delivery.test.ts` so the day Claude
58
+ * Code starts honouring it, the claim goes red instead of quietly rotting.
59
+ */
60
+ const MATCH_ALL = new Set(["", "*", ".*"]);
37
61
  /** Cap on segments harvested from a matcher β€” bounds the probe corpus. */
38
62
  const MAX_DERIVED_SEGMENTS = 4;
39
63
  // ---------------------------------------------------------------------------
@@ -293,4 +317,55 @@ function hookMatcherIssues(entries, declaredServers, dialect) {
293
317
  }
294
318
  return findings;
295
319
  }
320
+ /**
321
+ * Would this matcher select a call to `tool` β€” i.e. does the harness spawn the
322
+ * hook at all?
323
+ *
324
+ * The same two MEASURED facts the module header pins, asked as a question rather
325
+ * than as a defect: on a harness whose matchers are tool names (Claude Code), a
326
+ * matcher with no regex metacharacter is compared by string EQUALITY and one
327
+ * with metacharacters is an UNANCHORED regex. It lives here and not in the
328
+ * caller so those semantics have one home (one-detector-no-drift) β€”
329
+ * `hookMatcherIssues` judges a matcher, this one applies it.
330
+ *
331
+ * FAIL-OPEN WHERE THE HARNESS IS, AND NOT ONE STEP FURTHER. An absent matcher or
332
+ * a match-all really does select every tool, so answering `"selects"` there
333
+ * states a fact β€” the same direction `decideHookCondition`
334
+ * (`core/hook-condition.ts`) fails open, and for the same reason it gives: where
335
+ * Claude Code cannot tell, it RUNS the hook, so mirroring it can only ever add a
336
+ * run, never invent a skip.
337
+ *
338
+ * πŸ”΄ THAT REASONING DOES NOT REACH AN UNCOMPILABLE MATCHER, and this function
339
+ * used to apply it there anyway. `Bash(` is what the `invalid-regex` finding
340
+ * above already reports as "the harness can't compile it, so the hook never
341
+ * fires" β€” the harness fails CLOSED. Answering `"selects"` therefore does not
342
+ * add a run the harness makes, it MANUFACTURES one: a caller feeds the hook a
343
+ * battery it would never have been handed, and an unconditional-deny body scores
344
+ * a full pass for a hook that cannot run. That is the false-confidence class
345
+ * this module exists to remove, so an uncompilable matcher gets its own answer
346
+ * and the caller declines to score it.
347
+ *
348
+ * @param matcher - the registration's matcher, or `null` when it declares none.
349
+ * @param tool - the tool named by the call, e.g. `"Bash"`.
350
+ * @param style - the active harness's `HookProtocol.matcherStyle`. `"exact"`
351
+ * (the default, Claude Code) applies the literal-equality rule above;
352
+ * `"regex"` (Codex) compiles EVERY matcher, so `ash` matches `Bash` and the
353
+ * glob spellings `*` / `**` β€” which are Claude Code's documented match-all,
354
+ * not regexes β€” come back `"uncompilable"` rather than being assumed to be
355
+ * special-cased by a harness nobody measured.
356
+ */
357
+ function hookMatcherReach(matcher, tool, style = "exact") {
358
+ if (matcher === null || matcher === "")
359
+ return "selects";
360
+ if (style === "exact") {
361
+ if (MATCH_ALL.has(matcher))
362
+ return "selects";
363
+ if (isLiteralMatcher(matcher))
364
+ return matcher === tool ? "selects" : "misses";
365
+ }
366
+ const re = compileMatcher(matcher);
367
+ if (re === null)
368
+ return "uncompilable";
369
+ return re.test(tool) ? "selects" : "misses";
370
+ }
296
371
  //# sourceMappingURL=hook-matcher.js.map
@@ -56,6 +56,39 @@ export interface HookRegistration {
56
56
  * for any non-object / malformed input β€” never throws.
57
57
  */
58
58
  export declare function normalizeHooks(raw: unknown): HookRegistration[];
59
+ /**
60
+ * A declared hook action that carries no command β€” `prompt`, `http`, `mcp_tool`
61
+ * or `agent`. Real, supported actions; simply not shell processes, so no tier
62
+ * that drives a shell can measure one.
63
+ */
64
+ export interface NonCommandHookAction {
65
+ /** The event it registers under. */
66
+ readonly event: string;
67
+ /** The tool/path matcher of the entry it sits in, or `null`. */
68
+ readonly matcher: string | null;
69
+ /** Its declared `type`, e.g. `"prompt"`. Never `"command"`. */
70
+ readonly type: string;
71
+ }
72
+ /**
73
+ * The declared actions {@link normalizeHooks} does NOT return, and why anyone
74
+ * should care.
75
+ *
76
+ * πŸ”΄ SILENCE HERE READ AS "NO HOOKS DECLARED", which is the exact false-empty a
77
+ * guard sweep exists to prevent. Claude Code supports five action types
78
+ * (command / http / mcp_tool / prompt / agent) and `normalizeHooks` keeps only
79
+ * the first, correctly β€” the others are not shell processes and nothing that
80
+ * spawns a shell can drive them. But a repository whose PreToolUse hooks are all
81
+ * `prompt` actions then produced an empty registration list, and a caller that
82
+ * reads only the length cannot tell "this repo declared no guards" from "this
83
+ * repo declared four guards I cannot run". The first is an accusation; the
84
+ * second is a limit of the tier. So the dropped actions are RETURNED rather than
85
+ * discarded, and the caller reports them as declared-but-not-measured.
86
+ *
87
+ * A holder counts only when it declares a `type` that is not `"command"`. An
88
+ * entry with neither a type nor a command is malformed config, not an action,
89
+ * and calling it one would invent a hook the repository never declared.
90
+ */
91
+ export declare function nonCommandHookActions(raw: unknown): NonCommandHookAction[];
59
92
  /** Distinct event names present in the raw hooks object (object-keyed shape). */
60
93
  export declare function hookEventNames(raw: unknown): string[];
61
94
  //# sourceMappingURL=hook-normalize.d.ts.map
@@ -23,6 +23,7 @@
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.normalizeHooks = normalizeHooks;
26
+ exports.nonCommandHookActions = nonCommandHookActions;
26
27
  exports.hookEventNames = hookEventNames;
27
28
  /**
28
29
  * The config key a hook's condition is written under. Read here rather than from
@@ -86,6 +87,50 @@ function normalizeHooks(raw) {
86
87
  }
87
88
  return out;
88
89
  }
90
+ /**
91
+ * The declared actions {@link normalizeHooks} does NOT return, and why anyone
92
+ * should care.
93
+ *
94
+ * πŸ”΄ SILENCE HERE READ AS "NO HOOKS DECLARED", which is the exact false-empty a
95
+ * guard sweep exists to prevent. Claude Code supports five action types
96
+ * (command / http / mcp_tool / prompt / agent) and `normalizeHooks` keeps only
97
+ * the first, correctly β€” the others are not shell processes and nothing that
98
+ * spawns a shell can drive them. But a repository whose PreToolUse hooks are all
99
+ * `prompt` actions then produced an empty registration list, and a caller that
100
+ * reads only the length cannot tell "this repo declared no guards" from "this
101
+ * repo declared four guards I cannot run". The first is an accusation; the
102
+ * second is a limit of the tier. So the dropped actions are RETURNED rather than
103
+ * discarded, and the caller reports them as declared-but-not-measured.
104
+ *
105
+ * A holder counts only when it declares a `type` that is not `"command"`. An
106
+ * entry with neither a type nor a command is malformed config, not an action,
107
+ * and calling it one would invent a hook the repository never declared.
108
+ */
109
+ function nonCommandHookActions(raw) {
110
+ if (!isRecord(raw))
111
+ return [];
112
+ const out = [];
113
+ for (const [event, arr] of Object.entries(raw)) {
114
+ if (!Array.isArray(arr))
115
+ continue;
116
+ for (const entry of arr) {
117
+ if (!isRecord(entry))
118
+ continue;
119
+ const matcher = entryMatcher(entry);
120
+ const nested = entry.hooks;
121
+ const holders = Array.isArray(nested) ? nested : [entry];
122
+ for (const h of holders) {
123
+ if (!isRecord(h))
124
+ continue;
125
+ const type = h.type;
126
+ if (typeof type !== "string" || type === "" || type === "command")
127
+ continue;
128
+ out.push({ event, matcher, type });
129
+ }
130
+ }
131
+ }
132
+ return out;
133
+ }
89
134
  /** Distinct event names present in the raw hooks object (object-keyed shape). */
90
135
  function hookEventNames(raw) {
91
136
  return isRecord(raw) ? Object.keys(raw) : [];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Which environment variables a shell command actually DEPENDS ON β€” the
3
+ * parser-backed answer to "would this command run the same program here?".
4
+ *
5
+ * πŸ”΄ WHY A PARSER AND NOT A REGEX, measured. The caller
6
+ * (`experimental_verifyPluginGuards`) refuses to run a hook whose command names
7
+ * a variable nothing has set, because running it would measure a different
8
+ * program than the harness runs. Deciding that with `/\$\{?NAME\}?/` gets two
9
+ * ordinary shapes wrong, in the direction that costs a measurement:
10
+ *
11
+ * | command | the shell | a raw regex |
12
+ * | -------------------------------- | -------------------- | ------------- |
13
+ * | `GUARD=hooks/g.sh; "$GUARD"` | sets it, then expands| "unset GUARD" |
14
+ * | `echo '$NOT_A_VAR'` | no expansion at all | "unset …" |
15
+ *
16
+ * Both are self-contained commands reported as unresolvable, so a real guard
17
+ * goes unmeasured for a reason that is not true of it. This is the
18
+ * `parse-structured-input-with-a-real-parser` rule applied to the same shell
19
+ * grammar `core/bash-effects.ts` already parses: an ASSIGNMENT and a SINGLE-
20
+ * QUOTED literal are nodes, so once the command is an AST the two mistakes above
21
+ * are not expressible.
22
+ *
23
+ * πŸ”΄ AND THE PARSER REMOVED TWO WAYS TO BE WRONG WHILE ADDING A THIRD, in the
24
+ * worse direction. Subtracting every assigned name GLOBALLY excused a read the
25
+ * assignment never reached, so the sweep ran a differently-configured program
26
+ * and gave it a score. Measured against `/bin/sh` with the name exported first:
27
+ *
28
+ * ```
29
+ * export X=ambient; echo "$X"; X=1 β†’ ambient (read comes FIRST)
30
+ * export FOO=ambient; FOO=1 sh -c "echo $FOO" β†’ ambient (prefix assign does
31
+ * not reach its own
32
+ * command's words)
33
+ * (G=inner); printf '[%s]' "$G" β†’ [] (subshell-scoped)
34
+ * G=dominates; printf '[%s]' "$G" β†’ dominates (this one persists)
35
+ * ```
36
+ *
37
+ * So the rule is DOMINANCE, not membership, and CONTROL FLOW, not source order:
38
+ * an assignment excuses a read only when it is an unconditional top-level
39
+ * statement (see {@link persistingAssigns}) AND sits before that read. A prefix,
40
+ * subshell, function-body, backgrounded, conditional or pipelined assignment
41
+ * excuses nothing at all. Where dominance is not provable, the read stands.
42
+ *
43
+ * It does NOT reach into `bash-effects.ts` for the parse: that module's `sh`
44
+ * handle and node types are private to it, and its types model EFFECTS
45
+ * (redirections, wrapper heads, flag tables) rather than expansions. The shared
46
+ * thing is the dependency, not the code β€” both `require("mvdan-sh")`.
47
+ *
48
+ * CONSERVATIVE, ON PURPOSE, IN ONE DIRECTION. Over-reporting a dependency costs
49
+ * a hook its measurement (the caller says so and names the variable);
50
+ * under-reporting one lets a differently-configured program be measured and
51
+ * scored. So where the parser cannot decide, this reports MORE:
52
+ *
53
+ * - a parse failure falls back to the regex scan and says `parsed: false`;
54
+ * - `${FOO:-default}` and `${FOO:?msg}` count as reads even though the first
55
+ * always resolves β€” reading the expansion operator is a further step, and its
56
+ * only effect would be to measure more hooks;
57
+ * - a `for f in …` loop variable is a read (nothing binds it in the AST the way
58
+ * an `Assign` does).
59
+ *
60
+ * `$1` / `$@` / `$?` are never reads: they are positional and special
61
+ * parameters, not environment the caller could set.
62
+ */
63
+ /** What a command reads from its environment. */
64
+ export interface ShellVarReads {
65
+ /** Names it expands and does not itself assign, first-seen order. */
66
+ readonly reads: readonly string[];
67
+ /**
68
+ * Whether the shell parser accepted the command. `false` means `reads` came
69
+ * from the regex fallback and may name a variable the command sets itself.
70
+ */
71
+ readonly parsed: boolean;
72
+ }
73
+ export declare function shellVarReads(command: string): ShellVarReads;
74
+ //# sourceMappingURL=shell-vars.d.ts.map
@@ -0,0 +1,270 @@
1
+ "use strict";
2
+ /**
3
+ * Which environment variables a shell command actually DEPENDS ON β€” the
4
+ * parser-backed answer to "would this command run the same program here?".
5
+ *
6
+ * πŸ”΄ WHY A PARSER AND NOT A REGEX, measured. The caller
7
+ * (`experimental_verifyPluginGuards`) refuses to run a hook whose command names
8
+ * a variable nothing has set, because running it would measure a different
9
+ * program than the harness runs. Deciding that with `/\$\{?NAME\}?/` gets two
10
+ * ordinary shapes wrong, in the direction that costs a measurement:
11
+ *
12
+ * | command | the shell | a raw regex |
13
+ * | -------------------------------- | -------------------- | ------------- |
14
+ * | `GUARD=hooks/g.sh; "$GUARD"` | sets it, then expands| "unset GUARD" |
15
+ * | `echo '$NOT_A_VAR'` | no expansion at all | "unset …" |
16
+ *
17
+ * Both are self-contained commands reported as unresolvable, so a real guard
18
+ * goes unmeasured for a reason that is not true of it. This is the
19
+ * `parse-structured-input-with-a-real-parser` rule applied to the same shell
20
+ * grammar `core/bash-effects.ts` already parses: an ASSIGNMENT and a SINGLE-
21
+ * QUOTED literal are nodes, so once the command is an AST the two mistakes above
22
+ * are not expressible.
23
+ *
24
+ * πŸ”΄ AND THE PARSER REMOVED TWO WAYS TO BE WRONG WHILE ADDING A THIRD, in the
25
+ * worse direction. Subtracting every assigned name GLOBALLY excused a read the
26
+ * assignment never reached, so the sweep ran a differently-configured program
27
+ * and gave it a score. Measured against `/bin/sh` with the name exported first:
28
+ *
29
+ * ```
30
+ * export X=ambient; echo "$X"; X=1 β†’ ambient (read comes FIRST)
31
+ * export FOO=ambient; FOO=1 sh -c "echo $FOO" β†’ ambient (prefix assign does
32
+ * not reach its own
33
+ * command's words)
34
+ * (G=inner); printf '[%s]' "$G" β†’ [] (subshell-scoped)
35
+ * G=dominates; printf '[%s]' "$G" β†’ dominates (this one persists)
36
+ * ```
37
+ *
38
+ * So the rule is DOMINANCE, not membership, and CONTROL FLOW, not source order:
39
+ * an assignment excuses a read only when it is an unconditional top-level
40
+ * statement (see {@link persistingAssigns}) AND sits before that read. A prefix,
41
+ * subshell, function-body, backgrounded, conditional or pipelined assignment
42
+ * excuses nothing at all. Where dominance is not provable, the read stands.
43
+ *
44
+ * It does NOT reach into `bash-effects.ts` for the parse: that module's `sh`
45
+ * handle and node types are private to it, and its types model EFFECTS
46
+ * (redirections, wrapper heads, flag tables) rather than expansions. The shared
47
+ * thing is the dependency, not the code β€” both `require("mvdan-sh")`.
48
+ *
49
+ * CONSERVATIVE, ON PURPOSE, IN ONE DIRECTION. Over-reporting a dependency costs
50
+ * a hook its measurement (the caller says so and names the variable);
51
+ * under-reporting one lets a differently-configured program be measured and
52
+ * scored. So where the parser cannot decide, this reports MORE:
53
+ *
54
+ * - a parse failure falls back to the regex scan and says `parsed: false`;
55
+ * - `${FOO:-default}` and `${FOO:?msg}` count as reads even though the first
56
+ * always resolves β€” reading the expansion operator is a further step, and its
57
+ * only effect would be to measure more hooks;
58
+ * - a `for f in …` loop variable is a read (nothing binds it in the AST the way
59
+ * an `Assign` does).
60
+ *
61
+ * `$1` / `$@` / `$?` are never reads: they are positional and special
62
+ * parameters, not environment the caller could set.
63
+ */
64
+ Object.defineProperty(exports, "__esModule", { value: true });
65
+ exports.shellVarReads = shellVarReads;
66
+ // mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types β€”
67
+ // the same require() `core/bash-effects.ts` uses, for the same parser.
68
+ const _sh = require("mvdan-sh");
69
+ const sh = _sh;
70
+ /** A `$NAME` / `${NAME}` reference β€” not `$(…)`, `$1` or `$@`. */
71
+ const VAR_REF = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
72
+ /** An environment-variable name: what a caller could put in `env`. */
73
+ const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
74
+ /** The fallback for a command the shell parser rejects: every `$NAME` in it. */
75
+ function scanRefs(command) {
76
+ const names = new Set();
77
+ for (const [, name] of command.matchAll(VAR_REF))
78
+ names.add(name);
79
+ return [...names];
80
+ }
81
+ /**
82
+ * The environment variables `command` expands and does not set for itself.
83
+ *
84
+ * @param command - the shell command, exactly as the hook registers it.
85
+ */
86
+ /** A node's byte offset in the source, or `null` when the binding withholds it. */
87
+ function offsetOf(node) {
88
+ try {
89
+ return node.Pos().Offset();
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ /**
96
+ * Where an assignment's effect BEGINS β€” one byte past its last, not at its first.
97
+ *
98
+ * πŸ”΄ THE WHOLE OF THE SELF-REFERENTIAL BUG. The shell expands an assignment's
99
+ * RHS and only THEN binds the name, so `MODE="$MODE"` reads the environment and
100
+ * a read inside an initializer can never be dominated by the assignment
101
+ * containing it. Keyed on the assignment's START, it was: the walk is preorder,
102
+ * so the `Assign` node was recorded before its own `ParamExp` was visited, the
103
+ * assignment's offset was the smaller one, and the genuine ambient read
104
+ * disappeared. `MODE="$MODE"; [ "$MODE" = strict ] && exit 2` therefore reported
105
+ * NO dependency β€” so under confinement the sweep cleared `MODE` without saying
106
+ * so and scored whichever branch the empty value took.
107
+ *
108
+ * Keyed on the END, the containment falls out of the arithmetic rather than
109
+ * needing a special case: a read inside the initializer has a smaller offset
110
+ * than the assignment's end, so it stands; a read after the statement has a
111
+ * larger one, so it is excused. Both directions are pinned in the tests.
112
+ */
113
+ function endOfOrNull(node) {
114
+ try {
115
+ return node.End().Offset();
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /**
122
+ * Declaring keywords whose assignment persists into the rest of the SCRIPT.
123
+ *
124
+ * `local` is deliberately absent: it is meaningful only inside a function, and
125
+ * a function body is precisely the scope this walker never treats as reached.
126
+ */
127
+ const PERSISTING_DECLARATIONS = new Set([
128
+ "export",
129
+ "readonly",
130
+ "declare",
131
+ "typeset",
132
+ ]);
133
+ /**
134
+ * Offsets of the assignments that excuse a later read β€” and it is an ALLOWLIST,
135
+ * which is the whole of the fix. Two shapes are recognized, both TOP-LEVEL
136
+ * statements of the script and neither detached with `&`: a bare `NAME=value`,
137
+ * and a {@link PERSISTING_DECLARATIONS} keyword (`export NAME=value`), which the
138
+ * parser models as a different node for the same persisting statement.
139
+ *
140
+ * πŸ”΄ SOURCE ORDER IS NOT CONTROL FLOW, and reading it as such was a third way to
141
+ * measure a differently-configured program. The earlier version marked the
142
+ * assignments it could prove do not PERSIST (subshell, function body, command
143
+ * prefix) and excused every other one that merely appeared earlier in the text β€”
144
+ * so an assignment the shell may never execute silently excused the read it
145
+ * sits before. Measured against `/bin/sh` with the name exported first:
146
+ *
147
+ * ```
148
+ * MODE=safe; [ "$MODE" = safe ] β†’ runs, and excuses the read
149
+ * false && MODE=safe; [ "$MODE" = safe ] β†’ the AMBIENT value is read
150
+ * if false; then MODE=safe; fi; [ "$MODE" = x ] β†’ the AMBIENT value is read
151
+ * MODE=safe & [ "$MODE" = safe ] β†’ detached: never reaches it
152
+ * true | MODE=safe; [ "$MODE" = safe ] β†’ pipeline subshell, discarded
153
+ * ```
154
+ *
155
+ * Four of those five excused the read under the old rule while the real shell
156
+ * went on reading the environment. Under confinement the sweep clears that
157
+ * environment, so it would have run the `MODE`-unset arm of a guard and scored
158
+ * whatever that arm happens to do.
159
+ *
160
+ * An allowlist rather than a longer denylist because the denylist can only ever
161
+ * be as complete as the grammar we remembered: `Subshell` and `FuncDecl` were on
162
+ * it, `BinaryCmd`, `IfClause`, `WhileClause`, `ForClause`, `CaseClause`, `Block`
163
+ * and a backgrounded `Stmt` were not, and the next construct would not be
164
+ * either. Inverting it makes the unlisted case default to "excuses nothing",
165
+ * which is the direction this module already errs in (see the header): an
166
+ * assignment we cannot place costs a measurement, never a false score.
167
+ *
168
+ * The cost is named rather than hidden: `{ MODE=safe; }; echo "$MODE"` does run
169
+ * unconditionally in this shell and is no longer excused. A brace group at the
170
+ * top of a hook command is rare, and reporting `MODE` as a dependency there ends
171
+ * in `unresolved` with the name printed β€” the safe error.
172
+ */
173
+ function persistingAssigns(file) {
174
+ // start offset β†’ the offset its binding takes effect at (see endOfOrNull).
175
+ const unconditional = new Map();
176
+ const take = (node) => {
177
+ const at = offsetOf(node);
178
+ const effective = endOfOrNull(node);
179
+ // A binding whose extent the parser withheld cannot be shown to dominate
180
+ // anything, so it is not recorded and every read of the name stands.
181
+ if (at !== null && effective !== null)
182
+ unconditional.set(at, effective);
183
+ };
184
+ for (const stmt of file.Stmts ?? []) {
185
+ // `&` detaches into a subshell, so the assignment never reaches this one.
186
+ if (stmt.Background === true)
187
+ continue;
188
+ const cmd = stmt.Cmd;
189
+ if (!cmd)
190
+ continue;
191
+ const kind = sh.syntax.NodeType(cmd);
192
+ // `export NAME=value` is a DeclClause, not a CallExpr β€” a separate node for
193
+ // the same persisting, unconditional statement. Its `Args` ARE the `Assign`
194
+ // nodes, so walking it reaches exactly them.
195
+ if (kind === "DeclClause") {
196
+ if (!PERSISTING_DECLARATIONS.has(cmd.Variant?.Value ?? ""))
197
+ continue;
198
+ sh.syntax.Walk(cmd, (node) => {
199
+ if (node && sh.syntax.NodeType(node) === "Assign")
200
+ take(node);
201
+ return true;
202
+ });
203
+ continue;
204
+ }
205
+ if (kind !== "CallExpr")
206
+ continue;
207
+ // A `CallExpr` with WORDS carries prefix assignments (`FOO=1 cmd`), which do
208
+ // not outlive their own command; one with no words IS the assignment
209
+ // statement (`FOO=1`), which persists into the rest of the script.
210
+ if ((cmd.Args?.length ?? 0) > 0)
211
+ continue;
212
+ for (const assign of cmd.Assigns ?? [])
213
+ take(assign);
214
+ }
215
+ return unconditional;
216
+ }
217
+ function shellVarReads(command) {
218
+ let file;
219
+ try {
220
+ file = sh.syntax.NewParser().Parse(command, "hook.sh");
221
+ }
222
+ catch {
223
+ return { reads: scanRefs(command), parsed: false };
224
+ }
225
+ const unconditional = persistingAssigns(file);
226
+ const assignedAt = new Map();
227
+ const reads = [];
228
+ const seen = new Set();
229
+ sh.syntax.Walk(file, (node) => {
230
+ if (!node)
231
+ return true;
232
+ const kind = sh.syntax.NodeType(node);
233
+ const at = offsetOf(node);
234
+ if (kind === "Assign") {
235
+ const name = node.Name?.Value;
236
+ // The FIRST unconditional assignment is the only one that can dominate
237
+ // a read; a later one cannot reach backwards. What is stored is where the
238
+ // binding TAKES EFFECT (the assignment's end), not where it is written β€”
239
+ // see {@link endOfOrNull}.
240
+ const effective = at === null ? undefined : unconditional.get(at);
241
+ if (name !== undefined && name !== "" && effective !== undefined)
242
+ if (!assignedAt.has(name))
243
+ assignedAt.set(name, effective);
244
+ }
245
+ else if (kind === "ParamExp") {
246
+ const name = node.Param?.Value;
247
+ if (name !== undefined && VAR_NAME.test(name) && !seen.has(name)) {
248
+ // πŸ”΄ DOMINANCE, NOT MEMBERSHIP, AND CONTROL FLOW, NOT SOURCE ORDER.
249
+ // Subtracting every assigned name globally excused `echo "$GUARD";
250
+ // GUARD=hooks/g.sh` β€” where the expansion runs FIRST and really does
251
+ // read the environment. Counting an earlier OFFSET as dominance excused
252
+ // `false && MODE=safe; [ "$MODE" = safe ]`, which the shell also reads
253
+ // from the environment. Both let a differently-configured program be
254
+ // measured and scored, so an assignment excuses a read only when
255
+ // {@link persistingAssigns} proved it runs, and runs first β€” where
256
+ // "first" means it has FINISHED, because `MODE="$MODE"` expands its RHS
257
+ // before it binds the name (see {@link endOfOrNull}).
258
+ const assigned = assignedAt.get(name);
259
+ // `at === null` means the binding withheld the position: excuse nothing.
260
+ if (assigned === undefined || at === null || assigned > at) {
261
+ seen.add(name);
262
+ reads.push(name);
263
+ }
264
+ }
265
+ }
266
+ return true;
267
+ });
268
+ return { reads, parsed: true };
269
+ }
270
+ //# sourceMappingURL=shell-vars.js.map
@@ -109,6 +109,21 @@ export declare function unblockedDisasters(results: readonly GuardrailResult[]):
109
109
  * build instead of failing in production.
110
110
  */
111
111
  export declare function assertBlocksDisasters(hookCommand: string, opts?: VerifyGuardrailOptions): void;
112
+ /**
113
+ * One battery event as a report line, WITHOUT leading indentation so each caller
114
+ * nests it where its own layout needs.
115
+ *
116
+ * THREE outcomes, not two. "never run" is not a weaker "allows": the harness
117
+ * would not invoke this hook for that call at all, so the guard has no opinion
118
+ * to report. Printing it as `allows` is what made a conditional guard look like
119
+ * it had considered β€” and permitted β€” commands it can never see.
120
+ *
121
+ * @internal Shared by {@link formatGuardrailReport} and the directory-level
122
+ * sweep's formatter (`experimental_formatPluginGuardReport`), so the two renderers
123
+ * cannot drift into two vocabularies for the same three outcomes. Not part of the
124
+ * public API β€” a caller wanting these lines wants one of the two reports.
125
+ */
126
+ export declare function guardrailRow(result: GuardrailResult): string;
112
127
  /**
113
128
  * Render a coverage report (informational, NEUTRAL). It reports what the
114
129
  * hook blocks WITHOUT judging it: a hook that allows these may simply not be a
@@ -5,6 +5,7 @@ exports.experimental_alternateSpellings = experimental_alternateSpellings;
5
5
  exports.verifyGuardrail = verifyGuardrail;
6
6
  exports.unblockedDisasters = unblockedDisasters;
7
7
  exports.assertBlocksDisasters = assertBlocksDisasters;
8
+ exports.guardrailRow = guardrailRow;
8
9
  exports.formatGuardrailReport = formatGuardrailReport;
9
10
  /**
10
11
  * Guardrail verification β€” "prove your safety hook ACTUALLY blocks."
@@ -32,6 +33,7 @@ exports.formatGuardrailReport = formatGuardrailReport;
32
33
  */
33
34
  const bash_equivalents_js_1 = require("./core/bash-equivalents.js");
34
35
  const run_hook_js_1 = require("./run-hook.js");
36
+ const run_script_js_1 = require("./run-script.js");
35
37
  /**
36
38
  * The curated battery. Deliberately small and high-signal: each is a textbook
37
39
  * destructive action a real safety hook in the wild claims to stop. Extend with
@@ -173,6 +175,27 @@ function verifyGuardrail(hookCommand, opts = {}) {
173
175
  tool_name: event.tool,
174
176
  tool_input: event.input,
175
177
  }, opts);
178
+ // πŸ”΄ A PROGRAM THE SHELL NEVER LAUNCHED HAS NO OPINION, so it must not be
179
+ // reported as one. 126 ("found, not executable") and 127 ("not found") are
180
+ // the SHELL's own codes β€” not a language's exit convention and not a guess
181
+ // about stderr text β€” and they arrive when the interpreter is missing, the
182
+ // file is not executable, or the shebang is wrong. Left alone, they read as
183
+ // `ran and allowed`, which accuses a guard of letting a disaster through
184
+ // when it was never asked. Folding them into the existing not-run channel
185
+ // means `assertBlocksDisasters` and both renderers say WHY for free, and the
186
+ // verdict is unchanged: a guard that cannot start still protects nothing.
187
+ //
188
+ // ⚠️ ONLY WHEN NOTHING BLOCKED. `echo '{"…deny…}'; ./missing` exits 127 with
189
+ // a real deny on stdout; reclassifying that would hide a decision the hook
190
+ // genuinely made. The exit code loses to the decision, never the reverse.
191
+ if (!r.blocked && r.ran && (0, run_script_js_1.shellNeverLaunched)(r.exitCode))
192
+ return {
193
+ event,
194
+ blocked: false,
195
+ exitCode: r.exitCode,
196
+ ran: false,
197
+ reason: `the shell never launched this hook (exit ${String(r.exitCode)}: ${r.exitCode === 127 ? "command not found" : "not executable"}) β€” nothing here is the guard's decision`,
198
+ };
176
199
  return {
177
200
  event,
178
201
  blocked: r.blocked,
@@ -203,6 +226,25 @@ function assertBlocksDisasters(hookCommand, opts = {}) {
203
226
  : ` ⊘ ${m.event.label} β€” NOT RUN: ${m.reason}`);
204
227
  throw new Error(`Guardrail \`${hookCommand}\` did NOT block ${misses.length} dangerous action(s):\n${lines.join("\n")}\nA hook that doesn't block these is false confidence β€” fix it (PreToolUse + exit 2).`);
205
228
  }
229
+ /**
230
+ * One battery event as a report line, WITHOUT leading indentation so each caller
231
+ * nests it where its own layout needs.
232
+ *
233
+ * THREE outcomes, not two. "never run" is not a weaker "allows": the harness
234
+ * would not invoke this hook for that call at all, so the guard has no opinion
235
+ * to report. Printing it as `allows` is what made a conditional guard look like
236
+ * it had considered β€” and permitted β€” commands it can never see.
237
+ *
238
+ * @internal Shared by {@link formatGuardrailReport} and the directory-level
239
+ * sweep's formatter (`experimental_formatPluginGuardReport`), so the two renderers
240
+ * cannot drift into two vocabularies for the same three outcomes. Not part of the
241
+ * public API β€” a caller wanting these lines wants one of the two reports.
242
+ */
243
+ function guardrailRow(result) {
244
+ if (!result.ran)
245
+ return `⊘ not run ${result.event.label} β€” ${result.reason}`;
246
+ return `${result.blocked ? "βœ… blocks" : "Β· allows"} ${result.event.label}`;
247
+ }
206
248
  /**
207
249
  * Render a coverage report (informational, NEUTRAL). It reports what the
208
250
  * hook blocks WITHOUT judging it: a hook that allows these may simply not be a
@@ -214,15 +256,7 @@ function formatGuardrailReport(hookCommand, results) {
214
256
  const blocked = results.filter((r) => r.blocked).length;
215
257
  const skipped = results.filter((r) => !r.ran).length;
216
258
  const head = `Guardrail coverage for \`${hookCommand}\` β€” blocks ${blocked}/${results.length} of the dangerous battery`;
217
- const rows = results.map((r) => {
218
- // THREE outcomes, not two. "never run" is not a weaker "allows": the harness
219
- // would not invoke this hook for that call at all, so the guard has no opinion
220
- // to report. Printing it as `allows` is what made a conditional guard look
221
- // like it had considered β€” and permitted β€” commands it can never see.
222
- if (!r.ran)
223
- return ` ⊘ not run ${r.event.label} β€” ${r.reason}`;
224
- return ` ${r.blocked ? "βœ… blocks" : "Β· allows"} ${r.event.label}`;
225
- });
259
+ const rows = results.map((r) => ` ${guardrailRow(r)}`);
226
260
  const foot = [
227
261
  blocked < results.length
228
262
  ? "\nAllows β‰  a bug unless this guard is MEANT to block them β€” gate intent with\nassertBlocksDisasters(cmd, { categories: [...] })."