vigiles 10.0.0 → 11.0.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 +113 -83
- package/dist/adapters/claude-code/dialect.js +15 -0
- package/dist/audit-report.d.ts +1 -1
- package/dist/audit-report.template.html +1 -1
- package/dist/audit-score.d.ts +19 -12
- package/dist/audit-score.js +65 -11
- package/dist/cli.js +249 -0
- package/dist/core/CLAUDE.md.spec.d.ts +3 -0
- package/dist/core/CLAUDE.md.spec.js +26 -0
- package/dist/core/delegation-trifecta.d.ts +64 -0
- package/dist/core/delegation-trifecta.js +124 -0
- package/dist/core/dialect.d.ts +18 -0
- package/dist/core/hook-block-ineffective.d.ts +62 -0
- package/dist/core/hook-block-ineffective.js +153 -0
- package/dist/core/hook-matcher.d.ts +66 -0
- package/dist/core/hook-matcher.js +182 -0
- package/dist/core/hook-normalize.d.ts +43 -0
- package/dist/core/hook-normalize.js +78 -0
- package/dist/core/lethal-trifecta.d.ts +100 -0
- package/dist/core/lethal-trifecta.js +197 -0
- package/dist/core/plugin-dir-layout.d.ts +30 -0
- package/dist/core/plugin-dir-layout.js +73 -0
- package/dist/core/rule-meta.d.ts +82 -0
- package/dist/core/rule-meta.js +266 -0
- package/dist/core/skill-missing-fence.d.ts +47 -0
- package/dist/core/skill-missing-fence.js +119 -0
- package/dist/core/skill-resources.d.ts +27 -0
- package/dist/core/skill-resources.js +167 -0
- package/dist/core/types.d.ts +71 -0
- package/dist/core/validate.d.ts +1 -0
- package/dist/core/validate.js +26 -4
- package/dist/leaderboard.d.ts +1 -0
- package/dist/leaderboard.js +42 -4
- package/dist/scan.d.ts +106 -0
- package/dist/scan.js +251 -45
- package/dist/setup-plan.d.ts +6 -3
- package/dist/setup-plan.js +12 -2
- package/package.json +1 -1
package/dist/core/dialect.d.ts
CHANGED
|
@@ -42,6 +42,24 @@ export interface HarnessDialect {
|
|
|
42
42
|
readonly knownMcpServers?: readonly string[];
|
|
43
43
|
/** Hook event names the harness fires. */
|
|
44
44
|
readonly hookEvents: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* The subset of `hookEvents` where a block decision (`exit 2` / a deny field)
|
|
47
|
+
* is SILENTLY IGNORED ENTIRELY — no veto AND no model feedback (Claude Code's
|
|
48
|
+
* SessionStart / SessionEnd / Notification / PreCompact: exit 2 there writes
|
|
49
|
+
* stderr only to the user). The basis for the `hook-block-ineffective`
|
|
50
|
+
* "wrong-event" check, which fires ONLY on these (so it stays FP-safe and never
|
|
51
|
+
* cries wolf on a PostToolUse feedback/nudge hook). Optional (additive,
|
|
52
|
+
* non-breaking) — absent ⇒ the harness's block semantics are undeclared and the
|
|
53
|
+
* check does not run for it.
|
|
54
|
+
*/
|
|
55
|
+
readonly noEffectHookEvents?: readonly string[];
|
|
56
|
+
/**
|
|
57
|
+
* The subset of blocking events whose deny REQUIRES the structured
|
|
58
|
+
* `permissionDecision` field (e.g. Claude Code's `PreToolUse`), where the
|
|
59
|
+
* legacy top-level `decision` field is silently ignored. The basis for the
|
|
60
|
+
* `hook-block-ineffective` "wrong-field" check. Optional (additive).
|
|
61
|
+
*/
|
|
62
|
+
readonly permissionDecisionHookEvents?: readonly string[];
|
|
45
63
|
/** Instruction-file targets the harness reads (also the h1 heading). */
|
|
46
64
|
readonly instructionTargets: readonly string[];
|
|
47
65
|
/** The env token expanded to the plugin root in hook commands. */
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** The two "looks like it blocks but doesn't" failure shapes. */
|
|
2
|
+
export type HookBlockKind = "wrong-event" | "wrong-field";
|
|
3
|
+
/** One false-confidence finding: a hook that appears to block but silently won't. */
|
|
4
|
+
export interface HookBlockFinding {
|
|
5
|
+
readonly event: string;
|
|
6
|
+
readonly kind: HookBlockKind;
|
|
7
|
+
/** The hook script that was inspected (path if resolved from a script file, else null = inline command). */
|
|
8
|
+
readonly scriptPath: string | null;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}
|
|
11
|
+
/** A single hook registration to inspect. */
|
|
12
|
+
export interface HookScriptEntry {
|
|
13
|
+
readonly event: string;
|
|
14
|
+
readonly matcher?: string;
|
|
15
|
+
/** The hook command line as registered. */
|
|
16
|
+
readonly command: string;
|
|
17
|
+
/** Resolved path to the script file the command runs, if known (else null → inspect `command`). */
|
|
18
|
+
readonly scriptPath?: string | null;
|
|
19
|
+
}
|
|
20
|
+
/** Options for {@link hookBlockIssues}. All fields are injectable for testability. */
|
|
21
|
+
export interface HookBlockOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Events on this harness where a block decision (`exit 2` / a deny field) is
|
|
24
|
+
* SILENTLY IGNORED ENTIRELY — no veto AND no feedback to the model (Claude
|
|
25
|
+
* Code's `SessionStart`/`SessionEnd`/`Notification`/`PreCompact`: exit 2 there
|
|
26
|
+
* only writes stderr to the user). These are the ONLY events the "wrong-event"
|
|
27
|
+
* check fires on, so it stays FP-safe.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately EXCLUDES `PostToolUse`: a `PostToolUse` exit 2 feeds stderr back
|
|
30
|
+
* to the model — a legitimate FEEDBACK channel, NOT a failed block — so flagging
|
|
31
|
+
* it would cry wolf on every nudge/lint hook (the dogfood lesson: vigiles's own
|
|
32
|
+
* `refs-nudge.sh` is exactly that shape). A hook that exits 2 on `PostToolUse`
|
|
33
|
+
* intending to BLOCK is misguided, but that intent is not deterministically
|
|
34
|
+
* distinguishable from feedback, so we don't flag it.
|
|
35
|
+
*/
|
|
36
|
+
readonly noEffectEvents: ReadonlySet<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Events that require the structured `permissionDecision` field for a deny
|
|
39
|
+
* (e.g. `PreToolUse`). On these events the legacy top-level `"decision":"block"`
|
|
40
|
+
* field is ignored; only `hookSpecificOutput.permissionDecision:"deny"` works.
|
|
41
|
+
*/
|
|
42
|
+
readonly permissionDecisionEvents: ReadonlySet<string>;
|
|
43
|
+
/**
|
|
44
|
+
* Injectable file read (default: node:fs `readFileSync(p, "utf8")`).
|
|
45
|
+
* Returns `""` on any error so a missing / unreadable script doesn't crash
|
|
46
|
+
* the detector — it simply produces no findings for that entry.
|
|
47
|
+
*/
|
|
48
|
+
readonly readFileSync?: (p: string) => string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Detect false-confidence "blocks" across a set of hook entries.
|
|
52
|
+
*
|
|
53
|
+
* Returns one {@link HookBlockFinding} per entry (at most one per entry —
|
|
54
|
+
* `wrong-event` takes precedence over `wrong-field`). Identical
|
|
55
|
+
* (event, kind, scriptPath) pairs are de-duped.
|
|
56
|
+
*
|
|
57
|
+
* @param entries - The hook registrations to inspect (event + command/script).
|
|
58
|
+
* @param opts - Injected sets of blocking/permission events, and an optional
|
|
59
|
+
* `readFileSync` (default: node:fs).
|
|
60
|
+
*/
|
|
61
|
+
export declare function hookBlockIssues(entries: readonly HookScriptEntry[], opts: HookBlockOptions): HookBlockFinding[];
|
|
62
|
+
//# sourceMappingURL=hook-block-ineffective.d.ts.map
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hookBlockIssues = hookBlockIssues;
|
|
4
|
+
/**
|
|
5
|
+
* Hook-block-ineffective detector — the #1 verified hook pain ("false confidence").
|
|
6
|
+
*
|
|
7
|
+
* A safety hook that LOOKS like it blocks but SILENTLY DOESN'T. Two shapes:
|
|
8
|
+
*
|
|
9
|
+
* 1. **wrong-event** — the script tries to block (contains `exit 2`, a legacy
|
|
10
|
+
* `"decision":"block"` JSON, or a `"permissionDecision":"deny"`) but is
|
|
11
|
+
* registered on an event where a block is SILENTLY IGNORED ENTIRELY —
|
|
12
|
+
* SessionStart / SessionEnd / Notification / PreCompact, where exit 2 writes
|
|
13
|
+
* stderr only to the user (no veto, no model feedback). The author believes a
|
|
14
|
+
* gate is in place; nothing happens. (#19009 names the class.) NB `PostToolUse`
|
|
15
|
+
* is deliberately NOT flagged: there exit 2 feeds stderr back to the model — a
|
|
16
|
+
* legitimate FEEDBACK channel (a nudge/lint hook), not a failed block, and the
|
|
17
|
+
* block-vs-feedback intent isn't deterministically separable.
|
|
18
|
+
*
|
|
19
|
+
* 2. **wrong-field** — the hook IS on a permission-gated event (e.g. PreToolUse)
|
|
20
|
+
* but emits the LEGACY top-level `"decision":"block"` field instead of the
|
|
21
|
+
* required `hookSpecificOutput.permissionDecision:"deny"`. A copied template
|
|
22
|
+
* (PostToolUse style → PreToolUse registration) is the usual cause; the deny
|
|
23
|
+
* is silently discarded, nothing is blocked.
|
|
24
|
+
*
|
|
25
|
+
* Both shapes cause identical user-visible behaviour: the hook "works" (exits,
|
|
26
|
+
* no crash) but never actually stops anything. The only signal is "why did this
|
|
27
|
+
* run anyway?" after an incident.
|
|
28
|
+
*
|
|
29
|
+
* FP-SAFETY — conservative literal patterns only, `warn` severity by default:
|
|
30
|
+
* - `exit 2` is matched by a shell-context regex that requires surrounding
|
|
31
|
+
* whitespace/control chars to avoid false-positives on `exit 200` or a
|
|
32
|
+
* `git status --exit-code 2` argument.
|
|
33
|
+
* - JSON decision patterns are matched literally (no partial JSON walking).
|
|
34
|
+
* - Nothing is flagged on an unknown event (we only know which events CAN
|
|
35
|
+
* block because the caller injected that set).
|
|
36
|
+
*
|
|
37
|
+
* HARNESS-NEUTRAL — the sets of blocking events and permission-decision events
|
|
38
|
+
* are INJECTED from the dialect (never hard-coded here). The caller supplies the
|
|
39
|
+
* Claude Code sets; a Codex adapter supplies its own. This is the same
|
|
40
|
+
* dependency-injection pattern used by `verifyHookEvents` and `verifyToolContract`
|
|
41
|
+
* (core ⊄ adapter — one-detector-no-drift).
|
|
42
|
+
*
|
|
43
|
+
* ONE detector reused by scan + the `hook-block-ineffective` lint rule (two
|
|
44
|
+
* callers, no drift). See `research/hook-pain-points.md` for the verified corpus
|
|
45
|
+
* and `docs/compiled-hooks.md` for the authoritative fix (compiled hooks make
|
|
46
|
+
* this whole class unrepresentable).
|
|
47
|
+
*/
|
|
48
|
+
const node_fs_1 = require("node:fs");
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Block-mechanism patterns (conservative / FP-safe)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/**
|
|
53
|
+
* An `exit 2` statement.
|
|
54
|
+
*
|
|
55
|
+
* Requires a shell-context separator before `exit` (start-of-line, whitespace,
|
|
56
|
+
* `;`, `&`, `|`) and word-boundary / separator after `2` — so `exit 200` and
|
|
57
|
+
* `--exit-code 2` are NOT matched.
|
|
58
|
+
*/
|
|
59
|
+
const EXIT_2 = /(^|[\s;&|])exit\s+2(\s|;|$|['")])/m;
|
|
60
|
+
/**
|
|
61
|
+
* A status-2 exit in a NON-shell hook script (a hook file may be `.js`/`.mjs`/
|
|
62
|
+
* `.py`/`.rb`): `process.exit(2)` (Node), `sys.exit(2)` / `exit(2)` (Python),
|
|
63
|
+
* `Process.exit(2)` / `exit(2)` (Ruby), `os._exit(2)`. So a guard written in
|
|
64
|
+
* Node/Python on a no-effect event isn't shown clean.
|
|
65
|
+
*/
|
|
66
|
+
const EXIT_2_CODE = /\b(?:process\.exit|sys\.exit|os\._exit|Process\.exit|exit)\s*\(\s*2\s*\)/;
|
|
67
|
+
/**
|
|
68
|
+
* A legacy top-level `"decision":"block"` or `"decision":"deny"` JSON field.
|
|
69
|
+
* This is the OLD Claude Code hook output format. On permission-gated events
|
|
70
|
+
* (PreToolUse) it is ignored; on non-blocking events it never had any effect.
|
|
71
|
+
*/
|
|
72
|
+
const DECISION_BLOCK = /"decision"\s*:\s*"(block|deny)"/;
|
|
73
|
+
/**
|
|
74
|
+
* The CORRECT structured deny for permission-gated events:
|
|
75
|
+
* `"permissionDecision":"deny"` or `"permissionDecision":"ask"`.
|
|
76
|
+
* (Both require a structured response, as opposed to the legacy field.)
|
|
77
|
+
*/
|
|
78
|
+
const PERMISSION_DENY = /"permissionDecision"\s*:\s*"(deny|ask)"/;
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Detector
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
function defaultReadFile(p) {
|
|
83
|
+
try {
|
|
84
|
+
return (0, node_fs_1.readFileSync)(p, "utf8");
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Detect false-confidence "blocks" across a set of hook entries.
|
|
92
|
+
*
|
|
93
|
+
* Returns one {@link HookBlockFinding} per entry (at most one per entry —
|
|
94
|
+
* `wrong-event` takes precedence over `wrong-field`). Identical
|
|
95
|
+
* (event, kind, scriptPath) pairs are de-duped.
|
|
96
|
+
*
|
|
97
|
+
* @param entries - The hook registrations to inspect (event + command/script).
|
|
98
|
+
* @param opts - Injected sets of blocking/permission events, and an optional
|
|
99
|
+
* `readFileSync` (default: node:fs).
|
|
100
|
+
*/
|
|
101
|
+
function hookBlockIssues(entries, opts) {
|
|
102
|
+
const { noEffectEvents, permissionDecisionEvents } = opts;
|
|
103
|
+
const readFile = opts.readFileSync ?? defaultReadFile;
|
|
104
|
+
const findings = [];
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
// Determine the script text and the canonical "path" label for findings.
|
|
108
|
+
const scriptPath = entry.scriptPath ?? null;
|
|
109
|
+
const text = scriptPath !== null ? readFile(scriptPath) : (entry.command ?? "");
|
|
110
|
+
// Detect block mechanisms.
|
|
111
|
+
const hasExit2 = EXIT_2.test(text) || EXIT_2_CODE.test(text);
|
|
112
|
+
const hasDecisionBlock = DECISION_BLOCK.test(text);
|
|
113
|
+
const hasPermissionDeny = PERMISSION_DENY.test(text);
|
|
114
|
+
const triesBlock = hasExit2 || hasDecisionBlock || hasPermissionDeny;
|
|
115
|
+
if (!triesBlock)
|
|
116
|
+
continue;
|
|
117
|
+
let kind = null;
|
|
118
|
+
let message = "";
|
|
119
|
+
if (noEffectEvents.has(entry.event)) {
|
|
120
|
+
// wrong-event: a block on an event where it's silently ignored entirely
|
|
121
|
+
// (no veto AND no feedback — stderr goes to the user, not the model).
|
|
122
|
+
kind = "wrong-event";
|
|
123
|
+
message =
|
|
124
|
+
`This hook tries to block (exit 2 / "decision" / "permissionDecision") ` +
|
|
125
|
+
`but on "${entry.event}" a block decision is silently ignored — it can ` +
|
|
126
|
+
`neither veto nor feed the model back (stderr goes only to the user). ` +
|
|
127
|
+
`Nothing is prevented. Move the gate to a blocking event (e.g. PreToolUse) ` +
|
|
128
|
+
`so the deny fires BEFORE the action.`;
|
|
129
|
+
}
|
|
130
|
+
else if (permissionDecisionEvents.has(entry.event) &&
|
|
131
|
+
hasDecisionBlock &&
|
|
132
|
+
!hasPermissionDeny) {
|
|
133
|
+
// wrong-field: on a permission-gated event, uses the legacy field.
|
|
134
|
+
kind = "wrong-field";
|
|
135
|
+
message =
|
|
136
|
+
`On "${entry.event}" a deny must use ` +
|
|
137
|
+
`\`hookSpecificOutput.permissionDecision:"deny"\`; this script uses the ` +
|
|
138
|
+
`legacy top-level \`"decision"\` field, which is ignored on this event, ` +
|
|
139
|
+
`so nothing is blocked. Update the JSON output to the structured form: ` +
|
|
140
|
+
`\`{"hookSpecificOutput":{"permissionDecision":"deny"}}\`.`;
|
|
141
|
+
}
|
|
142
|
+
if (kind === null)
|
|
143
|
+
continue;
|
|
144
|
+
// De-dupe identical (event, kind, scriptPath) triples.
|
|
145
|
+
const dedupeKey = `${entry.event}:${kind}:${scriptPath ?? ""}`;
|
|
146
|
+
if (seen.has(dedupeKey))
|
|
147
|
+
continue;
|
|
148
|
+
seen.add(dedupeKey);
|
|
149
|
+
findings.push({ event: entry.event, kind, scriptPath, message });
|
|
150
|
+
}
|
|
151
|
+
return findings;
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=hook-block-ineffective.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook-matcher verification — the cross-referencing moat applied to the MATCHER
|
|
3
|
+
* string inside a hook registration. A PreToolUse hook fires only when its
|
|
4
|
+
* `matcher` equals the tool name the harness emits (or matches via glob/regex); a
|
|
5
|
+
* typo or wrong form silently prevents the hook from ever running — exactly the
|
|
6
|
+
* FALSE CONFIDENCE failure the compiled-hooks design exists to eliminate
|
|
7
|
+
* (research/hook-pain-points.md).
|
|
8
|
+
*
|
|
9
|
+
* THREE kinds of bad matcher, each verified here (one-detector-no-drift):
|
|
10
|
+
*
|
|
11
|
+
* 1. **tool-typo** — a bare token that is a CLOSE TYPO (edit distance ≤ 2) of a
|
|
12
|
+
* real built-in tool name but not an exact match (`bash` → `Bash`, `read` →
|
|
13
|
+
* `Read`). Suggests the correct casing. Reuses `closestTool` from
|
|
14
|
+
* `tool-contract.ts` — same edit-distance logic, same ≤ 2 confidence bound.
|
|
15
|
+
*
|
|
16
|
+
* 2. **mcp-form** — a token that looks MCP-ish (starts with `mcp`, case-
|
|
17
|
+
* insensitive) but is NOT the required `mcp__<server>__<tool>` double-underscore
|
|
18
|
+
* shape (single underscores, a hyphen, a trailing `*`…). Suggests the corrected
|
|
19
|
+
* form when the server/tool segments can be recovered.
|
|
20
|
+
*
|
|
21
|
+
* 3. **mcp-undeclared** — a correctly-formed `mcp__<server>__…` token whose server
|
|
22
|
+
* is NOT in the plugin's declared MCP servers. Gated EXACTLY like
|
|
23
|
+
* `mcp-tool-resolves`: (a) no declared set → skip (reaches global/project
|
|
24
|
+
* servers); (b) built-ins allowlisted via `dialect.knownMcpServers`; (c) the
|
|
25
|
+
* plugin-namespaced `mcp__plugin_…__…` form is skipped. Reuses `mcpToolServer`
|
|
26
|
+
* from `mcp-tool.ts` for the extraction — one parser, no drift.
|
|
27
|
+
*
|
|
28
|
+
* FP-SAFE: only a SINGLE bare token is inspected. A matcher that is empty, a pure
|
|
29
|
+
* wildcard (`*` / `.*`), or contains alternation (`|`) or other regex meta-
|
|
30
|
+
* characters is skipped — it is a pattern/glob with legitimate broad matching, not
|
|
31
|
+
* a tool name. Same don't-cry-wolf discipline as every other vigiles detector.
|
|
32
|
+
*
|
|
33
|
+
* Pure + ONE detector reused by `scan` + the `hook-matcher` lint rule
|
|
34
|
+
* (one-detector-no-drift). The dialect is injected (core ⊄ adapter).
|
|
35
|
+
*/
|
|
36
|
+
import type { HarnessDialect } from "./dialect.js";
|
|
37
|
+
/** Which matching failure was detected in the hook matcher string. */
|
|
38
|
+
export type HookMatcherKind = "tool-typo" | "mcp-form" | "mcp-undeclared";
|
|
39
|
+
/** One finding for a hook matcher that will silently never fire. */
|
|
40
|
+
export interface HookMatcherFinding {
|
|
41
|
+
/** The matcher string exactly as written. */
|
|
42
|
+
readonly matcher: string;
|
|
43
|
+
/** Which class of error was detected. */
|
|
44
|
+
readonly kind: HookMatcherKind;
|
|
45
|
+
/**
|
|
46
|
+
* The corrected matcher when the intent is recoverable (e.g. `Bash` for
|
|
47
|
+
* `bash`, `mcp__memory__.*` for `mcp_memory_*`). Absent when the server
|
|
48
|
+
* segment can't be recovered from a malformed MCP form.
|
|
49
|
+
*/
|
|
50
|
+
readonly suggestion?: string;
|
|
51
|
+
/** A ready-to-show, actionable message. */
|
|
52
|
+
readonly message: string;
|
|
53
|
+
}
|
|
54
|
+
/** A single hook registration entry — its event and matcher string. */
|
|
55
|
+
export interface HookMatcherEntry {
|
|
56
|
+
readonly event: string;
|
|
57
|
+
readonly matcher: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Verify hook-matcher strings for the three forms that silently never fire.
|
|
61
|
+
* Returns one {@link HookMatcherFinding} per offending entry. De-duplicates
|
|
62
|
+
* repeated matchers. Returns `[]` when all matchers are FP-safe to skip or
|
|
63
|
+
* are correct.
|
|
64
|
+
*/
|
|
65
|
+
export declare function hookMatcherIssues(entries: readonly HookMatcherEntry[], declaredServers: readonly string[], dialect: HarnessDialect): HookMatcherFinding[];
|
|
66
|
+
//# sourceMappingURL=hook-matcher.d.ts.map
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hookMatcherIssues = hookMatcherIssues;
|
|
4
|
+
const tool_contract_js_1 = require("./tool-contract.js");
|
|
5
|
+
const mcp_tool_js_1 = require("./mcp-tool.js");
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Internal helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Whether a matcher token should be skipped for FP-safety. We ONLY inspect
|
|
11
|
+
* a SINGLE bare token that could plausibly be a literal tool name or MCP
|
|
12
|
+
* reference. Anything with regex / glob meta-characters, alternation, a
|
|
13
|
+
* trailing glob wildcard alone, or an empty string is a pattern — skip it.
|
|
14
|
+
*
|
|
15
|
+
* Conservative by design: an unrecognized form → skip, never flag.
|
|
16
|
+
*/
|
|
17
|
+
function isInspectableToken(token) {
|
|
18
|
+
if (token.length === 0)
|
|
19
|
+
return false;
|
|
20
|
+
// Pure wildcard forms used as "match-all" matchers.
|
|
21
|
+
if (token === "*" || token === ".*" || token === "**")
|
|
22
|
+
return false;
|
|
23
|
+
// Contains regex alternation — a combined matcher, not a single tool name.
|
|
24
|
+
if (token.includes("|"))
|
|
25
|
+
return false;
|
|
26
|
+
// Contains a parenthesised group `(…)` — regex, not a tool name.
|
|
27
|
+
if (token.includes("(") || token.includes(")"))
|
|
28
|
+
return false;
|
|
29
|
+
// Contains a `[` — character class; skip.
|
|
30
|
+
if (token.includes("["))
|
|
31
|
+
return false;
|
|
32
|
+
// A leading `^` or trailing `$` — anchored regex.
|
|
33
|
+
if (token.startsWith("^") || token.endsWith("$"))
|
|
34
|
+
return false;
|
|
35
|
+
// Leading `.*` — regex prefix; always a pattern.
|
|
36
|
+
if (token.startsWith(".*"))
|
|
37
|
+
return false;
|
|
38
|
+
// A trailing `.*`/`*` is a glob/regex suffix on a plain TOOL matcher (`Bash.*`,
|
|
39
|
+
// `Read*`) → skip. But for an MCP-ish token the trailing wildcard is EXACTLY
|
|
40
|
+
// what we must inspect: `mcp__server__.*` is the legitimate match-all-tools
|
|
41
|
+
// form, and `mcp_memory_*` is the classic single-underscore typo we want to
|
|
42
|
+
// catch — so do NOT skip a wildcard suffix on an `mcp`-ish token.
|
|
43
|
+
if (!looksMcpIsh(token) && (token.endsWith(".*") || token.endsWith("*")))
|
|
44
|
+
return false;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A token starts with `mcp` (case-insensitive) and contains at least one
|
|
49
|
+
* `_` (making it look like an MCP tool reference, not a harness built-in).
|
|
50
|
+
*/
|
|
51
|
+
function looksMcpIsh(token) {
|
|
52
|
+
return /^mcp[_-]/i.test(token);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether `token` matches the canonical `mcp__<server>__<rest>` double-
|
|
56
|
+
* underscore shape (the valid MCP matcher form). We use the dialect's own
|
|
57
|
+
* `mcpToolPattern` extended to allow trailing `.*` for wildcard matchers,
|
|
58
|
+
* since a hook `matcher` may be `mcp__server__.*` (match-all-tools-on-server).
|
|
59
|
+
*/
|
|
60
|
+
function isValidMcpForm(token, dialect) {
|
|
61
|
+
// The canonical pattern from the dialect: `mcp__server__tool`.
|
|
62
|
+
if (dialect.mcpToolPattern.test(token))
|
|
63
|
+
return true;
|
|
64
|
+
// Also allow the wildcard suffix form `mcp__server__.*`.
|
|
65
|
+
if (/^mcp__[a-z0-9_-]+__\.\*$/i.test(token))
|
|
66
|
+
return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Attempt to recover the server segment from a malformed MCP token so we can
|
|
71
|
+
* suggest the corrected `mcp__<server>__.*` form. Returns null when no
|
|
72
|
+
* segment can be confidently recovered.
|
|
73
|
+
*
|
|
74
|
+
* Handles:
|
|
75
|
+
* - Single-underscore: `mcp_memory_search` → server=`memory`, tool=`search`
|
|
76
|
+
* - Hyphenated: `mcp-memory-search` → server=`memory`, tool=`search`
|
|
77
|
+
* - Glob suffix: `mcp_memory_*` → server=`memory`
|
|
78
|
+
* - Mixed: `mcp__memory_*` → only one `__` segment found
|
|
79
|
+
*/
|
|
80
|
+
function recoverMcpServer(token) {
|
|
81
|
+
// Strip a leading `mcp` and then a separator (`__`, `_`, `-`).
|
|
82
|
+
const rest = token.replace(/^mcp(?:__|_|-)/i, "");
|
|
83
|
+
if (!rest || rest === token)
|
|
84
|
+
return null;
|
|
85
|
+
// Split on single underscores or hyphens (not `__`) to get the next segment.
|
|
86
|
+
// We want the first non-empty segment after the `mcp` prefix separator.
|
|
87
|
+
const segments = rest.split(/(?<!_)_(?!_)|(?<!-)(?:-(?!-))/);
|
|
88
|
+
const server = segments[0];
|
|
89
|
+
if (!server || server.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
// Reject segments that are clearly numeric-only or single chars (too ambiguous).
|
|
92
|
+
if (/^\d+$/.test(server))
|
|
93
|
+
return null;
|
|
94
|
+
return server;
|
|
95
|
+
}
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Public detector
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
/**
|
|
100
|
+
* Verify hook-matcher strings for the three forms that silently never fire.
|
|
101
|
+
* Returns one {@link HookMatcherFinding} per offending entry. De-duplicates
|
|
102
|
+
* repeated matchers. Returns `[]` when all matchers are FP-safe to skip or
|
|
103
|
+
* are correct.
|
|
104
|
+
*/
|
|
105
|
+
function hookMatcherIssues(entries, declaredServers, dialect) {
|
|
106
|
+
const findings = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const { matcher } of entries) {
|
|
109
|
+
// De-dupe repeated matchers across entries.
|
|
110
|
+
if (seen.has(matcher))
|
|
111
|
+
continue;
|
|
112
|
+
seen.add(matcher);
|
|
113
|
+
// Skip wildcards, alternation, regex patterns — FP-safety.
|
|
114
|
+
if (!isInspectableToken(matcher))
|
|
115
|
+
continue;
|
|
116
|
+
// ── kind: mcp-form ──────────────────────────────────────────────────────
|
|
117
|
+
// The token looks MCP-ish but is NOT the valid double-underscore form.
|
|
118
|
+
if (looksMcpIsh(matcher)) {
|
|
119
|
+
if (!isValidMcpForm(matcher, dialect)) {
|
|
120
|
+
const server = recoverMcpServer(matcher);
|
|
121
|
+
const suggestion = server ? `mcp__${server}__.*` : undefined;
|
|
122
|
+
const hintPart = suggestion !== undefined
|
|
123
|
+
? ` Did you mean "${suggestion}"?`
|
|
124
|
+
: " Use the form `mcp__<server>__<tool>` (double underscores).";
|
|
125
|
+
findings.push({
|
|
126
|
+
matcher,
|
|
127
|
+
kind: "mcp-form",
|
|
128
|
+
...(suggestion !== undefined ? { suggestion } : {}),
|
|
129
|
+
message: `Hook matcher "${matcher}" is not a valid MCP tool reference (requires double underscores: \`mcp__server__tool\`).${hintPart}`,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// ── kind: mcp-undeclared ──────────────────────────────────────────────
|
|
134
|
+
// A correctly-formed MCP token whose server isn't in the declared set.
|
|
135
|
+
// Guard 1: no declared set → skip (reaches global/project servers).
|
|
136
|
+
if (declaredServers.length === 0)
|
|
137
|
+
continue;
|
|
138
|
+
// `mcpToolServer` reads the `mcp__server__tool` form; a server-wide WILDCARD
|
|
139
|
+
// matcher (`mcp__server__.*`) isn't a concrete tool, so fall back to the
|
|
140
|
+
// wildcard server segment so an undeclared server is still caught.
|
|
141
|
+
const server = (0, mcp_tool_js_1.mcpToolServer)(matcher, dialect) ??
|
|
142
|
+
/^mcp__([a-z0-9_-]+)__\.\*$/i.exec(matcher)?.[1] ??
|
|
143
|
+
null;
|
|
144
|
+
if (server === null)
|
|
145
|
+
continue; // plugin-namespaced form → guard 3, skip
|
|
146
|
+
// The plugin-namespaced `mcp__plugin_<plugin>_<server>__` form is the
|
|
147
|
+
// plugin's OWN server — never an undeclared reference (mirrors mcpToolServer).
|
|
148
|
+
if (/^plugin_/i.test(server))
|
|
149
|
+
continue;
|
|
150
|
+
const known = new Set([
|
|
151
|
+
...declaredServers,
|
|
152
|
+
...(dialect.knownMcpServers ?? []),
|
|
153
|
+
]);
|
|
154
|
+
// Guard 2: built-in server → skip.
|
|
155
|
+
if (known.has(server))
|
|
156
|
+
continue;
|
|
157
|
+
findings.push({
|
|
158
|
+
matcher,
|
|
159
|
+
kind: "mcp-undeclared",
|
|
160
|
+
message: `Hook matcher "${matcher}" references MCP server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the hook can't fire.`,
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// ── kind: tool-typo ─────────────────────────────────────────────────────
|
|
165
|
+
// A bare token that is NOT an exact built-in tool but IS a close typo of one.
|
|
166
|
+
const knownTools = new Set(dialect.builtinAgentTools);
|
|
167
|
+
if (knownTools.has(matcher))
|
|
168
|
+
continue; // exact match → no issue
|
|
169
|
+
// Reuse the same ≤ 2 edit-distance helper from tool-contract.ts.
|
|
170
|
+
const near = (0, tool_contract_js_1.closestTool)(matcher, dialect);
|
|
171
|
+
if (near === null)
|
|
172
|
+
continue; // far/unknown → likely a plugin tool, not a typo
|
|
173
|
+
findings.push({
|
|
174
|
+
matcher,
|
|
175
|
+
kind: "tool-typo",
|
|
176
|
+
suggestion: near,
|
|
177
|
+
message: `Hook matcher "${matcher}" doesn't match any built-in tool — the hook silently never fires. Did you mean "${near}"?`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return findings;
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=hook-matcher.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vigiles — hook settings normalization (the typed boundary for shipped hooks).
|
|
3
|
+
*
|
|
4
|
+
* A repo's hooks ship as raw, untrusted, parsed JSON/TOML — `unknown` at the
|
|
5
|
+
* loader edge (`LoadedPlugin.settings.hooks`). Rather than have every detector
|
|
6
|
+
* re-walk that `unknown` with inline casts (parse-don't-validate violated N
|
|
7
|
+
* times), this parses it ONCE at the boundary into a typed, flattened
|
|
8
|
+
* `HookRegistration[]` that the walkers consume.
|
|
9
|
+
*
|
|
10
|
+
* HARNESS-AGNOSTIC BY TOLERANCE, NOT BY A PORT. Two shipping shapes exist:
|
|
11
|
+
* - Claude Code (JSON): `{ Event: [{ matcher, hooks: [{ command }] }] }`
|
|
12
|
+
* - Codex (TOML): `{ Event: [{ command }] }` (`[[hooks.Event]] command=…`)
|
|
13
|
+
* A single tolerant reader absorbs both — a missing `hooks` array means the
|
|
14
|
+
* entry itself is the lone command holder. The difference is small enough that
|
|
15
|
+
* one parser covers it, so we deliberately DON'T add a per-harness port for it
|
|
16
|
+
* (rule-of-three / YAGNI: design the neutral shape first, defer the abstraction
|
|
17
|
+
* until a harness needs a genuinely divergent shape). If one ever does, this is
|
|
18
|
+
* the single seam to lift behind the layout/dialect.
|
|
19
|
+
*
|
|
20
|
+
* Pure + fully testable (no IO); the script resolution that turns a `command`
|
|
21
|
+
* into an on-disk path stays in the caller (it needs the plugin root + fs).
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* One flattened hook registration: a single command bound to an event, with its
|
|
25
|
+
* optional matcher. The neutral form every hook detector reads — CC-nested and
|
|
26
|
+
* Codex-flat both collapse to this.
|
|
27
|
+
*/
|
|
28
|
+
export interface HookRegistration {
|
|
29
|
+
/** The event the hook registers under, e.g. `"PreToolUse"`. */
|
|
30
|
+
readonly event: string;
|
|
31
|
+
/** The tool/path matcher, or `null` when the entry declares none. */
|
|
32
|
+
readonly matcher: string | null;
|
|
33
|
+
/** The shell command the hook runs (non-empty). */
|
|
34
|
+
readonly command: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse the raw `settings.hooks` value into typed registrations. Returns `[]`
|
|
38
|
+
* for any non-object / malformed input — never throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function normalizeHooks(raw: unknown): HookRegistration[];
|
|
41
|
+
/** Distinct event names present in the raw hooks object (object-keyed shape). */
|
|
42
|
+
export declare function hookEventNames(raw: unknown): string[];
|
|
43
|
+
//# sourceMappingURL=hook-normalize.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vigiles — hook settings normalization (the typed boundary for shipped hooks).
|
|
4
|
+
*
|
|
5
|
+
* A repo's hooks ship as raw, untrusted, parsed JSON/TOML — `unknown` at the
|
|
6
|
+
* loader edge (`LoadedPlugin.settings.hooks`). Rather than have every detector
|
|
7
|
+
* re-walk that `unknown` with inline casts (parse-don't-validate violated N
|
|
8
|
+
* times), this parses it ONCE at the boundary into a typed, flattened
|
|
9
|
+
* `HookRegistration[]` that the walkers consume.
|
|
10
|
+
*
|
|
11
|
+
* HARNESS-AGNOSTIC BY TOLERANCE, NOT BY A PORT. Two shipping shapes exist:
|
|
12
|
+
* - Claude Code (JSON): `{ Event: [{ matcher, hooks: [{ command }] }] }`
|
|
13
|
+
* - Codex (TOML): `{ Event: [{ command }] }` (`[[hooks.Event]] command=…`)
|
|
14
|
+
* A single tolerant reader absorbs both — a missing `hooks` array means the
|
|
15
|
+
* entry itself is the lone command holder. The difference is small enough that
|
|
16
|
+
* one parser covers it, so we deliberately DON'T add a per-harness port for it
|
|
17
|
+
* (rule-of-three / YAGNI: design the neutral shape first, defer the abstraction
|
|
18
|
+
* until a harness needs a genuinely divergent shape). If one ever does, this is
|
|
19
|
+
* the single seam to lift behind the layout/dialect.
|
|
20
|
+
*
|
|
21
|
+
* Pure + fully testable (no IO); the script resolution that turns a `command`
|
|
22
|
+
* into an on-disk path stays in the caller (it needs the plugin root + fs).
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.normalizeHooks = normalizeHooks;
|
|
26
|
+
exports.hookEventNames = hookEventNames;
|
|
27
|
+
/** True for a non-null, non-array object. */
|
|
28
|
+
function isRecord(v) {
|
|
29
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
/** The string `matcher` of an entry, or `null` when absent/empty. */
|
|
32
|
+
function entryMatcher(entry) {
|
|
33
|
+
const m = entry.matcher;
|
|
34
|
+
return typeof m === "string" && m.length > 0 ? m : null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Flatten ONE `{ event: [...] }` group's entry into registrations. Tolerant of
|
|
38
|
+
* both shapes: a Claude Code entry nests `hooks: [{command}]`; a Codex flat
|
|
39
|
+
* entry IS the command holder (no `hooks` array), so the entry stands in for it.
|
|
40
|
+
*/
|
|
41
|
+
function flattenEntry(event, entry) {
|
|
42
|
+
if (!isRecord(entry))
|
|
43
|
+
return [];
|
|
44
|
+
const matcher = entryMatcher(entry);
|
|
45
|
+
const nested = entry.hooks;
|
|
46
|
+
const holders = Array.isArray(nested) ? nested : [entry];
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const h of holders) {
|
|
49
|
+
if (!isRecord(h))
|
|
50
|
+
continue;
|
|
51
|
+
const command = h.command;
|
|
52
|
+
if (typeof command !== "string" || command.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
out.push({ event, matcher, command });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse the raw `settings.hooks` value into typed registrations. Returns `[]`
|
|
60
|
+
* for any non-object / malformed input — never throws.
|
|
61
|
+
*/
|
|
62
|
+
function normalizeHooks(raw) {
|
|
63
|
+
if (!isRecord(raw))
|
|
64
|
+
return [];
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const [event, arr] of Object.entries(raw)) {
|
|
67
|
+
if (!Array.isArray(arr))
|
|
68
|
+
continue;
|
|
69
|
+
for (const entry of arr)
|
|
70
|
+
out.push(...flattenEntry(event, entry));
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** Distinct event names present in the raw hooks object (object-keyed shape). */
|
|
75
|
+
function hookEventNames(raw) {
|
|
76
|
+
return isRecord(raw) ? Object.keys(raw) : [];
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=hook-normalize.js.map
|