@jjanczur/tyran 0.1.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/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +22 -0
- package/CHANGELOG.md +245 -0
- package/LICENSE +201 -0
- package/README.md +468 -0
- package/agents/.gitkeep +0 -0
- package/agents/implementer.md +60 -0
- package/agents/retro.md +88 -0
- package/agents/reviewer.md +55 -0
- package/agents/scout.md +60 -0
- package/bin/tyran.mjs +172 -0
- package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
- package/hooks/hooks.json +83 -0
- package/hooks/scripts/.gitkeep +0 -0
- package/hooks/scripts/evidence-gate.mjs +705 -0
- package/hooks/scripts/hook-io.mjs +813 -0
- package/hooks/scripts/policy-gate.mjs +1402 -0
- package/hooks/scripts/pre-compact.mjs +211 -0
- package/hooks/scripts/retro-gate.mjs +191 -0
- package/hooks/scripts/secrets-gate.mjs +1683 -0
- package/hooks/scripts/session-start.mjs +358 -0
- package/hooks/scripts/write-guard.mjs +475 -0
- package/package.json +52 -0
- package/scripts/desc-budget.mjs +139 -0
- package/scripts/doctor.mjs +1267 -0
- package/scripts/hooks-check.mjs +1312 -0
- package/scripts/invisible.mjs +346 -0
- package/scripts/journal.mjs +747 -0
- package/scripts/project.mjs +981 -0
- package/scripts/scan-control-chars.mjs +547 -0
- package/scripts/scan-repo.mjs +287 -0
- package/scripts/schema.mjs +467 -0
- package/scripts/stop-check.mjs +89 -0
- package/scripts/tiers.mjs +324 -0
- package/scripts/yaml-lite.mjs +383 -0
- package/skills/browser-check/SKILL.md +118 -0
- package/skills/code-review/SKILL.md +83 -0
- package/skills/deslop/SKILL.md +97 -0
- package/skills/doctor/SKILL.md +35 -0
- package/skills/fidelity-gate/SKILL.md +132 -0
- package/skills/hello/SKILL.md +16 -0
- package/skills/pr-feedback/SKILL.md +85 -0
- package/skills/prompt-tuning/SKILL.md +102 -0
- package/skills/retro/SKILL.md +69 -0
- package/skills/root-cause/SKILL.md +89 -0
- package/skills/run/SKILL.md +285 -0
- package/skills/setup/SKILL.md +79 -0
- package/skills/skill-writing/SKILL.md +99 -0
- package/skills/status/SKILL.md +31 -0
- package/templates/.gitkeep +0 -0
- package/templates/config.yaml +44 -0
- package/templates/knowledge.yaml +23 -0
- package/templates/policies/autonomy.yaml +61 -0
- package/templates/project-command/SKILL.md +16 -0
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hooks-check — is every gate this plugin declares actually ALIVE?
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* Everything else in this repository defends against a gate that decides
|
|
7
|
+
* wrongly. This one defends against a gate that is not there — which is the
|
|
8
|
+
* failure the platform hands us for free, and the only one no gate can catch
|
|
9
|
+
* from the inside.
|
|
10
|
+
*
|
|
11
|
+
* Measured on the shipped binary (v2.1.116, `hooks/HOOK-CONTRACT-MEASURED.md`):
|
|
12
|
+
* a hook file that is missing, or present without the execute bit, is spawned
|
|
13
|
+
* through `shell: true`, the shell exits 126/127 with empty stdout, and that
|
|
14
|
+
* lands in the "non-blocking status code" branch — **the action proceeds and
|
|
15
|
+
* nothing is printed**. A matcher containing a comma silently becomes an
|
|
16
|
+
* unanchored regex that matches no tool name that will ever exist. In both
|
|
17
|
+
* cases `hooks.json` still lists the gate, the manifest still validates, and
|
|
18
|
+
* the user still believes they are protected.
|
|
19
|
+
*
|
|
20
|
+
* So there exists a state in which the plugin is installed, every document
|
|
21
|
+
* says it guards, and it guards nothing.
|
|
22
|
+
*
|
|
23
|
+
* ## What this is, said in the words that are true
|
|
24
|
+
*
|
|
25
|
+
* **This is DETECTION, not ENFORCEMENT.** Nothing here can make a gate run.
|
|
26
|
+
* `doctor --hooks` reports; the `SessionStart` probe warns. Neither can refuse
|
|
27
|
+
* anything — `SessionStart` has no refusal channel at all (ADR-22) — and a
|
|
28
|
+
* README claiming the plugin "guarantees" its gates fire would be a false
|
|
29
|
+
* guarantee, which this project treats as a blocking defect rather than as
|
|
30
|
+
* marketing. What the check buys is that the silent state stops being silent.
|
|
31
|
+
*
|
|
32
|
+
* ## Why the matcher analysis duplicates platform logic, and how far
|
|
33
|
+
*
|
|
34
|
+
* `matcherMatches` below is a verbatim transcription of the platform's own
|
|
35
|
+
* predicate. Copying another program's logic is normally the thing this repo
|
|
36
|
+
* refuses to do, so the reason has to be stated rather than assumed: the
|
|
37
|
+
* predicate is not a rule we may choose, it is an OBSERVATION about a system
|
|
38
|
+
* we do not control, and there is no other way to answer "will this matcher
|
|
39
|
+
* ever fire?" than to evaluate the same expression the platform evaluates.
|
|
40
|
+
* The alternative — a hand-rolled approximation — would be a check that is
|
|
41
|
+
* confidently wrong, which is worse than no check.
|
|
42
|
+
*
|
|
43
|
+
* The cost is stated in one place, `PLATFORM_VERSION`, and it is real: the
|
|
44
|
+
* transcription is pinned to one version and a platform upgrade can silently
|
|
45
|
+
* make it stale. That is why every subject-based verdict is phrased as "this
|
|
46
|
+
* matcher does not match any subject KNOWN TO THIS CHECK" and why an open
|
|
47
|
+
* subject set can only ever produce a warning.
|
|
48
|
+
*/
|
|
49
|
+
import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
50
|
+
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
51
|
+
import { fileURLToPath } from 'node:url';
|
|
52
|
+
|
|
53
|
+
import { escapeInvisible } from './invisible.mjs';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The platform build every measured statement in this file was read from.
|
|
57
|
+
*
|
|
58
|
+
* Named, exported and printed in the report on purpose (ADR-22 correction 2:
|
|
59
|
+
* a measured fact carries its measurement conditions, never just its
|
|
60
|
+
* conclusion). A reader who upgrades Claude Code and sees this string is
|
|
61
|
+
* looking at a check whose model of the platform is one version behind.
|
|
62
|
+
*/
|
|
63
|
+
export const PLATFORM_VERSION = '2.1.116';
|
|
64
|
+
|
|
65
|
+
/** A journal-derived or config-derived value on its way into a message. */
|
|
66
|
+
const q = (value) => escapeInvisible(String(value));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every event name the platform's hook dispatcher knows, read out of the
|
|
70
|
+
* binary's own event table.
|
|
71
|
+
*
|
|
72
|
+
* An event key that is not in this set is not a typo the platform will
|
|
73
|
+
* complain about — the block is simply never dispatched, which is the same
|
|
74
|
+
* silent absence a deleted file produces. This is why the check exists at
|
|
75
|
+
* the level of the KEY and not only at the level of the file.
|
|
76
|
+
*/
|
|
77
|
+
export const PLATFORM_EVENTS = Object.freeze([
|
|
78
|
+
'PreToolUse',
|
|
79
|
+
'PostToolUse',
|
|
80
|
+
'PostToolUseFailure',
|
|
81
|
+
'Notification',
|
|
82
|
+
'UserPromptSubmit',
|
|
83
|
+
'UserPromptExpansion',
|
|
84
|
+
'SessionStart',
|
|
85
|
+
'SessionEnd',
|
|
86
|
+
'Stop',
|
|
87
|
+
'StopFailure',
|
|
88
|
+
'SubagentStart',
|
|
89
|
+
'SubagentStop',
|
|
90
|
+
'PreCompact',
|
|
91
|
+
'PostCompact',
|
|
92
|
+
'PermissionRequest',
|
|
93
|
+
'PermissionDenied',
|
|
94
|
+
'Setup',
|
|
95
|
+
'TeammateIdle',
|
|
96
|
+
'TaskCreated',
|
|
97
|
+
'TaskCompleted',
|
|
98
|
+
'Elicitation',
|
|
99
|
+
'ElicitationResult',
|
|
100
|
+
'ConfigChange',
|
|
101
|
+
'WorktreeCreate',
|
|
102
|
+
'WorktreeRemove',
|
|
103
|
+
'InstructionsLoaded',
|
|
104
|
+
'CwdChanged',
|
|
105
|
+
'FileChanged',
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Which input field the platform turns into the match query, per event —
|
|
110
|
+
* transcribed from the dispatcher's own switch.
|
|
111
|
+
*
|
|
112
|
+
* An event ABSENT from this table has no match query, and that is not a
|
|
113
|
+
* detail. Measured, verbatim:
|
|
114
|
+
*
|
|
115
|
+
* let z = ($ ? T.filter(k => !k.matcher || UB5($, k.matcher)) : T)
|
|
116
|
+
*
|
|
117
|
+
* When the query `$` is falsy the matcher list is **not filtered at all**.
|
|
118
|
+
* So on `Stop`, `UserPromptSubmit`, `TaskCreated`, `TaskCompleted` and
|
|
119
|
+
* `TeammateIdle` a matcher is inert: the hook fires whatever it says. The
|
|
120
|
+
* same expression is also the mechanism behind the empty-`agent_type` case
|
|
121
|
+
* that S-E3-2 had to work around — an empty string is falsy, so a
|
|
122
|
+
* `SubagentStop` carrying `agent_type: ""` runs EVERY hook registered for
|
|
123
|
+
* the event, matcher or no matcher. One ternary, two consequences, and
|
|
124
|
+
* neither is documented.
|
|
125
|
+
*/
|
|
126
|
+
export const MATCH_QUERY_FIELD = Object.freeze(
|
|
127
|
+
Object.assign(Object.create(null), {
|
|
128
|
+
PreToolUse: 'tool_name',
|
|
129
|
+
PostToolUse: 'tool_name',
|
|
130
|
+
PostToolUseFailure: 'tool_name',
|
|
131
|
+
PermissionRequest: 'tool_name',
|
|
132
|
+
PermissionDenied: 'tool_name',
|
|
133
|
+
UserPromptExpansion: 'command_name',
|
|
134
|
+
SessionStart: 'source',
|
|
135
|
+
Setup: 'trigger',
|
|
136
|
+
PreCompact: 'trigger',
|
|
137
|
+
PostCompact: 'trigger',
|
|
138
|
+
Notification: 'notification_type',
|
|
139
|
+
SessionEnd: 'reason',
|
|
140
|
+
StopFailure: 'error',
|
|
141
|
+
SubagentStart: 'agent_type',
|
|
142
|
+
SubagentStop: 'agent_type',
|
|
143
|
+
Elicitation: 'mcp_server_name',
|
|
144
|
+
ElicitationResult: 'mcp_server_name',
|
|
145
|
+
ConfigChange: 'source',
|
|
146
|
+
InstructionsLoaded: 'load_reason',
|
|
147
|
+
FileChanged: 'file_path (basename)',
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Events whose match query comes from a CLOSED enumeration in the platform's
|
|
153
|
+
* own input schema, so "this matcher matches none of them" is a fact rather
|
|
154
|
+
* than an opinion, and is reported as an error.
|
|
155
|
+
*
|
|
156
|
+
* `PreCompact`/`PostCompact` are `h.enum(["manual","auto"])` in the schema;
|
|
157
|
+
* `SessionStart`'s sources are the documented five.
|
|
158
|
+
*/
|
|
159
|
+
export const CLOSED_SUBJECTS = Object.freeze(
|
|
160
|
+
Object.assign(Object.create(null), {
|
|
161
|
+
SessionStart: Object.freeze(['startup', 'resume', 'clear', 'compact', 'fork']),
|
|
162
|
+
PreCompact: Object.freeze(['manual', 'auto']),
|
|
163
|
+
PostCompact: Object.freeze(['manual', 'auto']),
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The platform's tool-name alias table, read from the binary (`gJ6`).
|
|
169
|
+
*
|
|
170
|
+
* It is load-bearing for the matcher predicate in a way that is easy to miss:
|
|
171
|
+
* `normalise` is applied to the MATCHER and compared against the RAW query,
|
|
172
|
+
* so a `hooks.json` written against the old name `Task` still fires for a
|
|
173
|
+
* query of `Agent` — while a regex matcher is additionally retried against
|
|
174
|
+
* every alias OF the query. Both directions are transcribed below because
|
|
175
|
+
* getting one of them backwards would make this check disagree with the
|
|
176
|
+
* platform in the direction that reports a live gate as dead.
|
|
177
|
+
*/
|
|
178
|
+
export const TOOL_ALIASES = Object.freeze(
|
|
179
|
+
Object.assign(Object.create(null), {
|
|
180
|
+
Task: 'Agent',
|
|
181
|
+
KillShell: 'TaskStop',
|
|
182
|
+
AgentOutputTool: 'TaskOutput',
|
|
183
|
+
BashOutputTool: 'TaskOutput',
|
|
184
|
+
// The fifth entry, missed on the first pass and caught in review. The
|
|
185
|
+
// table is built as
|
|
186
|
+
// { Task: …, KillShell: …, AgentOutputTool: …, BashOutputTool: …,
|
|
187
|
+
// ...(BRIEF_TOOL_NAME ? { Brief: BRIEF_TOOL_NAME } : {}) }
|
|
188
|
+
// and BRIEF_TOOL_NAME is an imported constant equal to "SendUserMessage",
|
|
189
|
+
// never a runtime flag — so the spread is ALWAYS active and the table
|
|
190
|
+
// always has five entries.
|
|
191
|
+
//
|
|
192
|
+
// Worth stating why a missing row mattered rather than just fixing it: a
|
|
193
|
+
// matcher of "Brief" fires on the live platform, and this check called it
|
|
194
|
+
// dead. A LIVE gate reported as dead is the direction this module's own
|
|
195
|
+
// header calls inadmissible, and it happened in the one file whose entire
|
|
196
|
+
// value is fidelity of transcription.
|
|
197
|
+
Brief: 'SendUserMessage',
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Tool names this check knows about. Deliberately NOT presented as complete:
|
|
203
|
+
* MCP tools arrive as `mcp__<server>__<tool>` at runtime and a project may
|
|
204
|
+
* add more, so the set is OPEN and a matcher matching nothing in it can only
|
|
205
|
+
* ever be a warning here.
|
|
206
|
+
*
|
|
207
|
+
* Read from the binary's `filePatternTools` / `bashPrefixTools` tables and
|
|
208
|
+
* from the alias table above, rather than typed from memory.
|
|
209
|
+
*/
|
|
210
|
+
export const KNOWN_TOOLS = Object.freeze([
|
|
211
|
+
'Read',
|
|
212
|
+
'Write',
|
|
213
|
+
'Edit',
|
|
214
|
+
'Glob',
|
|
215
|
+
'Grep',
|
|
216
|
+
'NotebookRead',
|
|
217
|
+
'NotebookEdit',
|
|
218
|
+
'Bash',
|
|
219
|
+
'WebFetch',
|
|
220
|
+
'WebSearch',
|
|
221
|
+
'Agent',
|
|
222
|
+
'TaskOutput',
|
|
223
|
+
'TaskStop',
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The tools that put NEW content into a file, as the platform itself
|
|
228
|
+
* enumerates them for its own diff statistics (`sn_ = new Set([Edit, Write,
|
|
229
|
+
* NotebookEdit])`, where the three constants resolve to exactly those
|
|
230
|
+
* strings).
|
|
231
|
+
*
|
|
232
|
+
* Exported from here rather than from the guard that uses it, because it is a
|
|
233
|
+
* measured property of the PLATFORM and this module is where measured
|
|
234
|
+
* platform facts live. The write guard imports it; there is no second list.
|
|
235
|
+
*/
|
|
236
|
+
export const FILE_WRITING_TOOLS = Object.freeze(['Write', 'Edit', 'NotebookEdit']);
|
|
237
|
+
|
|
238
|
+
/** `normalise` from the matcher predicate: alias -> canonical, else identity. */
|
|
239
|
+
export function normalizeName(name) {
|
|
240
|
+
return Object.hasOwn(TOOL_ALIASES, name) ? TOOL_ALIASES[name] : name;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `aliasesOf` from the matcher predicate: canonical -> every alias of it. */
|
|
244
|
+
export function aliasesOf(name) {
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const [alias, canonical] of Object.entries(TOOL_ALIASES)) {
|
|
247
|
+
if (canonical === name) out.push(alias);
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The platform's matcher predicate, transcribed from v2.1.116.
|
|
254
|
+
*
|
|
255
|
+
* Kept as one expression-for-expression copy rather than "improved", so that
|
|
256
|
+
* a future reader can diff it against a new binary in one sitting. Every
|
|
257
|
+
* surprising consequence this repo relies on falls out of these six lines:
|
|
258
|
+
*
|
|
259
|
+
* - an empty matcher, or `*`, matches EVERYTHING;
|
|
260
|
+
* - a matcher of only `[a-zA-Z0-9_|]` is EQUALITY (or a `|`-list of
|
|
261
|
+
* equalities), never a substring — so `implementer` can never match
|
|
262
|
+
* `tyran:implementer`, measured live;
|
|
263
|
+
* - anything else is `new RegExp(matcher)`, UNANCHORED — so
|
|
264
|
+
* `tyran-implementer` also matches `evil-tyran-implementer-nope`;
|
|
265
|
+
* - an invalid regex matches NOTHING and only writes a debug line.
|
|
266
|
+
*
|
|
267
|
+
* The `.trim()` in the list branch is dead code in the platform, and the copy
|
|
268
|
+
* keeps it: the character class that guards the branch forbids whitespace, so
|
|
269
|
+
* no alternative can ever have any to trim. Noted rather than dropped,
|
|
270
|
+
* because a transcription that silently "fixes" its source stops being usable
|
|
271
|
+
* as a reference.
|
|
272
|
+
*/
|
|
273
|
+
export function matcherMatches(query, matcher) {
|
|
274
|
+
if (!matcher || matcher === '*') return true;
|
|
275
|
+
if (/^[a-zA-Z0-9_|]+$/.test(matcher)) {
|
|
276
|
+
if (matcher.includes('|')) {
|
|
277
|
+
return matcher.split('|').map((k) => normalizeName(k.trim())).includes(query);
|
|
278
|
+
}
|
|
279
|
+
return query === normalizeName(matcher);
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const re = new RegExp(matcher);
|
|
283
|
+
if (re.test(query)) return true;
|
|
284
|
+
for (const alias of aliasesOf(query)) if (re.test(alias)) return true;
|
|
285
|
+
return false;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Which branch of the predicate a matcher falls into. The whole point of the
|
|
293
|
+
* check is that authors believe they are in one branch and are in another.
|
|
294
|
+
*/
|
|
295
|
+
export function matcherBranch(matcher) {
|
|
296
|
+
if (!matcher || matcher === '*') return 'always';
|
|
297
|
+
if (/^[a-zA-Z0-9_|]+$/.test(matcher)) return matcher.includes('|') ? 'list' : 'exact';
|
|
298
|
+
try {
|
|
299
|
+
new RegExp(matcher);
|
|
300
|
+
return 'regex';
|
|
301
|
+
} catch {
|
|
302
|
+
return 'invalid-regex';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** True when a regex-branch matcher is anchored at both ends. */
|
|
307
|
+
function isAnchored(matcher) {
|
|
308
|
+
return matcher.startsWith('^') && matcher.endsWith('$') && !matcher.endsWith('\\$');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ------------------------------------------------------------- the findings
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Severity is a property of the finding CODE, declared once — the same rule,
|
|
315
|
+
* and the same reason, as `doctor.SEVERITY_BY_CODE`: a literal at each call
|
|
316
|
+
* site is N independent places where an `error` can quietly become an `info`
|
|
317
|
+
* while every test stays green.
|
|
318
|
+
*
|
|
319
|
+
* doctor merges this table into its own and asserts the two do not collide,
|
|
320
|
+
* so a code cannot end up with two severities depending on who rendered it.
|
|
321
|
+
*/
|
|
322
|
+
export const HOOK_SEVERITY_BY_CODE = Object.freeze(
|
|
323
|
+
Object.assign(Object.create(null), {
|
|
324
|
+
// the manifest and the hooks file itself
|
|
325
|
+
'hooks-manifest-missing': 'error',
|
|
326
|
+
'hooks-manifest-unreadable': 'error',
|
|
327
|
+
'hooks-manifest-no-hooks': 'error',
|
|
328
|
+
'hooks-file-missing': 'error',
|
|
329
|
+
'hooks-file-unreadable': 'error',
|
|
330
|
+
'hooks-file-invalid': 'error',
|
|
331
|
+
'hooks-file-empty': 'warning',
|
|
332
|
+
// the event key
|
|
333
|
+
'hook-event-unknown': 'error',
|
|
334
|
+
'hook-event-not-a-list': 'error',
|
|
335
|
+
'hook-entry-malformed': 'error',
|
|
336
|
+
// the command and its file
|
|
337
|
+
'hook-command-missing': 'error',
|
|
338
|
+
'hook-command-not-modellable': 'warning',
|
|
339
|
+
'hook-path-unquoted': 'error',
|
|
340
|
+
'hook-file-absent': 'error',
|
|
341
|
+
'hook-file-not-a-file': 'error',
|
|
342
|
+
'hook-file-unreadable': 'error',
|
|
343
|
+
'hook-not-executable': 'error',
|
|
344
|
+
'hook-no-shebang': 'error',
|
|
345
|
+
'hook-interpreter-absent': 'error',
|
|
346
|
+
'hook-type-unchecked': 'info',
|
|
347
|
+
'hook-duplicate-command': 'warning',
|
|
348
|
+
// keys on the ENTRY that disarm a gate while everything else looks right
|
|
349
|
+
'hook-async-on-gate': 'error',
|
|
350
|
+
'hook-once-on-gate': 'error',
|
|
351
|
+
'hook-conditional-gate': 'error',
|
|
352
|
+
'hook-foreign-shell': 'error',
|
|
353
|
+
// the timeout
|
|
354
|
+
'hook-no-timeout': 'warning',
|
|
355
|
+
'hook-timeout-implausible': 'warning',
|
|
356
|
+
// the matcher
|
|
357
|
+
'matcher-invalid-regex': 'error',
|
|
358
|
+
'matcher-comma-separated': 'error',
|
|
359
|
+
'matcher-matches-nothing-closed': 'error',
|
|
360
|
+
'matcher-matches-nothing-known': 'warning',
|
|
361
|
+
'matcher-whitespace': 'warning',
|
|
362
|
+
'matcher-unanchored': 'warning',
|
|
363
|
+
'matcher-ignored-by-event': 'warning',
|
|
364
|
+
'matcher-matches-everything': 'info',
|
|
365
|
+
// cross-checks between the file and the script it names
|
|
366
|
+
'hook-event-declaration-mismatch': 'warning',
|
|
367
|
+
'hook-namespace-drift': 'error',
|
|
368
|
+
// accounting
|
|
369
|
+
'hooks-ok': 'info',
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
/** The severity of a hook finding code, or a loud failure. See doctor's twin. */
|
|
374
|
+
export function hookSeverityFor(code) {
|
|
375
|
+
const severity = HOOK_SEVERITY_BY_CODE[code];
|
|
376
|
+
if (severity === undefined) {
|
|
377
|
+
throw new Error(`hooks-check bug: finding code "${code}" has no severity in HOOK_SEVERITY_BY_CODE`);
|
|
378
|
+
}
|
|
379
|
+
return severity;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function finding(code, where, message, fix = null) {
|
|
383
|
+
return { severity: hookSeverityFor(code), code, where, message, fix };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ------------------------------------------------- keys on the hook entry
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Events on which a hook is a GATE — i.e. the platform will read a refusal
|
|
390
|
+
* from it. Kept here rather than imported from `hook-io.EVENTS` on purpose:
|
|
391
|
+
* this module is the one that models the PLATFORM, and `hook-io` models what
|
|
392
|
+
* OUR runtime is willing to answer. They agree today and a test asserts it,
|
|
393
|
+
* but they are answers to different questions and merging them would make the
|
|
394
|
+
* next disagreement invisible.
|
|
395
|
+
*/
|
|
396
|
+
export const BLOCKING_EVENTS = Object.freeze([
|
|
397
|
+
'PreToolUse',
|
|
398
|
+
'UserPromptSubmit',
|
|
399
|
+
'Stop',
|
|
400
|
+
'SubagentStop',
|
|
401
|
+
'PreCompact',
|
|
402
|
+
'TaskCreated',
|
|
403
|
+
'TaskCompleted',
|
|
404
|
+
'TeammateIdle',
|
|
405
|
+
'PermissionRequest',
|
|
406
|
+
'ConfigChange',
|
|
407
|
+
'PostToolBatch',
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
/** The platform's default `shell` for a command hook. */
|
|
411
|
+
export const DEFAULT_SHELL = 'bash';
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The largest `timeout` that can plausibly be a number of SECONDS.
|
|
415
|
+
*
|
|
416
|
+
* The platform's own default is 600 and the field is documented as seconds.
|
|
417
|
+
* A value above that is almost always a millisecond figure pasted into a
|
|
418
|
+
* seconds field — `10000` reads as ten seconds to the author and buys the
|
|
419
|
+
* hook two hours and 46 minutes, during which a hung gate holds the tool call.
|
|
420
|
+
*/
|
|
421
|
+
export const MAX_PLAUSIBLE_TIMEOUT_S = 600;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Keys on a hook ENTRY that turn a gate into decoration.
|
|
425
|
+
*
|
|
426
|
+
* This is the fifth failure variant, and it is the worst of them, because
|
|
427
|
+
* every earlier check passes: the file exists, is executable, has a shebang,
|
|
428
|
+
* the matcher is correct and the event is real. Measured live by review, same
|
|
429
|
+
* payload, one key changed:
|
|
430
|
+
*
|
|
431
|
+
* bare entry -> refused, the file was never written
|
|
432
|
+
* + "async": true -> PASSED, raw TAG characters landed on disk
|
|
433
|
+
* + "if": "Bash(git *)" -> PASSED
|
|
434
|
+
* + "shell": "powershell" -> PASSED
|
|
435
|
+
*
|
|
436
|
+
* and in every case a logger on the same matcher fired normally, so dispatch
|
|
437
|
+
* and matching were both working. `doctor --hooks` printed `healthy`.
|
|
438
|
+
*
|
|
439
|
+
* That is a false guarantee produced by the tool built to detect false
|
|
440
|
+
* guarantees, so these are ERRORS rather than warnings — with one shared
|
|
441
|
+
* justification, taken from the schema's own descriptions:
|
|
442
|
+
*
|
|
443
|
+
* - `async` / `asyncRewake`: "hook runs in background without blocking".
|
|
444
|
+
* A backgrounded hook has no channel to return a decision through. The
|
|
445
|
+
* gate cannot refuse. There is no severity below error for that.
|
|
446
|
+
* - `once`: "hook runs once and is removed after execution". A gate that
|
|
447
|
+
* guards the first write and nothing after it is worse than no gate,
|
|
448
|
+
* because the first run is the one people test with.
|
|
449
|
+
* - `shell`: the command is handed to a different interpreter than the one
|
|
450
|
+
* it was written for; our hooks are `#!`-dispatched POSIX invocations and
|
|
451
|
+
* the failure under pwsh is the silent 127 kind.
|
|
452
|
+
* - `if`: the condition is a language this check does not evaluate, so the
|
|
453
|
+
* gate's coverage is UNKNOWN. Unknown coverage on a control is treated as
|
|
454
|
+
* failure everywhere else in this repository — the secrets gate refuses
|
|
455
|
+
* rather than scanning a prefix — and this is the same call.
|
|
456
|
+
*
|
|
457
|
+
* On a NON-blocking event none of these is an error: a probe that runs in the
|
|
458
|
+
* background, once, or conditionally is a legitimate design. The severity is
|
|
459
|
+
* a property of the pair (key, event), which is why this takes the event.
|
|
460
|
+
*/
|
|
461
|
+
export function entryKeyFindings(event, hook, where) {
|
|
462
|
+
const findings = [];
|
|
463
|
+
if (!BLOCKING_EVENTS.includes(event)) return findings;
|
|
464
|
+
|
|
465
|
+
if (hook.async === true || hook.asyncRewake === true) {
|
|
466
|
+
const key = hook.async === true ? 'async' : 'asyncRewake';
|
|
467
|
+
findings.push(
|
|
468
|
+
finding(
|
|
469
|
+
'hook-async-on-gate',
|
|
470
|
+
where,
|
|
471
|
+
`"${key}": true on ${q(event)}, which is an event the platform reads a REFUSAL from. The schema ` +
|
|
472
|
+
'describes the key as "hook runs in background without blocking" (asyncRewake implies async), and ' +
|
|
473
|
+
'a backgrounded hook has no channel to return a decision — measured live: the identical gate ' +
|
|
474
|
+
'refused without this key and PASSED with it, writing the payload to disk. Every other check ' +
|
|
475
|
+
'here still passes, which is what makes this the dangerous one.',
|
|
476
|
+
`remove "${key}" from this entry, or move the hook to a non-blocking event`,
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
if (hook.once === true) {
|
|
481
|
+
findings.push(
|
|
482
|
+
finding(
|
|
483
|
+
'hook-once-on-gate',
|
|
484
|
+
where,
|
|
485
|
+
`"once": true on ${q(event)}. The schema describes it as "hook runs once and is removed after ` +
|
|
486
|
+
'execution", so this gate guards the first occurrence and nothing after it — and the first ' +
|
|
487
|
+
'occurrence is the one anybody testing the installation will use.',
|
|
488
|
+
'remove "once" from this entry',
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (hook.if !== undefined && hook.if !== null && hook.if !== '') {
|
|
493
|
+
findings.push(
|
|
494
|
+
finding(
|
|
495
|
+
'hook-conditional-gate',
|
|
496
|
+
where,
|
|
497
|
+
`"if": ${JSON.stringify(q(String(hook.if)))} on ${q(event)}. The condition is evaluated by the ` +
|
|
498
|
+
'platform in a language this check does not interpret, so what this gate actually covers is ' +
|
|
499
|
+
'UNKNOWN — it is narrower than its matcher claims by an unknown amount. Measured live: a gate ' +
|
|
500
|
+
'whose matcher covered the call PASSED the payload because the condition did not match. Unknown ' +
|
|
501
|
+
'coverage on a control is treated as failure everywhere else here (the secrets gate refuses ' +
|
|
502
|
+
'rather than scanning a prefix); this is the same call.',
|
|
503
|
+
'drop the condition and narrow inside the hook, where the reasoning is testable',
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (hook.shell !== undefined && hook.shell !== DEFAULT_SHELL) {
|
|
508
|
+
findings.push(
|
|
509
|
+
finding(
|
|
510
|
+
'hook-foreign-shell',
|
|
511
|
+
where,
|
|
512
|
+
`"shell": ${JSON.stringify(q(String(hook.shell)))} on ${q(event)}. The command is handed to an ` +
|
|
513
|
+
`interpreter other than the default ${DEFAULT_SHELL}, and these hooks are POSIX invocations ` +
|
|
514
|
+
'dispatched through a shebang. Measured live: the gate PASSED the payload under pwsh, in the ' +
|
|
515
|
+
'silent way — the interpreter fails, the platform records a non-blocking error, the action ' +
|
|
516
|
+
'proceeds.',
|
|
517
|
+
`remove "shell", or provide a command the named interpreter can run`,
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
return findings;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// -------------------------------------------------------------- the command
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Characters that make a command line something other than "one program and
|
|
528
|
+
* its literal arguments". Same doctrine as the secrets gate: enumerate what
|
|
529
|
+
* is inert and refuse to model the rest, rather than guess and be confidently
|
|
530
|
+
* wrong about which file is about to run.
|
|
531
|
+
*/
|
|
532
|
+
const SHELL_METACHARACTERS = ';&|<>()';
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Lex a hooks.json command into argv, recording which variable references
|
|
536
|
+
* were left UNQUOTED.
|
|
537
|
+
*
|
|
538
|
+
* The quoting matters and it is measured, not stylistic: the platform spawns
|
|
539
|
+
* the command with `shell: true`, so an unquoted `${CLAUDE_PLUGIN_ROOT}`
|
|
540
|
+
* whose value contains a space is split by the shell into two words. The
|
|
541
|
+
* first is then a path that does not exist, the shell exits 127, and — per
|
|
542
|
+
* the failure table above — the action proceeds silently. A user whose
|
|
543
|
+
* checkout lives under `~/Library/Application Support/...` therefore installs
|
|
544
|
+
* a plugin whose every gate is dead, with nothing to read anywhere.
|
|
545
|
+
*/
|
|
546
|
+
export function lexCommand(command) {
|
|
547
|
+
const argv = [];
|
|
548
|
+
const unquotedVars = [];
|
|
549
|
+
const metachars = [];
|
|
550
|
+
const expansions = [];
|
|
551
|
+
let current = '';
|
|
552
|
+
let started = false;
|
|
553
|
+
let quote = null; // null | "'" | '"'
|
|
554
|
+
for (let i = 0; i < command.length; i++) {
|
|
555
|
+
const ch = command[i];
|
|
556
|
+
if (quote === null && ch === '\\') {
|
|
557
|
+
const next = command[++i];
|
|
558
|
+
if (next !== undefined) {
|
|
559
|
+
current += next;
|
|
560
|
+
started = true;
|
|
561
|
+
}
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (quote === null && (ch === "'" || ch === '"')) {
|
|
565
|
+
quote = ch;
|
|
566
|
+
started = true;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (quote !== null && ch === quote) {
|
|
570
|
+
quote = null;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (quote === "'") {
|
|
574
|
+
current += ch;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// Outside single quotes a `${...}` is a shell expansion. Record the ones
|
|
578
|
+
// that are NOT inside double quotes; those are the dangerous ones.
|
|
579
|
+
if (ch === '$' && command[i + 1] === '{') {
|
|
580
|
+
const close = command.indexOf('}', i + 2);
|
|
581
|
+
if (close !== -1) {
|
|
582
|
+
const name = command.slice(i + 2, close);
|
|
583
|
+
expansions.push(name);
|
|
584
|
+
if (quote === null) unquotedVars.push(name);
|
|
585
|
+
current += command.slice(i, close + 1);
|
|
586
|
+
started = true;
|
|
587
|
+
i = close;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (quote === null && (ch === ' ' || ch === '\t' || ch === '\n')) {
|
|
592
|
+
if (started) argv.push(current);
|
|
593
|
+
current = '';
|
|
594
|
+
started = false;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (quote === null && (SHELL_METACHARACTERS.includes(ch) || ch === '`' || ch === '$')) {
|
|
598
|
+
metachars.push(ch);
|
|
599
|
+
}
|
|
600
|
+
current += ch;
|
|
601
|
+
started = true;
|
|
602
|
+
}
|
|
603
|
+
if (started) argv.push(current);
|
|
604
|
+
return { argv, unquotedVars, metachars, expansions, unterminatedQuote: quote !== null };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Substitute the path variables this check can resolve. */
|
|
608
|
+
function substitute(token, vars) {
|
|
609
|
+
return token.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, name) =>
|
|
610
|
+
Object.hasOwn(vars, name) ? vars[name] : whole,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Is `interpreter` reachable? Absolute path, or a bare name on PATH. */
|
|
615
|
+
function interpreterExists(interpreter, env) {
|
|
616
|
+
if (interpreter.includes('/')) return existsSync(interpreter);
|
|
617
|
+
const path = env.PATH ?? '';
|
|
618
|
+
for (const dir of path.split(delimiter)) {
|
|
619
|
+
if (dir !== '' && existsSync(join(dir, interpreter))) return true;
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The interpreter a shebang line names, resolved through `/usr/bin/env`.
|
|
626
|
+
* Returns null when the line is not a shebang at all.
|
|
627
|
+
*/
|
|
628
|
+
export function shebangInterpreter(firstLine) {
|
|
629
|
+
if (!firstLine.startsWith('#!')) return null;
|
|
630
|
+
const words = firstLine.slice(2).trim().split(/\s+/).filter((w) => w !== '');
|
|
631
|
+
if (words.length === 0) return null;
|
|
632
|
+
if (words[0].endsWith('/env') || words[0] === 'env') {
|
|
633
|
+
// `#!/usr/bin/env -S node --flag` is legal; skip the flags to find the name.
|
|
634
|
+
for (const word of words.slice(1)) {
|
|
635
|
+
if (!word.startsWith('-') && !word.includes('=')) return word;
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
return words[0];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ---------------------------------------------------------- the file checks
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Everything that can be wrong with the FILE a hook entry names.
|
|
646
|
+
*
|
|
647
|
+
* Split out so each condition is reachable from a test without constructing a
|
|
648
|
+
* whole plugin tree, and so the order is explicit: a missing file must not
|
|
649
|
+
* also be reported as "no shebang", which would bury the finding that
|
|
650
|
+
* actually says what to do.
|
|
651
|
+
*/
|
|
652
|
+
function checkHookFile(path, where, { env }) {
|
|
653
|
+
const findings = [];
|
|
654
|
+
let stat;
|
|
655
|
+
try {
|
|
656
|
+
stat = statSync(path);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
if (err.code === 'ENOENT') {
|
|
659
|
+
findings.push(
|
|
660
|
+
finding(
|
|
661
|
+
'hook-file-absent',
|
|
662
|
+
where,
|
|
663
|
+
`the hook file does not exist. The platform spawns hook commands through a shell, so a ` +
|
|
664
|
+
`missing file makes the shell exit 127 with empty stdout — which the platform records as a ` +
|
|
665
|
+
`non-blocking error and THE ACTION PROCEEDS. This gate is not weakened, it is absent, and ` +
|
|
666
|
+
`nothing anywhere says so.`,
|
|
667
|
+
`ls -l ${q(path)} # then reinstall the plugin, or restore the file from git`,
|
|
668
|
+
),
|
|
669
|
+
);
|
|
670
|
+
} else {
|
|
671
|
+
findings.push(
|
|
672
|
+
finding('hook-file-unreadable', where, `cannot stat the hook file (${q(err.code ?? err.message)})`),
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
return findings;
|
|
676
|
+
}
|
|
677
|
+
if (!stat.isFile()) {
|
|
678
|
+
findings.push(finding('hook-file-not-a-file', where, 'the hook command names something that is not a file'));
|
|
679
|
+
return findings;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// TWO tests, and the mode bits come first deliberately. `accessSync(X_OK)`
|
|
683
|
+
// answers "may THIS process execute it", which depends on who is running —
|
|
684
|
+
// and under uid 0 in a container the answer can be yes for a file the shell
|
|
685
|
+
// still cannot dispatch. The mode bits answer "is this file executable at
|
|
686
|
+
// all", which is the property the platform's `shell: true` spawn depends on
|
|
687
|
+
// and is the same on every machine. Raised in review as a CI hazard that
|
|
688
|
+
// would have disarmed the mutant for this guard; measuring the bits removes
|
|
689
|
+
// the dependency instead of hoping about the runner.
|
|
690
|
+
let executable = (stat.mode & 0o111) !== 0;
|
|
691
|
+
if (executable) {
|
|
692
|
+
try {
|
|
693
|
+
accessSync(path, constants.X_OK);
|
|
694
|
+
} catch {
|
|
695
|
+
executable = false;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (!executable) {
|
|
699
|
+
findings.push(
|
|
700
|
+
finding(
|
|
701
|
+
'hook-not-executable',
|
|
702
|
+
where,
|
|
703
|
+
`the hook file exists but is not executable (mode ${(stat.mode & 0o777).toString(8)}). The shell ` +
|
|
704
|
+
`exits 126, the platform records a non-blocking error, and THE ACTION PROCEEDS. An installer ` +
|
|
705
|
+
`that loses the execute bit disables every gate it copies, silently.`,
|
|
706
|
+
`chmod +x ${q(path)}`,
|
|
707
|
+
),
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
let head;
|
|
712
|
+
try {
|
|
713
|
+
head = readFileSync(path, 'utf8').split('\n', 1)[0] ?? '';
|
|
714
|
+
} catch (err) {
|
|
715
|
+
findings.push(
|
|
716
|
+
finding('hook-file-unreadable', where, `cannot read the hook file (${q(err.code ?? err.message)})`),
|
|
717
|
+
);
|
|
718
|
+
return findings;
|
|
719
|
+
}
|
|
720
|
+
const interpreter = shebangInterpreter(head);
|
|
721
|
+
if (interpreter === null) {
|
|
722
|
+
findings.push(
|
|
723
|
+
finding(
|
|
724
|
+
'hook-no-shebang',
|
|
725
|
+
where,
|
|
726
|
+
'the hook file has no shebang line. It is spawned as a program, not passed to node, so without ' +
|
|
727
|
+
'`#!/usr/bin/env node` the shell tries to interpret JavaScript as shell script — which fails ' +
|
|
728
|
+
'in the non-blocking way, so the action proceeds.',
|
|
729
|
+
`add "#!/usr/bin/env node" as the first line of ${q(path)}`,
|
|
730
|
+
),
|
|
731
|
+
);
|
|
732
|
+
} else if (!interpreterExists(interpreter, env)) {
|
|
733
|
+
findings.push(
|
|
734
|
+
finding(
|
|
735
|
+
'hook-interpreter-absent',
|
|
736
|
+
where,
|
|
737
|
+
`the shebang names the interpreter "${q(interpreter)}", which is not on PATH and is not an ` +
|
|
738
|
+
'existing absolute path. The spawn fails in the non-blocking way, so the action proceeds.',
|
|
739
|
+
`command -v ${q(interpreter)}`,
|
|
740
|
+
),
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
return findings;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ------------------------------------------------------- the matcher checks
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Everything that can be wrong with a matcher, given the event it sits on.
|
|
750
|
+
*
|
|
751
|
+
* `subjects` is the vocabulary this check knows for the event; `closed` says
|
|
752
|
+
* whether that vocabulary is the whole world. The distinction is the entire
|
|
753
|
+
* difference between an error and a warning here, and it is why the two are
|
|
754
|
+
* separate finding codes rather than one code with a variable severity.
|
|
755
|
+
*/
|
|
756
|
+
export function analyzeMatcher(event, matcher, where, { subjects, closed, namespace = null }) {
|
|
757
|
+
const findings = [];
|
|
758
|
+
const hasQuery = Object.hasOwn(MATCH_QUERY_FIELD, event);
|
|
759
|
+
|
|
760
|
+
if (!hasQuery) {
|
|
761
|
+
if (matcher !== undefined && matcher !== '' && matcher !== '*') {
|
|
762
|
+
findings.push(
|
|
763
|
+
finding(
|
|
764
|
+
'matcher-ignored-by-event',
|
|
765
|
+
where,
|
|
766
|
+
`this entry sets matcher "${q(matcher)}", but the platform builds no match query for ${q(event)} ` +
|
|
767
|
+
'— measured, the filter is `($ ? T.filter(...) : T)`, so with no query the matcher list is not ' +
|
|
768
|
+
'filtered at all. The hook fires for EVERY occurrence of the event. The matcher is not doing ' +
|
|
769
|
+
'what it says; whatever it was meant to narrow has to be re-checked inside the hook.',
|
|
770
|
+
'remove the matcher, and narrow inside the hook script instead',
|
|
771
|
+
),
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
return findings;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (matcher === undefined || matcher === '' || matcher === '*') {
|
|
778
|
+
findings.push(
|
|
779
|
+
finding(
|
|
780
|
+
'matcher-matches-everything',
|
|
781
|
+
where,
|
|
782
|
+
`matcher ${matcher === undefined ? '(absent)' : `"${q(matcher)}"`} matches every ` +
|
|
783
|
+
`${q(MATCH_QUERY_FIELD[event])} for this event. That is a deliberate shape, not a defect — ` +
|
|
784
|
+
'stated so it is a choice on the record rather than an omission.',
|
|
785
|
+
),
|
|
786
|
+
);
|
|
787
|
+
return findings;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const branch = matcherBranch(matcher);
|
|
791
|
+
|
|
792
|
+
if (branch === 'invalid-regex') {
|
|
793
|
+
findings.push(
|
|
794
|
+
finding(
|
|
795
|
+
'matcher-invalid-regex',
|
|
796
|
+
where,
|
|
797
|
+
`matcher "${q(matcher)}" is not valid as a regular expression and does not fit the exact-match ` +
|
|
798
|
+
'character class, so the platform catches the error locally and the predicate returns false ' +
|
|
799
|
+
'FOREVER. This hook can never fire. The only trace is a debug-level log line.',
|
|
800
|
+
'fix the expression, or write the matcher as an exact `A|B` list',
|
|
801
|
+
),
|
|
802
|
+
);
|
|
803
|
+
return findings;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// The measured killer: `Edit, Write` reads as a list, is valid JSON, appears
|
|
807
|
+
// in the manifest, and matches nothing that will ever exist.
|
|
808
|
+
if (branch === 'regex' && matcher.includes(',')) {
|
|
809
|
+
findings.push(
|
|
810
|
+
finding(
|
|
811
|
+
'matcher-comma-separated',
|
|
812
|
+
where,
|
|
813
|
+
`matcher "${q(matcher)}" contains a comma. The platform never splits a matcher on commas: the ` +
|
|
814
|
+
'exact-match branch is guarded by `/^[a-zA-Z0-9_|]+$/`, which a comma fails, so this became an ' +
|
|
815
|
+
`unanchored regex that can only match a ${q(MATCH_QUERY_FIELD[event])} CONTAINING the literal ` +
|
|
816
|
+
`text "${q(matcher)}". Measured: matcher "Edit, Write" matches neither "Edit" nor "Write". ` +
|
|
817
|
+
'The entry looks installed and never fires.',
|
|
818
|
+
`write it as an alternation instead: "${q(matcher.split(/\s*,\s*/).filter((s) => s !== '').join('|'))}"`,
|
|
819
|
+
),
|
|
820
|
+
);
|
|
821
|
+
} else if (branch === 'regex' && /\s/.test(matcher)) {
|
|
822
|
+
findings.push(
|
|
823
|
+
finding(
|
|
824
|
+
'matcher-whitespace',
|
|
825
|
+
where,
|
|
826
|
+
`matcher "${q(matcher)}" contains whitespace, which puts it in the regex branch (the exact-match ` +
|
|
827
|
+
'character class has no space in it). A regex containing a literal space matches only a subject ' +
|
|
828
|
+
'containing that space, and no tool name, source or trigger does.',
|
|
829
|
+
'remove the whitespace; use `A|B` for a list',
|
|
830
|
+
),
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// A matcher that matches every probe — including the empty string and a
|
|
835
|
+
// string nobody would ever name — is a WILDCARD, whatever its syntax. Saying
|
|
836
|
+
// ".+|^$ is unanchored" would be true, useless, and exactly the kind of
|
|
837
|
+
// noise that gets a check switched off: the author wrote it precisely so
|
|
838
|
+
// that it would match everything, including the measured empty-`agent_type`
|
|
839
|
+
// case. Report the shape that is actually there.
|
|
840
|
+
const probes = [...subjects, '', 'x', 'zzz-no-such-subject-9'];
|
|
841
|
+
if (probes.every((p) => matcherMatches(p, matcher))) {
|
|
842
|
+
findings.push(
|
|
843
|
+
finding(
|
|
844
|
+
'matcher-matches-everything',
|
|
845
|
+
where,
|
|
846
|
+
`matcher "${q(matcher)}" matches every ${q(MATCH_QUERY_FIELD[event])} this check can construct, ` +
|
|
847
|
+
'including the empty string. That is a wildcard written the long way — a deliberate shape for ' +
|
|
848
|
+
'an event whose subject must be re-checked inside the hook, and it is recorded here so the ' +
|
|
849
|
+
'choice is on the record rather than mistaken for a narrowing that failed.',
|
|
850
|
+
),
|
|
851
|
+
);
|
|
852
|
+
return findings;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (branch === 'regex' && !isAnchored(matcher)) {
|
|
856
|
+
findings.push(
|
|
857
|
+
finding(
|
|
858
|
+
'matcher-unanchored',
|
|
859
|
+
where,
|
|
860
|
+
`matcher "${q(matcher)}" is applied with \`new RegExp(matcher)\` and is NOT anchored, so it ` +
|
|
861
|
+
'matches anywhere inside the subject. Measured: `tyran-implementer` also matches ' +
|
|
862
|
+
'`evil-tyran-implementer-nope`. For a gate this widens what it fires on; for an EXEMPTION it ' +
|
|
863
|
+
'widens what escapes.',
|
|
864
|
+
matcher.includes('|')
|
|
865
|
+
? 'anchor EACH alternative — `^a$|^b$`, not `^a|b$`, which anchors only the outer two'
|
|
866
|
+
: `anchor it: "^${q(matcher)}$"`,
|
|
867
|
+
),
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// The namespace check, which this module's header promised and the first
|
|
872
|
+
// pass never emitted — a declared severity with no code behind it is a line
|
|
873
|
+
// no mutant can kill, i.e. exactly the decoration this file exists to find.
|
|
874
|
+
//
|
|
875
|
+
// Measured: an agent's `agent_type` is `<name from plugin.json>:<agent>`,
|
|
876
|
+
// and the namespace comes from the MANIFEST, not the install directory. So
|
|
877
|
+
// renaming the plugin silently disarms every matcher that spells the old
|
|
878
|
+
// name, and nothing else in the system notices.
|
|
879
|
+
if (namespace !== null && (event === 'SubagentStart' || event === 'SubagentStop')) {
|
|
880
|
+
for (const [, spelled] of matcher.matchAll(/([A-Za-z0-9_-]+):/g)) {
|
|
881
|
+
if (spelled === namespace) continue;
|
|
882
|
+
findings.push(
|
|
883
|
+
finding(
|
|
884
|
+
'hook-namespace-drift',
|
|
885
|
+
where,
|
|
886
|
+
`matcher "${q(matcher)}" spells the namespace "${q(spelled)}:", but this plugin's manifest says ` +
|
|
887
|
+
`its name is "${q(namespace)}" — so its agents present as "${q(namespace)}:<agent>". A plugin ` +
|
|
888
|
+
'rename changes every agent_type at once and leaves the matchers behind; the hook then never ' +
|
|
889
|
+
'fires and nothing reports it.',
|
|
890
|
+
`spell it "${q(namespace)}:", or change "name" in .claude-plugin/plugin.json back`,
|
|
891
|
+
),
|
|
892
|
+
);
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (subjects.length === 0) return findings;
|
|
898
|
+
|
|
899
|
+
const hits = subjects.filter((s) => matcherMatches(s, matcher));
|
|
900
|
+
if (hits.length > 0) return findings;
|
|
901
|
+
|
|
902
|
+
const listed = subjects.map((s) => `"${q(s)}"`).join(', ');
|
|
903
|
+
if (closed) {
|
|
904
|
+
findings.push(
|
|
905
|
+
finding(
|
|
906
|
+
'matcher-matches-nothing-closed',
|
|
907
|
+
where,
|
|
908
|
+
`matcher "${q(matcher)}" matches none of the ${subjects.length} values ${q(event)} can ever carry ` +
|
|
909
|
+
`in ${q(MATCH_QUERY_FIELD[event])} (${listed}) — that set is closed by the platform's own input ` +
|
|
910
|
+
'schema, so this hook can never fire.',
|
|
911
|
+
'correct the matcher to one of the values above',
|
|
912
|
+
),
|
|
913
|
+
);
|
|
914
|
+
} else {
|
|
915
|
+
findings.push(
|
|
916
|
+
finding(
|
|
917
|
+
'matcher-matches-nothing-known',
|
|
918
|
+
where,
|
|
919
|
+
`matcher "${q(matcher)}" matches none of the ${q(MATCH_QUERY_FIELD[event])} values this check ` +
|
|
920
|
+
`knows about (${listed}). That set is OPEN — MCP tools arrive as \`mcp__server__tool\` and a ` +
|
|
921
|
+
'project may define its own agents — so this is a warning, not a verdict. If the matcher aims ' +
|
|
922
|
+
'at something outside the list, it is fine; if it aims at something in it, it is dead.' +
|
|
923
|
+
(matcherBranch(matcher) === 'exact' || matcherBranch(matcher) === 'list'
|
|
924
|
+
? ' Note this matcher is in the EQUALITY branch: it is not a substring test, so a value ' +
|
|
925
|
+
'like "tyran:implementer" can never be matched by "implementer".'
|
|
926
|
+
: ''),
|
|
927
|
+
'check the matcher against `claude agents` (for agent types) or the tool name in a transcript',
|
|
928
|
+
),
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
return findings;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ------------------------------------------------------------- the plugin
|
|
935
|
+
|
|
936
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
937
|
+
|
|
938
|
+
/** Where this checkout's plugin root is, derived from this file's own path. */
|
|
939
|
+
export const DEFAULT_PLUGIN_ROOT = resolve(HERE, '..');
|
|
940
|
+
|
|
941
|
+
/** Bytes of a hook script this check will read looking for its event name. */
|
|
942
|
+
const MAX_SCRIPT_BYTES = 512 * 1024;
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The agent types the plugin's own agents will present, measured format:
|
|
946
|
+
* `<name from plugin.json>:<agent name>`.
|
|
947
|
+
*
|
|
948
|
+
* The namespace comes from the MANIFEST, not from the install directory —
|
|
949
|
+
* proven with a directory called `DIRNAME_DIFFERENT` holding a manifest named
|
|
950
|
+
* `manifestname`. The consequence is the reason this function exists: renaming
|
|
951
|
+
* the plugin in `plugin.json` silently disarms every matcher that spells the
|
|
952
|
+
* old namespace, and nothing else in the system would notice.
|
|
953
|
+
*/
|
|
954
|
+
export function pluginAgentTypes(root) {
|
|
955
|
+
const namespace = readPluginName(root);
|
|
956
|
+
if (namespace === null) return [];
|
|
957
|
+
let entries;
|
|
958
|
+
try {
|
|
959
|
+
entries = readdirSync(join(root, 'agents'));
|
|
960
|
+
} catch {
|
|
961
|
+
return [];
|
|
962
|
+
}
|
|
963
|
+
return entries
|
|
964
|
+
.filter((n) => n.endsWith('.md'))
|
|
965
|
+
.map((n) => `${namespace}:${n.slice(0, -3)}`)
|
|
966
|
+
.sort();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** The `name` field of the plugin manifest, or null when unreadable. */
|
|
970
|
+
export function readPluginName(root) {
|
|
971
|
+
try {
|
|
972
|
+
const parsed = JSON.parse(readFileSync(join(root, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
973
|
+
return typeof parsed?.name === 'string' && parsed.name !== '' ? parsed.name : null;
|
|
974
|
+
} catch {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** The subject vocabulary this check knows for an event, and whether it is all of it. */
|
|
980
|
+
export function subjectsFor(event, root) {
|
|
981
|
+
if (Object.hasOwn(CLOSED_SUBJECTS, event)) {
|
|
982
|
+
return { subjects: [...CLOSED_SUBJECTS[event]], closed: true };
|
|
983
|
+
}
|
|
984
|
+
if (MATCH_QUERY_FIELD[event] === 'tool_name') {
|
|
985
|
+
return { subjects: [...KNOWN_TOOLS], closed: false };
|
|
986
|
+
}
|
|
987
|
+
if (event === 'SubagentStart' || event === 'SubagentStop') {
|
|
988
|
+
// Built-ins measured live, plus this plugin's own agents. A project may
|
|
989
|
+
// add more from `.claude/agents/`, so the set stays open.
|
|
990
|
+
return { subjects: [...pluginAgentTypes(root), 'general-purpose', 'Explore'], closed: false };
|
|
991
|
+
}
|
|
992
|
+
return { subjects: [], closed: false };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* The event a hook script declares to its runtime, found textually.
|
|
997
|
+
*
|
|
998
|
+
* A HEURISTIC, and labelled as one everywhere it surfaces. It exists because
|
|
999
|
+
* the failure it catches is total: `hook-io.runGate` compares the event it was
|
|
1000
|
+
* registered for against `hook_event_name` from the platform and turns a
|
|
1001
|
+
* mismatch into a refusal, so a gate registered under the wrong key in
|
|
1002
|
+
* hooks.json does not misbehave occasionally — it refuses EVERY invocation.
|
|
1003
|
+
* That is loud, but it is loud in the transcript of whoever hits it, not in
|
|
1004
|
+
* any place an installer looks.
|
|
1005
|
+
*/
|
|
1006
|
+
export function declaredEvents(source) {
|
|
1007
|
+
const found = new Set();
|
|
1008
|
+
for (const m of source.matchAll(/\bevent:\s*['"]([A-Za-z]+)['"]/g)) {
|
|
1009
|
+
if (PLATFORM_EVENTS.includes(m[1])) found.add(m[1]);
|
|
1010
|
+
}
|
|
1011
|
+
return [...found];
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ------------------------------------------------------------------- check
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Check every hook this plugin declares.
|
|
1018
|
+
*
|
|
1019
|
+
* Pure with respect to the process: everything it needs is a root directory
|
|
1020
|
+
* and an environment, both injectable, so the whole check is testable against
|
|
1021
|
+
* a synthetic plugin tree without touching the real one.
|
|
1022
|
+
*/
|
|
1023
|
+
export function checkHooks({ root = DEFAULT_PLUGIN_ROOT, env = process.env } = {}) {
|
|
1024
|
+
const findings = [];
|
|
1025
|
+
const checked = [];
|
|
1026
|
+
const pluginRoot = resolve(root);
|
|
1027
|
+
const vars = { CLAUDE_PLUGIN_ROOT: pluginRoot, CLAUDE_PROJECT_DIR: process.cwd() };
|
|
1028
|
+
|
|
1029
|
+
// ---- the manifest ------------------------------------------------------
|
|
1030
|
+
const manifestPath = join(pluginRoot, '.claude-plugin', 'plugin.json');
|
|
1031
|
+
let manifest = null;
|
|
1032
|
+
try {
|
|
1033
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
findings.push(
|
|
1036
|
+
finding(
|
|
1037
|
+
err.code === 'ENOENT' ? 'hooks-manifest-missing' : 'hooks-manifest-unreadable',
|
|
1038
|
+
q(manifestPath),
|
|
1039
|
+
err.code === 'ENOENT'
|
|
1040
|
+
? 'no plugin manifest — nothing declares where the hooks file is, so no gate is registered at all'
|
|
1041
|
+
: `cannot read the plugin manifest (${q(err.message)})`,
|
|
1042
|
+
),
|
|
1043
|
+
);
|
|
1044
|
+
return summarize(pluginRoot, findings, checked);
|
|
1045
|
+
}
|
|
1046
|
+
const declaredHooks = typeof manifest?.hooks === 'string' ? manifest.hooks : null;
|
|
1047
|
+
if (declaredHooks === null) {
|
|
1048
|
+
findings.push(
|
|
1049
|
+
finding(
|
|
1050
|
+
'hooks-manifest-no-hooks',
|
|
1051
|
+
q(manifestPath),
|
|
1052
|
+
'the manifest declares no "hooks" file. Every gate in this repository is registered through it, ' +
|
|
1053
|
+
'so without the field the plugin installs cleanly and gates nothing.',
|
|
1054
|
+
`add "hooks": "./hooks/hooks.json" to ${q(manifestPath)}`,
|
|
1055
|
+
),
|
|
1056
|
+
);
|
|
1057
|
+
return summarize(pluginRoot, findings, checked);
|
|
1058
|
+
}
|
|
1059
|
+
checked.push(`manifest: name "${readPluginName(pluginRoot) ?? '(unnamed)'}", hooks -> ${declaredHooks}`);
|
|
1060
|
+
|
|
1061
|
+
// ---- the hooks file ----------------------------------------------------
|
|
1062
|
+
const hooksPath = resolve(pluginRoot, declaredHooks);
|
|
1063
|
+
let doc;
|
|
1064
|
+
try {
|
|
1065
|
+
doc = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
findings.push(
|
|
1068
|
+
finding(
|
|
1069
|
+
err.code === 'ENOENT' ? 'hooks-file-missing' : err instanceof SyntaxError ? 'hooks-file-invalid' : 'hooks-file-unreadable',
|
|
1070
|
+
q(hooksPath),
|
|
1071
|
+
err.code === 'ENOENT'
|
|
1072
|
+
? 'the manifest points at a hooks file that does not exist — no gate is registered'
|
|
1073
|
+
: `the hooks file could not be read as JSON (${q(err.message)}). The platform reads the same ` +
|
|
1074
|
+
'file the same way, so it registers nothing and says nothing.',
|
|
1075
|
+
`node -e "JSON.parse(require('fs').readFileSync(${JSON.stringify(hooksPath)},'utf8'))"`,
|
|
1076
|
+
),
|
|
1077
|
+
);
|
|
1078
|
+
return summarize(pluginRoot, findings, checked);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const events = doc?.hooks;
|
|
1082
|
+
if (typeof events !== 'object' || events === null || Array.isArray(events)) {
|
|
1083
|
+
findings.push(
|
|
1084
|
+
finding('hooks-file-invalid', q(hooksPath), 'the hooks file has no top-level "hooks" object'),
|
|
1085
|
+
);
|
|
1086
|
+
return summarize(pluginRoot, findings, checked);
|
|
1087
|
+
}
|
|
1088
|
+
const eventNames = Object.keys(events);
|
|
1089
|
+
if (eventNames.length === 0) {
|
|
1090
|
+
findings.push(finding('hooks-file-empty', q(hooksPath), 'the hooks file registers no events at all'));
|
|
1091
|
+
return summarize(pluginRoot, findings, checked);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// The platform deduplicates matched hooks by (pluginRoot, command), so two
|
|
1095
|
+
// entries carrying the same command run ONCE. Counted per event, because
|
|
1096
|
+
// that is the scope of the deduplication.
|
|
1097
|
+
let commandCount = 0;
|
|
1098
|
+
|
|
1099
|
+
for (const event of eventNames) {
|
|
1100
|
+
if (!PLATFORM_EVENTS.includes(event)) {
|
|
1101
|
+
findings.push(
|
|
1102
|
+
finding(
|
|
1103
|
+
'hook-event-unknown',
|
|
1104
|
+
`${q(hooksPath)} -> hooks.${q(event)}`,
|
|
1105
|
+
`"${q(event)}" is not an event this platform build (${PLATFORM_VERSION}) dispatches. The block is ` +
|
|
1106
|
+
'never reached. A one-character typo in an event key removes every gate under it, and neither ' +
|
|
1107
|
+
'the manifest nor the platform complains.',
|
|
1108
|
+
`spell it as one of: ${PLATFORM_EVENTS.join(', ')}`,
|
|
1109
|
+
),
|
|
1110
|
+
);
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const groups = events[event];
|
|
1114
|
+
if (!Array.isArray(groups)) {
|
|
1115
|
+
findings.push(
|
|
1116
|
+
finding('hook-event-not-a-list', `${q(hooksPath)} -> hooks.${q(event)}`, 'the value must be an array of matcher groups'),
|
|
1117
|
+
);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
const { subjects, closed } = subjectsFor(event, pluginRoot);
|
|
1121
|
+
const namespace = readPluginName(pluginRoot);
|
|
1122
|
+
const seenCommands = new Map();
|
|
1123
|
+
|
|
1124
|
+
groups.forEach((group, gi) => {
|
|
1125
|
+
const at = `${q(hooksPath)} -> hooks.${q(event)}[${gi}]`;
|
|
1126
|
+
if (typeof group !== 'object' || group === null || !Array.isArray(group.hooks)) {
|
|
1127
|
+
findings.push(finding('hook-entry-malformed', at, 'a matcher group must be an object with a "hooks" array'));
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
findings.push(...analyzeMatcher(event, group.matcher, at, { subjects, closed, namespace }));
|
|
1131
|
+
|
|
1132
|
+
group.hooks.forEach((hook, hi) => {
|
|
1133
|
+
const where = `${at}.hooks[${hi}]`;
|
|
1134
|
+
if (typeof hook !== 'object' || hook === null) {
|
|
1135
|
+
findings.push(finding('hook-entry-malformed', where, 'a hook entry must be an object'));
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
if (hook.type !== 'command') {
|
|
1139
|
+
findings.push(
|
|
1140
|
+
finding(
|
|
1141
|
+
'hook-type-unchecked',
|
|
1142
|
+
where,
|
|
1143
|
+
`hook type "${q(hook.type)}" is not a command hook. This check can only verify that a ` +
|
|
1144
|
+
'FILE exists and can run; nothing here inspects prompt, http, agent or callback hooks.',
|
|
1145
|
+
),
|
|
1146
|
+
);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
commandCount++;
|
|
1150
|
+
if (typeof hook.command !== 'string' || hook.command.trim() === '') {
|
|
1151
|
+
findings.push(finding('hook-command-missing', where, 'a command hook with no command string'));
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (hook.timeout === undefined) {
|
|
1155
|
+
findings.push(
|
|
1156
|
+
finding(
|
|
1157
|
+
'hook-no-timeout',
|
|
1158
|
+
where,
|
|
1159
|
+
'no timeout is declared, so the platform default of 600 seconds applies. A gate that hangs ' +
|
|
1160
|
+
'then holds the tool call for ten minutes, and its own runtime deadline cannot help: the ' +
|
|
1161
|
+
'deadline only survives if it is SHORTER than this number.',
|
|
1162
|
+
'add "timeout": <seconds>, larger than the gate\'s own deadline and far below 600',
|
|
1163
|
+
),
|
|
1164
|
+
);
|
|
1165
|
+
} else if (typeof hook.timeout === 'number' && hook.timeout > MAX_PLAUSIBLE_TIMEOUT_S) {
|
|
1166
|
+
// Checking only for ABSENCE was the gap: a present-but-nonsensical
|
|
1167
|
+
// value passed silently, and the commonest nonsense is a
|
|
1168
|
+
// milliseconds figure in a field documented as seconds.
|
|
1169
|
+
const days = (hook.timeout / 86400).toFixed(1);
|
|
1170
|
+
findings.push(
|
|
1171
|
+
finding(
|
|
1172
|
+
'hook-timeout-implausible',
|
|
1173
|
+
where,
|
|
1174
|
+
`"timeout": ${hook.timeout} is in SECONDS — that is ${days} day(s). The platform's own ` +
|
|
1175
|
+
`default is ${MAX_PLAUSIBLE_TIMEOUT_S} s, so a value above it is almost always a ` +
|
|
1176
|
+
'millisecond figure pasted into a seconds field. Until the hook exits, the tool call it ' +
|
|
1177
|
+
'guards is held.',
|
|
1178
|
+
`set "timeout" to the number of SECONDS this hook may take (e.g. ${Math.max(1, Math.round(hook.timeout / 1000))})`,
|
|
1179
|
+
),
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
findings.push(...entryKeyFindings(event, hook, where));
|
|
1183
|
+
|
|
1184
|
+
const previous = seenCommands.get(hook.command);
|
|
1185
|
+
if (previous !== undefined) {
|
|
1186
|
+
findings.push(
|
|
1187
|
+
finding(
|
|
1188
|
+
'hook-duplicate-command',
|
|
1189
|
+
where,
|
|
1190
|
+
`the same command is already registered for ${q(event)} at ${q(previous)}. The platform ` +
|
|
1191
|
+
'deduplicates matched hooks by (pluginRoot, command), so the second registration never ' +
|
|
1192
|
+
'runs — including its matcher, which is what usually makes this a mistake rather than ' +
|
|
1193
|
+
'a redundancy.',
|
|
1194
|
+
'give the two entries different commands, or merge their matchers',
|
|
1195
|
+
),
|
|
1196
|
+
);
|
|
1197
|
+
} else {
|
|
1198
|
+
seenCommands.set(hook.command, where);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const lexed = lexCommand(hook.command);
|
|
1202
|
+
if (lexed.unterminatedQuote) {
|
|
1203
|
+
findings.push(
|
|
1204
|
+
finding('hook-command-not-modellable', where, 'the command has an unterminated quote; this check will not guess where the path ends'),
|
|
1205
|
+
);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
for (const name of lexed.unquotedVars) {
|
|
1209
|
+
findings.push(
|
|
1210
|
+
finding(
|
|
1211
|
+
'hook-path-unquoted',
|
|
1212
|
+
where,
|
|
1213
|
+
`\${${q(name)}} is used unquoted. The platform spawns hook commands with \`shell: true\`, so ` +
|
|
1214
|
+
'the shell word-splits the substituted value: an installation path containing a space ' +
|
|
1215
|
+
'becomes two arguments, the first names a file that does not exist, the shell exits 127, ' +
|
|
1216
|
+
'and the action proceeds silently. Every gate under such a path is dead on machines whose ' +
|
|
1217
|
+
'home directory has a space in it, and alive everywhere else.',
|
|
1218
|
+
`quote it: "\\"\${${q(name)}}/...\\""`,
|
|
1219
|
+
),
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
// A metacharacter means the command is a shell program, not a file
|
|
1223
|
+
// invocation. Refuse to model it rather than check the wrong path.
|
|
1224
|
+
const unmodellable = lexed.metachars.filter((c) => c !== '$');
|
|
1225
|
+
if (unmodellable.length > 0) {
|
|
1226
|
+
findings.push(
|
|
1227
|
+
finding(
|
|
1228
|
+
'hook-command-not-modellable',
|
|
1229
|
+
where,
|
|
1230
|
+
`the command contains the shell metacharacter(s) ${unmodellable.map((c) => `"${c}"`).join(', ')}, ` +
|
|
1231
|
+
'so which file actually runs depends on the shell. This check verifies files, not shell ' +
|
|
1232
|
+
'programs, and says so rather than checking a path that may not be the one that executes.',
|
|
1233
|
+
),
|
|
1234
|
+
);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const program = substitute(lexed.argv[0] ?? '', vars);
|
|
1238
|
+
if (program === '' || /\$\{/.test(program)) {
|
|
1239
|
+
findings.push(
|
|
1240
|
+
finding(
|
|
1241
|
+
'hook-command-not-modellable',
|
|
1242
|
+
where,
|
|
1243
|
+
`the command's program word "${q(lexed.argv[0] ?? '')}" still contains a variable this check ` +
|
|
1244
|
+
'cannot resolve, so the file it names is unknown here.',
|
|
1245
|
+
),
|
|
1246
|
+
);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const path = resolve(pluginRoot, program);
|
|
1250
|
+
const fileFindings = checkHookFile(path, `${where} -> ${q(path)}`, { env });
|
|
1251
|
+
findings.push(...fileFindings);
|
|
1252
|
+
|
|
1253
|
+
// Only cross-check the declared event when the file was readable —
|
|
1254
|
+
// otherwise the absent-file finding above is the whole story.
|
|
1255
|
+
if (fileFindings.some((f) => f.code === 'hook-file-absent' || f.code === 'hook-file-not-a-file' || f.code === 'hook-file-unreadable')) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
try {
|
|
1259
|
+
const stat = statSync(path);
|
|
1260
|
+
if (stat.size <= MAX_SCRIPT_BYTES) {
|
|
1261
|
+
const declared = declaredEvents(readFileSync(path, 'utf8'));
|
|
1262
|
+
if (declared.length > 0 && !declared.includes(event)) {
|
|
1263
|
+
findings.push(
|
|
1264
|
+
finding(
|
|
1265
|
+
'hook-event-declaration-mismatch',
|
|
1266
|
+
where,
|
|
1267
|
+
`hooks.json registers this script for ${q(event)}, but the script itself names ` +
|
|
1268
|
+
`${declared.map((e) => `"${q(e)}"`).join(', ')} to its runtime. hook-io compares the two ` +
|
|
1269
|
+
'and turns a mismatch into a refusal, so the gate would refuse every invocation rather ' +
|
|
1270
|
+
'than check anything. This is a TEXTUAL heuristic over the source, not a proof — a ' +
|
|
1271
|
+
'script that computes its event will trip it wrongly.',
|
|
1272
|
+
'align the event key in hooks.json with the one the script declares',
|
|
1273
|
+
),
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
} catch {
|
|
1278
|
+
/* already reported above; a race here is not a second finding */
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
checked.push(`${event}: ${groups.length} matcher group(s)`);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (findings.every((f) => f.severity === 'info')) {
|
|
1286
|
+
findings.push(
|
|
1287
|
+
finding(
|
|
1288
|
+
'hooks-ok',
|
|
1289
|
+
q(hooksPath),
|
|
1290
|
+
`${commandCount} command hook(s) checked against platform ${PLATFORM_VERSION}: every file exists, ` +
|
|
1291
|
+
'is executable, has a runnable shebang, and every matcher can match something. This is DETECTION ' +
|
|
1292
|
+
'only — nothing here can make a gate run.',
|
|
1293
|
+
),
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
return summarize(pluginRoot, findings, checked);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function summarize(root, findings, checked) {
|
|
1300
|
+
const order = ['error', 'warning', 'info'];
|
|
1301
|
+
const sorted = [...findings].sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity));
|
|
1302
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
1303
|
+
for (const f of sorted) counts[f.severity]++;
|
|
1304
|
+
return {
|
|
1305
|
+
ok: counts.error === 0 && counts.warning === 0,
|
|
1306
|
+
root: q(root),
|
|
1307
|
+
platform: PLATFORM_VERSION,
|
|
1308
|
+
checked,
|
|
1309
|
+
counts,
|
|
1310
|
+
findings: sorted,
|
|
1311
|
+
};
|
|
1312
|
+
}
|