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.
Files changed (51) hide show
  1. package/README.md +5 -4
  2. package/dist/adapters/claude-code/hook-condition.d.ts +46 -0
  3. package/dist/adapters/claude-code/hook-condition.js +142 -0
  4. package/dist/adapters/claude-code/hook-protocol.js +5 -0
  5. package/dist/audit-report.template.html +2 -2
  6. package/dist/cli.js +67 -115
  7. package/dist/core/bash-effects.d.ts +22 -0
  8. package/dist/core/bash-effects.js +10 -0
  9. package/dist/core/command-files.d.ts +107 -0
  10. package/dist/core/command-files.js +407 -0
  11. package/dist/core/hook-condition.d.ts +96 -0
  12. package/dist/core/hook-condition.js +63 -0
  13. package/dist/core/hook-matcher.d.ts +50 -0
  14. package/dist/core/hook-matcher.js +77 -2
  15. package/dist/core/hook-normalize.d.ts +51 -0
  16. package/dist/core/hook-normalize.js +61 -1
  17. package/dist/core/hook-program.d.ts +62 -1
  18. package/dist/core/hook-program.js +15 -1
  19. package/dist/core/hook-protocol.d.ts +16 -0
  20. package/dist/core/linters.js +97 -58
  21. package/dist/core/shell-vars.d.ts +74 -0
  22. package/dist/core/shell-vars.js +270 -0
  23. package/dist/core/skill-resources.d.ts +22 -1
  24. package/dist/core/skill-resources.js +2 -1
  25. package/dist/doc-test-script-coverage.d.ts +52 -0
  26. package/dist/doc-test-script-coverage.js +66 -0
  27. package/dist/guardrail-check.d.ts +29 -0
  28. package/dist/guardrail-check.js +69 -10
  29. package/dist/harness-assert.d.ts +8 -5
  30. package/dist/harness-assert.js +8 -5
  31. package/dist/harness-resolve-hooks.mjs +14 -37
  32. package/dist/hook-state-store.d.ts +143 -0
  33. package/dist/hook-state-store.js +241 -0
  34. package/dist/hook.d.ts +3 -1
  35. package/dist/hook.js +3 -1
  36. package/dist/run-hook.d.ts +33 -1
  37. package/dist/run-hook.js +46 -2
  38. package/dist/run-script.d.ts +94 -0
  39. package/dist/run-script.js +47 -26
  40. package/dist/scan-core.js +21 -3
  41. package/dist/score-core.d.ts +21 -1
  42. package/dist/score-core.js +30 -6
  43. package/dist/self-resolve.d.mts +20 -0
  44. package/dist/self-resolve.mjs +75 -0
  45. package/dist/spec-hooks.d.mts +10 -0
  46. package/dist/spec-hooks.mjs +17 -0
  47. package/dist/test.d.ts +5 -0
  48. package/dist/test.js +23 -2
  49. package/dist/verify-plugin-guards.d.ts +194 -0
  50. package/dist/verify-plugin-guards.js +822 -0
  51. package/package.json +1 -1
@@ -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
@@ -32,12 +32,63 @@ export interface HookRegistration {
32
32
  readonly matcher: string | null;
33
33
  /** The shell command the hook runs (non-empty). */
34
34
  readonly command: string;
35
+ /**
36
+ * The hook's CONDITION as written — Claude Code's `if`, a permission-rule
37
+ * pattern like `"Bash(git push *--force*)"` — or `null` when unconditional.
38
+ *
39
+ * 🔴 THIS FIELD WAS SILENTLY DROPPED, and that was half a real defect. Everything
40
+ * downstream reads registrations, so a key this boundary discards is a key the
41
+ * whole tool is blind to: a published guard whose body denies unconditionally
42
+ * but whose `if` only ever fires on a force push was reported by
43
+ * `verifyGuardrail` as blocking `rm -rf /` and `cat ~/.ssh/id_rsa` too. Carrying
44
+ * it is parse-don't-validate doing its job — read once, here, not re-walked (or
45
+ * forgotten) per detector. See `core/hook-condition.ts`.
46
+ *
47
+ * The KEY is read tolerantly like `matcher`/`command`, per this module's
48
+ * documented no-port stance; the harness that spells it and the semantics that
49
+ * evaluate it live on `HookProtocol.condition`, and a test binds the two so the
50
+ * spelling cannot drift.
51
+ */
52
+ readonly condition: string | null;
35
53
  }
36
54
  /**
37
55
  * Parse the raw `settings.hooks` value into typed registrations. Returns `[]`
38
56
  * for any non-object / malformed input — never throws.
39
57
  */
40
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[];
41
92
  /** Distinct event names present in the raw hooks object (object-keyed shape). */
42
93
  export declare function hookEventNames(raw: unknown): string[];
43
94
  //# sourceMappingURL=hook-normalize.d.ts.map
@@ -23,7 +23,18 @@
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.normalizeHooks = normalizeHooks;
26
+ exports.nonCommandHookActions = nonCommandHookActions;
26
27
  exports.hookEventNames = hookEventNames;
28
+ /**
29
+ * The config key a hook's condition is written under. Read here rather than from
30
+ * the port for the same reason `matcher` and `command` are: this reader is
31
+ * harness-agnostic BY TOLERANCE (see the module header), and one shared spelling
32
+ * is cheaper than threading a port through every caller. `hook-condition.test.ts`
33
+ * asserts it equals `claudeCodeHookCondition.field`, so a rename fails a test
34
+ * instead of quietly reading nothing. The day a harness spells it differently,
35
+ * this constant is the single seam to lift behind the port.
36
+ */
37
+ const CONDITION_KEY = "if";
27
38
  /** True for a non-null, non-array object. */
28
39
  function isRecord(v) {
29
40
  return v !== null && typeof v === "object" && !Array.isArray(v);
@@ -51,7 +62,12 @@ function flattenEntry(event, entry) {
51
62
  const command = h.command;
52
63
  if (typeof command !== "string" || command.length === 0)
53
64
  continue;
54
- out.push({ event, matcher, command });
65
+ // The condition may sit on the ACTION (Claude Code's nested shape) or, for a
66
+ // flat entry, on the entry itself — which is the same object, so one read
67
+ // covers both without a second branch.
68
+ const raw = h[CONDITION_KEY];
69
+ const condition = typeof raw === "string" && raw.trim().length > 0 ? raw : null;
70
+ out.push({ event, matcher, command, condition });
55
71
  }
56
72
  return out;
57
73
  }
@@ -71,6 +87,50 @@ function normalizeHooks(raw) {
71
87
  }
72
88
  return out;
73
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
+ }
74
134
  /** Distinct event names present in the raw hooks object (object-keyed shape). */
75
135
  function hookEventNames(raw) {
76
136
  return isRecord(raw) ? Object.keys(raw) : [];
@@ -26,7 +26,9 @@
26
26
  * Pure core, harness-neutral. HONEST SCOPE (kept in every doc): compile/verify fix
27
27
  * the hook's AUTHORING + LOGIC, not the harness's DELIVERY. #34692 (a subagent's
28
28
  * calls never reaching PreToolUse) is FIXED as of CC 2.1.241 — measured on a stock
29
- * install, pinned by src/subagent-delivery.test.ts. A gate is STILL a strong default
29
+ * install, pinned by src/subagent-delivery.test.ts. SCOPE of that measurement:
30
+ * headless `claude -p` only — interactive is unmeasured, and depth-2 subagent
31
+ * nesting does not occur there at all. A gate is STILL a strong default
30
32
  * rather than an unbypassable wall, because a model can route around a tool
31
33
  * entirely (#45427 / #32376). Limits (buy-in, node-startup latency) +
32
34
  * full record in research/hook-pain-points.md.
@@ -120,6 +122,65 @@ export type GateAction = {
120
122
  * Pure, so a test asserts "in observe mode this deny does NOT block" with no process.
121
123
  */
122
124
  export declare function gateAction(decision: Decision, mode?: HookMode): GateAction;
125
+ /**
126
+ * WHERE A REACT'S `notice` CAN ACTUALLY ARRIVE — the react-side twin of
127
+ * {@link gateAction}, and pure for the same reason: the runtime and a test must
128
+ * agree about delivery without either of them running a harness.
129
+ *
130
+ * 🔴 THE DEFECT THIS EXISTS TO CLOSE, MEASURED 2026-09-07 BY THIS REPO'S OWN
131
+ * FIRST COMPILED HOOK. `notice(…)` was written to stderr and nothing else. Per
132
+ * Claude Code's hooks documentation, stderr from a hook that exits 0 "goes to
133
+ * the debug log only, never the transcript, and Claude never sees it" — and a
134
+ * react ALWAYS exits 0, because its type has no `deny`. So a notice reached
135
+ * NOBODY: not the model, not the user, not the transcript. `docs/compiled-hooks.md`
136
+ * stated the MECHANIC ("notice writes to stderr") and never the CONSEQUENCE, so
137
+ * the role looked healthy while delivering nowhere — precisely the
138
+ * false-confidence class compiled hooks exist to eliminate, inside the
139
+ * implementation of compiled hooks.
140
+ *
141
+ * The fix is the mechanism the shipped nudges already use:
142
+ * `hookSpecificOutput.additionalContext` on STDOUT. Two candidates were
143
+ * considered and one is a trap:
144
+ *
145
+ * - **exit 2.** The host's per-event table does show `PostToolUse` stderr to the
146
+ * model on exit 2. But {@link HookProtocol.blockExitCode} IS 2 — it is the
147
+ * DENY channel. Spending it on a role whose type-level guarantee is "can never
148
+ * block" would make that guarantee a lie on every other event. Rejected.
149
+ * - **`additionalContext`.** Already proven on this event by the shipped refs
150
+ * nudge, shape shared across harnesses, and the per-harness half is exactly
151
+ * {@link HookProtocol.injectableEvents} — a fact the port already carries and
152
+ * the conformance kit already checks. Chosen.
153
+ *
154
+ * `injectableEvents` is INJECTED rather than read from a Claude Code literal, so
155
+ * this stays harness-agnostic (`core ⊄ adapter`): a Codex react on `PostToolUse`
156
+ * is delivered by the same code path, and an event neither harness injects is
157
+ * reported `undeliverable` rather than silently dropped.
158
+ */
159
+ export type NoticeDelivery =
160
+ /** The notice can reach the agent as injected context on this event. */
161
+ {
162
+ readonly kind: "inject";
163
+ readonly context: string;
164
+ }
165
+ /**
166
+ * A notice on an event this harness does NOT inject. It still goes to stderr
167
+ * (the debug log), but nothing surfaces it — so the CALLER must say so out
168
+ * loud rather than treat this as success. Carrying the message means the
169
+ * caller never has to re-derive which reaction it was talking about.
170
+ */
171
+ | {
172
+ readonly kind: "undeliverable";
173
+ readonly message: string;
174
+ }
175
+ /** Not a notice (a `run` or `nothing`) — nothing to deliver. */
176
+ | {
177
+ readonly kind: "none";
178
+ };
179
+ /**
180
+ * Decide how a {@link Reaction}'s notice reaches the agent on `on`, given the
181
+ * events this harness injects. Pure — no I/O, no process, no harness literal.
182
+ */
183
+ export declare function noticeDelivery(reaction: Reaction, on: string, injectableEvents: readonly string[]): NoticeDelivery;
123
184
  /** An AST-backed view of a Bash command — the author never writes a regex. */
124
185
  export interface CommandView {
125
186
  readonly raw: string;
@@ -4,6 +4,7 @@ exports.nothing = exports.notice = exports.run = exports.inject = exports.tools
4
4
  exports.matchesTool = matchesTool;
5
5
  exports.invalidToolPatterns = invalidToolPatterns;
6
6
  exports.gateAction = gateAction;
7
+ exports.noticeDelivery = noticeDelivery;
7
8
  exports.trimTrailingSeparators = trimTrailingSeparators;
8
9
  exports.commandView = commandView;
9
10
  exports.experimental_defineHook = experimental_defineHook;
@@ -66,7 +67,9 @@ exports.isLoadPathRepairEvent = isLoadPathRepairEvent;
66
67
  * Pure core, harness-neutral. HONEST SCOPE (kept in every doc): compile/verify fix
67
68
  * the hook's AUTHORING + LOGIC, not the harness's DELIVERY. #34692 (a subagent's
68
69
  * calls never reaching PreToolUse) is FIXED as of CC 2.1.241 — measured on a stock
69
- * install, pinned by src/subagent-delivery.test.ts. A gate is STILL a strong default
70
+ * install, pinned by src/subagent-delivery.test.ts. SCOPE of that measurement:
71
+ * headless `claude -p` only — interactive is unmeasured, and depth-2 subagent
72
+ * nesting does not occur there at all. A gate is STILL a strong default
70
73
  * rather than an unbypassable wall, because a model can route around a tool
71
74
  * entirely (#45427 / #32376). Limits (buy-in, node-startup latency) +
72
75
  * full record in research/hook-pain-points.md.
@@ -168,6 +171,17 @@ function gateAction(decision, mode = "enforce") {
168
171
  return (0, hash_js_1.assertNever)(decision);
169
172
  }
170
173
  }
174
+ /**
175
+ * Decide how a {@link Reaction}'s notice reaches the agent on `on`, given the
176
+ * events this harness injects. Pure — no I/O, no process, no harness literal.
177
+ */
178
+ function noticeDelivery(reaction, on, injectableEvents) {
179
+ if (reaction.kind !== "notice")
180
+ return { kind: "none" };
181
+ return injectableEvents.includes(on)
182
+ ? { kind: "inject", context: reaction.message }
183
+ : { kind: "undeliverable", message: reaction.message };
184
+ }
171
185
  const FORCE_FLAG = /^-(?:-force$|[a-z]*f[a-z]*$)/;
172
186
  const hasForce = (argv) => argv.some((a) => FORCE_FLAG.test(a));
173
187
  const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
@@ -12,6 +12,7 @@
12
12
  * The Claude Code implementation is `claudeCodeHookProtocol` in
13
13
  * `src/adapters/claude-code/hook-protocol.ts`.
14
14
  */
15
+ import type { HookConditionSupport } from "./hook-condition.js";
15
16
  export interface HookProtocol {
16
17
  /** Stable identifier, e.g. "claude-code". */
17
18
  readonly name: string;
@@ -60,5 +61,20 @@ export interface HookProtocol {
60
61
  * by `decideHook`; absent ⇒ no field halts the turn on this harness.
61
62
  */
62
63
  readonly haltsTurnField?: string;
64
+ /**
65
+ * How this harness decides whether a hook that declares a CONDITION runs at all
66
+ * — Claude Code's per-action `if` field. Optional (additive, non-breaking);
67
+ * absent ⇒ the harness has no such feature and every hook is unconditional,
68
+ * which is what every consumer got before this existed.
69
+ *
70
+ * 🔴 IT IS A PORT FIELD BECAUSE THE SYNTAX IS THE HARNESS'S, NOT BECAUSE THE
71
+ * IDEA IS. "A hook may only run on matching calls" is neutral and modelled in
72
+ * `core/hook-condition.ts`; `"Bash(git push *--force*)"` is Claude Code's
73
+ * permission-rule grammar, so the matcher lives in its adapter. Reading it here
74
+ * is what stops `verifyGuardrail` reporting a conditional guard as blocking
75
+ * things its condition means it never sees — see `core/hook-condition.ts` for
76
+ * the measured false green that put this field on the port.
77
+ */
78
+ readonly condition?: HookConditionSupport;
63
79
  }
64
80
  //# sourceMappingURL=hook-protocol.d.ts.map
@@ -655,37 +655,104 @@ const stylelintConfigEnabled = createCachedChecker((basePath) => {
655
655
  return null;
656
656
  }
657
657
  });
658
- const ruffConfigEnabled = createCachedChecker((basePath) => {
658
+ /**
659
+ * Ask ruff which rule codes are enabled for `basePath`, or null when it could
660
+ * not be asked. THE ONE place any ruff settings probe is spelled — the three
661
+ * call sites (config-state, catalog enumeration, discovery) used to each carry
662
+ * their own copy of the command and the parse, and each copy carried the same
663
+ * bug.
664
+ *
665
+ * WHY the argument is a DIRECTORY and never a filename: all three call sites
666
+ * passed a synthesized `<basePath>/dummy.py`. `ruff check` walks the path it is
667
+ * given, so a filename that does not exist makes it exit 2 with "No files found
668
+ * under the given path" — on every repository that does not happen to contain a
669
+ * file called `dummy.py`, which is every real repository. Measured 2026-09-06:
670
+ * with the synthesized path the enabled set came back empty and every
671
+ * `enforce("ruff/...")` reported `enabled: "unknown"`; `touch dummy.py` in the
672
+ * same repo flipped the identical rule to "enabled" and an out-of-select rule
673
+ * to "disabled". The failure was SILENT because only "disabled" is ever
674
+ * surfaced as a finding (src/core/compile.ts, src/cli.ts) — "unknown" reads as
675
+ * clean, so a genuinely disabled rule passed its check.
676
+ *
677
+ * A directory works where a synthesized filename does not, including the case
678
+ * the filename was invented for: a project with NO Python files at all still
679
+ * resolves its settings (measured: a directory holding only a ruff config
680
+ * reports its full enabled set).
681
+ */
682
+ function ruffEnabledCodes(basePath, timeoutMs) {
683
+ let output;
659
684
  try {
660
- const dummyPath = (0, node_path_1.resolve)(basePath, "dummy.py");
661
- const output = (0, node_child_process_1.execSync)(`ruff check --show-settings ${dummyPath}`, {
685
+ output = (0, node_child_process_1.execSync)("ruff check --show-settings .", {
662
686
  encoding: "utf-8",
663
687
  cwd: basePath,
664
688
  stdio: ["pipe", "pipe", "pipe"],
665
- timeout: 10000,
689
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
666
690
  });
667
- const enabledMatch = output.match(/linter\.rules\.enabled\s*=\s*\[([\s\S]*?)\]/);
668
- const enabledCodes = new Set();
669
- if (enabledMatch?.[1]) {
670
- const codeRe = /\(([A-Z]+\d*)\)/g;
671
- let m;
672
- while ((m = codeRe.exec(enabledMatch[1])) !== null) {
673
- enabledCodes.add(m[1]);
674
- }
691
+ }
692
+ catch (e) {
693
+ // Two failures wear the same catch, and only ONE of them is legitimate
694
+ // silence. ruff not being installed (127 / ENOENT) means this project has
695
+ // no ruff to consult — nothing to report. Anything else means ruff RAN and
696
+ // REFUSED, and a caller that turns that into "unknown" is reporting a
697
+ // verified-clean result it never verified. That one gets said out loud.
698
+ const err = e;
699
+ if (err.status !== 127 && err.code !== "ENOENT") {
700
+ warnRuffUnreadable(basePath, firstLines(err.stderr));
675
701
  }
676
- return (ruleName) => {
677
- if (enabledCodes.has(ruleName))
678
- return "enabled";
679
- for (const code of enabledCodes) {
680
- if (code.startsWith(ruleName))
681
- return "enabled";
682
- }
683
- return "disabled";
684
- };
702
+ return null;
685
703
  }
686
- catch {
704
+ const enabledMatch = output.match(/linter\.rules\.enabled\s*=\s*\[([\s\S]*?)\]/);
705
+ if (!enabledMatch?.[1]) {
706
+ // ruff succeeded but its output does not carry the block we parse (a
707
+ // format change upstream). Same reasoning as above: unreadable is not clean.
708
+ warnRuffUnreadable(basePath, "no linter.rules.enabled block in output");
687
709
  return null;
688
710
  }
711
+ const codes = new Set();
712
+ const codeRe = /\(([A-Z]+\d*)\)/g;
713
+ let m;
714
+ while ((m = codeRe.exec(enabledMatch[1])) !== null) {
715
+ codes.add(m[1]);
716
+ }
717
+ return codes;
718
+ }
719
+ function firstLines(stderr) {
720
+ // execSync hands stderr back as a string or a Buffer depending on `encoding`.
721
+ // Anything else is stringified as "[object Object]", which would put noise
722
+ // into a message whose whole job is to name the cause — so it becomes "".
723
+ let text = "";
724
+ if (typeof stderr === "string")
725
+ text = stderr;
726
+ else if (Buffer.isBuffer(stderr))
727
+ text = stderr.toString("utf-8");
728
+ return text.trim().split("\n").slice(0, 2).join(" — ").trim();
729
+ }
730
+ /**
731
+ * Loud, but once per basePath. A warning that repeats per rule would be noise,
732
+ * and noise is how a real signal gets muted.
733
+ */
734
+ const ruffWarned = new Set();
735
+ function warnRuffUnreadable(basePath, detail) {
736
+ if (ruffWarned.has(basePath))
737
+ return;
738
+ ruffWarned.add(basePath);
739
+ console.warn(`⚠ ruff could not report its settings in ${basePath}` +
740
+ (detail ? `: ${detail}` : "") +
741
+ `. Every ruff/* rule is UNKNOWN here — not verified, and not clean.`);
742
+ }
743
+ const ruffConfigEnabled = createCachedChecker((basePath) => {
744
+ const enabledCodes = ruffEnabledCodes(basePath, 10000);
745
+ if (!enabledCodes)
746
+ return null;
747
+ return (ruleName) => {
748
+ if (enabledCodes.has(ruleName))
749
+ return "enabled";
750
+ for (const code of enabledCodes) {
751
+ if (code.startsWith(ruleName))
752
+ return "enabled";
753
+ }
754
+ return "disabled";
755
+ };
689
756
  });
690
757
  const pylintConfigEnabled = createCachedChecker((basePath) => {
691
758
  try {
@@ -874,24 +941,11 @@ function cachedByBasePath(fn) {
874
941
  return rules;
875
942
  };
876
943
  }
877
- const enumerateRuffRules = cachedByBasePath((basePath) => {
878
- const rules = new Set();
879
- try {
880
- const output = (0, node_child_process_1.execSync)(`ruff check --show-settings ${(0, node_path_1.resolve)(basePath, "dummy.py")}`, { encoding: "utf-8", cwd: basePath, stdio: ["pipe", "pipe", "pipe"] });
881
- const enabledMatch = output.match(/linter\.rules\.enabled\s*=\s*\[([\s\S]*?)\]/);
882
- if (enabledMatch?.[1]) {
883
- const codeRe = /\(([A-Z]+\d*)\)/g;
884
- let m;
885
- while ((m = codeRe.exec(enabledMatch[1])) !== null) {
886
- rules.add(m[1]);
887
- }
888
- }
889
- }
890
- catch {
891
- // CLI failed — return empty set, caller will just skip suggestions
892
- }
893
- return rules;
894
- });
944
+ const enumerateRuffRules = cachedByBasePath(
945
+ // An unreadable settings probe yields an EMPTY suggestion catalog, which is
946
+ // the same shape as "ruff enables nothing" — so the reason is reported by
947
+ // ruffEnabledCodes rather than swallowed here.
948
+ (basePath) => ruffEnabledCodes(basePath) ?? new Set());
895
949
  const enumeratePylintRules = cachedByBasePath((basePath) => {
896
950
  const rules = new Set();
897
951
  try {
@@ -1260,25 +1314,10 @@ function discoverRuffRules(basePath) {
1260
1314
  if (!hasRuffConfig(basePath))
1261
1315
  return null;
1262
1316
  (0, node_child_process_1.execSync)("which ruff", { stdio: "ignore" });
1263
- const dummyPath = (0, node_path_1.resolve)(basePath, "dummy.py");
1264
- const output = (0, node_child_process_1.execSync)(`ruff check --show-settings ${dummyPath}`, {
1265
- encoding: "utf-8",
1266
- cwd: basePath,
1267
- stdio: ["pipe", "pipe", "pipe"],
1268
- timeout: 10000,
1269
- });
1270
- const enabledMatch = output.match(/linter\.rules\.enabled\s*=\s*\[([\s\S]*?)\]/);
1271
- const rules = [];
1272
- if (enabledMatch?.[1]) {
1273
- const codeRe = /\(([A-Z]+\d*)\)/g;
1274
- let m;
1275
- while ((m = codeRe.exec(enabledMatch[1])) !== null) {
1276
- rules.push(m[1]);
1277
- }
1278
- }
1279
- if (rules.length === 0)
1317
+ const codes = ruffEnabledCodes(basePath, 10000);
1318
+ if (!codes || codes.size === 0)
1280
1319
  return null;
1281
- return { linter: "ruff", rules, via: "CLI" };
1320
+ return { linter: "ruff", rules: [...codes], via: "CLI" };
1282
1321
  }
1283
1322
  catch {
1284
1323
  return null;
@@ -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