vigiles 26.0.1 → 26.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/adapters/claude-code/hook-condition.d.ts +46 -0
- package/dist/adapters/claude-code/hook-condition.js +142 -0
- package/dist/adapters/claude-code/hook-protocol.js +5 -0
- package/dist/audit-report.template.html +2 -2
- package/dist/cli.js +67 -115
- package/dist/core/bash-effects.d.ts +22 -0
- package/dist/core/bash-effects.js +10 -0
- package/dist/core/command-files.d.ts +107 -0
- package/dist/core/command-files.js +407 -0
- package/dist/core/hook-condition.d.ts +96 -0
- package/dist/core/hook-condition.js +63 -0
- package/dist/core/hook-matcher.d.ts +50 -0
- package/dist/core/hook-matcher.js +77 -2
- package/dist/core/hook-normalize.d.ts +51 -0
- package/dist/core/hook-normalize.js +61 -1
- package/dist/core/hook-program.d.ts +62 -1
- package/dist/core/hook-program.js +15 -1
- package/dist/core/hook-protocol.d.ts +16 -0
- package/dist/core/linters.js +97 -58
- package/dist/core/shell-vars.d.ts +74 -0
- package/dist/core/shell-vars.js +270 -0
- package/dist/core/skill-resources.d.ts +22 -1
- package/dist/core/skill-resources.js +2 -1
- package/dist/doc-test-script-coverage.d.ts +52 -0
- package/dist/doc-test-script-coverage.js +66 -0
- package/dist/guardrail-check.d.ts +29 -0
- package/dist/guardrail-check.js +69 -10
- package/dist/harness-assert.d.ts +8 -5
- package/dist/harness-assert.js +8 -5
- package/dist/harness-resolve-hooks.mjs +14 -37
- package/dist/hook-state-store.d.ts +143 -0
- package/dist/hook-state-store.js +241 -0
- package/dist/hook.d.ts +3 -1
- package/dist/hook.js +3 -1
- package/dist/run-hook.d.ts +33 -1
- package/dist/run-hook.js +46 -2
- package/dist/run-script.d.ts +94 -0
- package/dist/run-script.js +47 -26
- package/dist/scan-core.js +21 -3
- package/dist/score-core.d.ts +21 -1
- package/dist/score-core.js +30 -6
- package/dist/self-resolve.d.mts +20 -0
- package/dist/self-resolve.mjs +75 -0
- package/dist/spec-hooks.d.mts +10 -0
- package/dist/spec-hooks.mjs +17 -0
- package/dist/test.d.ts +5 -0
- package/dist/test.js +23 -2
- package/dist/verify-plugin-guards.d.ts +194 -0
- package/dist/verify-plugin-guards.js +822 -0
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
36
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
package/dist/core/linters.js
CHANGED
|
@@ -655,37 +655,104 @@ const stylelintConfigEnabled = createCachedChecker((basePath) => {
|
|
|
655
655
|
return null;
|
|
656
656
|
}
|
|
657
657
|
});
|
|
658
|
-
|
|
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
|
-
|
|
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:
|
|
689
|
+
...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
|
|
666
690
|
});
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
|
1264
|
-
|
|
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
|