@devrik-tools/claude-gates 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +65 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -1
@@ -1,357 +1,367 @@
1
- // Process I/O shared by every gate. Self-contained: Node built-ins only, so a gate keeps
2
- // working when installed outside this repo.
3
- //
4
- // There is no dispatcher: Claude Code runs one hook entry per gate natively (each with its
5
- // own `matcher`, all matching entries in parallel), so each gate is its own process and
6
- // reads its own stdin. This module holds what every gate needs, none of it gate-specific:
7
- // 1. Reading and parsing the hook payload Claude Code writes to stdin.
8
- // 2. Translating the registry's semantic tool groups (write/shell/...) into the concrete
9
- // tool names Claude Code sends — used to derive each gate's `matcher` and, when a gate
10
- // also inspects delegation prompts, to recognize the tool at runtime.
11
- // 3. Reading a deny/warn decision out of a gate's own JSON output shape.
12
-
13
- import { readFileSync } from 'node:fs';
14
- import { gateParameters, isGateEnabled } from './config.mjs';
15
-
16
- const STDIN_FILE_DESCRIPTOR = 0;
17
-
18
- /**
19
- * Semantic tool groups (as declared in registry.json's `tools`) mapped to the concrete
20
- * tool names Claude Code emits. Keeping the map here (not in the registry) keeps the
21
- * catalog declarative: which tool names exist is a fact about the Claude Code runtime,
22
- * not about the gate. `matcherFor` turns a group set into the `Tool1|Tool2` string a
23
- * hooks.json entry uses; `toolNamesFor` gives the flat list a gate matches at runtime.
24
- */
25
- export const TOOL_GROUPS = Object.freeze({
26
- write: [
27
- 'Write',
28
- 'Edit',
29
- 'NotebookEdit',
30
- 'write_to_file',
31
- 'replace_file_content',
32
- ],
33
- shell: ['Bash', 'run_command'],
34
- delegation: ['Agent', 'Task', 'invoke_subagent'],
35
- question: ['AskUserQuestion'],
36
- // The main agent materializing a change directly, whatever the surface.
37
- execution: [
38
- 'Write',
39
- 'Edit',
40
- 'NotebookEdit',
41
- 'write_to_file',
42
- 'replace_file_content',
43
- 'Bash',
44
- 'run_command',
45
- 'mcp__ide__executeCode',
46
- ],
47
- });
48
-
49
- /** Every concrete tool name a set of groups expands to, de-duplicated. */
50
- export function toolNamesFor(groups) {
51
- const names = new Set();
52
- for (const group of groups) {
53
- for (const name of TOOL_GROUPS[group] ?? []) names.add(name);
54
- }
55
- return [...names];
56
- }
57
-
58
- /**
59
- * MCP tools are named `mcp__<server>__<tool>`, and any connected server can expose a write,
60
- * shell, delegation or ask surface under a name this repo has never seen. Enumerating exact
61
- * names (the old approach) left every such tool invisible: the gate never fired. Instead,
62
- * each group carries a substring rule matched against the MCP tool's action segment, so a
63
- * NEW server's `mcp__fs__write_file` or `mcp__shell__exec` is classified by what it does, not
64
- * by a name we had to know in advance. The rule intentionally over-includes (a false match
65
- * makes a gate inspect a payload it then finds benign — cheap) rather than under-includes (a
66
- * miss is a silent hole). `question` stays deny-heavy so autonomous mode cannot be dodged by
67
- * an MCP ask tool. Native (non-mcp) names are still matched exactly via TOOL_GROUPS.
68
- */
69
- const MCP_GROUP_SIGNALS = Object.freeze({
70
- write: /(?:write|edit|create|append|patch|replace|insert|modify|save|update)/i,
71
- shell: /(?:shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
72
- delegation: /(?:agent|task|delegat|subagent|spawn|dispatch|orchestrat|worker)/i,
73
- question: /(?:ask|question|confirm|prompt|approv|choice|elicit|clarif)/i,
74
- execution: /(?:write|edit|create|append|patch|replace|insert|modify|save|update|shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
75
- });
76
-
77
- const MCP_TOOL_PREFIX = 'mcp__';
78
-
79
- /** The action segment of an MCP tool name (`mcp__server__do_thing` -> `do_thing`), or ''. */
80
- function mcpActionSegment(toolName) {
81
- if (!toolName.startsWith(MCP_TOOL_PREFIX)) return '';
82
- const parts = toolName.split('__');
83
- return parts.length >= 3 ? parts.slice(2).join('__') : '';
84
- }
85
-
86
- /**
87
- * Whether a tool name belongs to any of the given groups. A native tool matches by exact
88
- * membership; an MCP tool (`mcp__*`) matches when its action segment hits the group's signal
89
- * regex. This is what every gate should use instead of a private `Set.has(toolName)` — the
90
- * private sets were the second half of the MCP blind spot (even a payload that reached the
91
- * gate was rejected by an exact-name check).
92
- */
93
- export function toolInGroups(toolName, groups) {
94
- if (!toolName) return false;
95
- const native = toolNamesFor(groups);
96
- // Native tool names from Claude Code are canonical (`AskUserQuestion`), but match
97
- // case-insensitively so a differently-cased spelling from any surface can't dodge a gate.
98
- const lowered = toolName.toLowerCase();
99
- if (native.some((name) => name.toLowerCase() === lowered)) return true;
100
- const action = mcpActionSegment(toolName);
101
- if (!action) return false;
102
- return groups.some((group) => MCP_GROUP_SIGNALS[group]?.test(action));
103
- }
104
-
105
- /**
106
- * The `matcher` string for a hooks.json entry. Native names are listed explicitly; a trailing
107
- * `mcp__.*` alternative makes Claude Code also route EVERY MCP tool call to the hook, so the
108
- * gate can classify it at runtime with `toolInGroups`. Without the `mcp__.*` clause the hook
109
- * is never even invoked for an MCP tool — the deepest layer of the blind spot, since no
110
- * runtime check can compensate for a hook that never runs.
111
- */
112
- export function matcherFor(groups) {
113
- return [...toolNamesFor(groups), String.raw`mcp__.*`].join('|');
114
- }
115
-
116
- /**
117
- * The content a write-style tool is about to put on disk, across every native and MCP field
118
- * shape seen in the wild: Write/create (`content`), Edit (`new_string`), NotebookEdit
119
- * (`new_source`), MultiEdit (`edits[].new_string`), replace_file_content (`new_content`,
120
- * `ReplacementContent`), and MCP variants (`text`, `data`, `CodeContent`). Returns '' when
121
- * none is present. A gate that inspects written text MUST read through this, so a differently
122
- * shaped payload can no longer degrade silently to '' and slip past.
123
- */
124
- export function writtenContentOf(toolInput) {
125
- if (!toolInput || typeof toolInput !== 'object') return '';
126
- if (Array.isArray(toolInput.edits)) {
127
- return toolInput.edits
128
- .map((edit) => String(edit?.new_string ?? edit?.new_source ?? edit?.content ?? ''))
129
- .join('\n');
130
- }
131
- const direct =
132
- toolInput.content ??
133
- toolInput.new_string ??
134
- toolInput.new_source ??
135
- toolInput.new_content ??
136
- toolInput.ReplacementContent ??
137
- toolInput.CodeContent ??
138
- toolInput.text ??
139
- toolInput.data ??
140
- '';
141
- return String(direct);
142
- }
143
-
144
- /** The file path a write-style tool targets, across native and MCP field shapes. */
145
- export function writtenPathOf(toolInput) {
146
- if (!toolInput || typeof toolInput !== 'object') return '';
147
- const path =
148
- toolInput.file_path ??
149
- toolInput.path ??
150
- toolInput.target_file ??
151
- toolInput.notebook_path ??
152
- toolInput.filename ??
153
- toolInput.uri ??
154
- '';
155
- return String(path);
156
- }
157
-
158
- // Shell forms that CREATE or write a file at a named path, so a gate protecting a path can
159
- // see a `printf ... > basura.txt` the same way it sees a Write. Each pattern captures the
160
- // target path. Conservative by design: it over-detects (a gate then finds the path benign)
161
- // rather than under-detects (a silent hole). It does NOT resolve variables, command
162
- // substitution, or subshells — a path built dynamically (`> "$f"`) is not extracted; that
163
- // limitation is documented on the gates that use this, the honest boundary of a regex.
164
- const SHELL_WRITE_PATTERNS = [
165
- // redirection: `> file`, `>> file`, `1> file`, `&> file` (not `2>` alone — stderr)
166
- /(?:^|\s|;|&&|\|\|)(?:[0-9]*|&)>>?\s*(['"]?)([^\s'"|;&<>]+)\1/g,
167
- // touch / tee target(s)
168
- /\b(?:touch|tee)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
169
- // cp / mv / install destination is the LAST path; capture the first arg after the command
170
- // as a cheap proxy (over-detects the source too, which is acceptable — a gate re-checks).
171
- /\b(?:cp|mv|install)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
172
- ];
173
-
174
- /**
175
- * Every filesystem path a shell command appears to create or write to (redirections, touch,
176
- * tee, cp/mv destinations). Returns a de-duplicated list, empty when none is found. A gate
177
- * that protects paths should check these IN ADDITION to writtenPathOf, or a shell redirection
178
- * slips past it (the exact hole that let `printf x > basura.txt` evade root-whitelist while a
179
- * Write to the same path was blocked).
180
- */
181
- export function shellWrittenPaths(command) {
182
- const text = String(command ?? '');
183
- const found = new Set();
184
- for (const pattern of SHELL_WRITE_PATTERNS) {
185
- for (const match of text.matchAll(pattern)) {
186
- const path = match[2];
187
- // Skip a dynamically built target (a variable/substitution): we cannot resolve `$f`,
188
- // `${x}` or `$(…)` to a real path, and guessing would only add false positives. This is
189
- // the documented limitation the write-tool surface, which carries a concrete path,
190
- // stays the reliable one.
191
- if (path && !/[$`]/.test(path)) found.add(path);
192
- }
193
- }
194
- return [...found];
195
- }
196
-
197
- /** The brief/prompt a delegation carries, across native and MCP field shapes. */
198
- export function delegationPromptOf(toolInput) {
199
- if (!toolInput || typeof toolInput !== 'object') return '';
200
- const prompt =
201
- toolInput.prompt ??
202
- toolInput.Prompt ??
203
- toolInput.description ??
204
- toolInput.task ??
205
- toolInput.instructions ??
206
- toolInput.message ??
207
- toolInput.input ??
208
- '';
209
- return String(prompt);
210
- }
211
-
212
- /** Reads the raw hook payload from stdin. Returns null when stdin cannot be read. */
213
- export function readHookPayload() {
214
- try {
215
- return readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8');
216
- } catch {
217
- return null;
218
- }
219
- }
220
-
221
- /**
222
- * The tool name inside a raw payload. Returns null when the payload is unparseable
223
- * the caller cannot filter by tool then, so it must run every gate rather than skip
224
- * silently (erring toward over-running protects; erring toward skipping leaves a mute hole).
225
- */
226
- export function toolNameOf(rawPayload) {
227
- try {
228
- const payload = JSON.parse(rawPayload);
229
- return payload?.tool_name ?? payload?.name ?? '';
230
- } catch {
231
- return null;
232
- }
233
- }
234
-
235
- /** The session id inside a raw payload, or null when absent/unparseable. */
236
- export function sessionIdOf(rawPayload) {
237
- try {
238
- return JSON.parse(rawPayload)?.session_id ?? null;
239
- } catch {
240
- return null;
241
- }
242
- }
243
-
244
- /** The tool input object inside a raw payload, or {} when absent/unparseable. */
245
- export function toolInputOf(rawPayload) {
246
- try {
247
- const payload = JSON.parse(rawPayload);
248
- return payload?.tool_input ?? payload?.input ?? {};
249
- } catch {
250
- return {};
251
- }
252
- }
253
-
254
- const PRE_TOOL_USE_EVENT = 'PreToolUse';
255
-
256
- /**
257
- * A gate answers Claude Code in exactly one of three ways, and every gate uses these
258
- * emitters so the JSON shape is written once:
259
- * - deny: the rule is deterministic and the action is wrong. Blocks the tool call.
260
- * - warn: the question needs judgment; the gate surfaces context at the right moment
261
- * (additionalContext) and lets the call proceed.
262
- * - allow: nothing to say. The common path — silent and cheap.
263
- * Each emitter exits the process (exit 0 always: a PreToolUse deny is expressed in the
264
- * JSON, not in the exit code — reserving exit codes keeps a crash distinguishable).
265
- *
266
- * `label` is the gate id, prefixed to every message so a block names its source.
267
- */
268
- export function deny(label, reason) {
269
- process.stdout.write(
270
- JSON.stringify({
271
- hookSpecificOutput: {
272
- hookEventName: PRE_TOOL_USE_EVENT,
273
- permissionDecision: 'deny',
274
- permissionDecisionReason: `[${label}] ${reason}`,
275
- },
276
- }),
277
- );
278
- process.exit(0);
279
- }
280
-
281
- export function warn(label, context) {
282
- process.stdout.write(
283
- JSON.stringify({
284
- hookSpecificOutput: {
285
- hookEventName: PRE_TOOL_USE_EVENT,
286
- additionalContext: `[${label}] ${context}`,
287
- },
288
- }),
289
- );
290
- process.exit(0);
291
- }
292
-
293
- export function allow() {
294
- process.exit(0);
295
- }
296
-
297
- /**
298
- * The scaffolding every gate shares, so a gate file is just its rule. Given a gate's
299
- * identity and a `check` function, this: reads stdin (allow when unreadable), turns the
300
- * gate off when the project config disables it (allow), loads the project's params merged
301
- * over the gate's own defaults, and runs `check`. `check` calls `deny`/`warn` to object,
302
- * or returns to allow. A throw inside `check` is caught and turned into a deny — a gate
303
- * that cannot evaluate must not silently permit.
304
- *
305
- * @param {object} gate
306
- * @param {string} gate.id registry id, used as the message label
307
- * @param {string} gate.configKey the config flag that enables/disables this gate
308
- * @param {boolean} gate.enabledByDefault the registry default when config is silent
309
- * @param {object} [gate.defaultParams] the gate's built-in params (the readable defaults)
310
- * @param {(context: { rawPayload: string, toolName: string, toolInput: object,
311
- * sessionId: string|null, parameters: object }) => void} check
312
- */
313
- export async function runGate(gate, check) {
314
- // Defaults-dump mode: when the CLI spawns a gate with this flag set, the gate prints its
315
- // own descriptor (id, configKey, default flag and built-in params) and exits — before
316
- // touching stdin. This lets `init` materialize each gate's defaults into the config it
317
- // writes, with the gate as the single source of truth (no duplication in the registry).
318
- if (process.env.CLAUDE_GATES_DUMP_DEFAULTS) {
319
- process.stdout.write(
320
- JSON.stringify({
321
- id: gate.id,
322
- configKey: gate.configKey,
323
- enabledByDefault: gate.enabledByDefault,
324
- defaultParams: gate.defaultParams ?? {},
325
- }),
326
- );
327
- process.exit(0);
328
- }
329
-
330
- const rawPayload = readHookPayload();
331
- if (rawPayload === null) allow();
332
-
333
- if (!isGateEnabled(gate.configKey, gate.enabledByDefault, process.cwd()))
334
- allow();
335
-
336
- const declared = gateParameters(gate.configKey, process.cwd());
337
- const parameters = { ...(gate.defaultParams ?? {}), ...declared };
338
-
339
- try {
340
- // `check` may be sync or async; awaiting a non-promise is transparent, so the same
341
- // scaffolding serves both. Crucially, allow() runs only AFTER the check settles — an
342
- // async gate that permits early would never block.
343
- await check({
344
- rawPayload,
345
- toolName: toolNameOf(rawPayload) ?? '',
346
- toolInput: toolInputOf(rawPayload),
347
- sessionId: sessionIdOf(rawPayload),
348
- parameters,
349
- });
350
- } catch (error) {
351
- deny(
352
- gate.id,
353
- `The gate failed to evaluate and blocks the action for safety: ${error?.message ?? error}.`,
354
- );
355
- }
356
- allow();
357
- }
1
+ // Process I/O shared by every gate. Self-contained: Node built-ins only, so a gate keeps
2
+ // working when installed outside this repo.
3
+ //
4
+ // There is no dispatcher: Claude Code runs one hook entry per gate natively (each with its
5
+ // own `matcher`, all matching entries in parallel), so each gate is its own process and
6
+ // reads its own stdin. This module holds what every gate needs, none of it gate-specific:
7
+ // 1. Reading and parsing the hook payload Claude Code writes to stdin.
8
+ // 2. Translating the registry's semantic tool groups (write/shell/...) into the concrete
9
+ // tool names Claude Code sends — used to derive each gate's `matcher` and, when a gate
10
+ // also inspects delegation prompts, to recognize the tool at runtime.
11
+ // 3. Reading a deny/warn decision out of a gate's own JSON output shape.
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { gateParameters, isGateEnabled } from './config.mjs';
15
+
16
+ const STDIN_FILE_DESCRIPTOR = 0;
17
+
18
+ /**
19
+ * Semantic tool groups (as declared in registry.json's `tools`) mapped to the concrete
20
+ * tool names Claude Code emits. Keeping the map here (not in the registry) keeps the
21
+ * catalog declarative: which tool names exist is a fact about the Claude Code runtime,
22
+ * not about the gate. `matcherFor` turns a group set into the `Tool1|Tool2` string a
23
+ * hooks.json entry uses; `toolNamesFor` gives the flat list a gate matches at runtime.
24
+ */
25
+ export const TOOL_GROUPS = Object.freeze({
26
+ write: [
27
+ 'Write',
28
+ 'Edit',
29
+ 'NotebookEdit',
30
+ 'write_to_file',
31
+ 'replace_file_content',
32
+ ],
33
+ shell: ['Bash', 'run_command'],
34
+ delegation: ['Agent', 'Task', 'invoke_subagent'],
35
+ question: ['AskUserQuestion'],
36
+ // The main agent materializing a change directly, whatever the surface.
37
+ execution: [
38
+ 'Write',
39
+ 'Edit',
40
+ 'NotebookEdit',
41
+ 'write_to_file',
42
+ 'replace_file_content',
43
+ 'Bash',
44
+ 'run_command',
45
+ 'mcp__ide__executeCode',
46
+ ],
47
+ });
48
+
49
+ /** Every concrete tool name a set of groups expands to, de-duplicated. */
50
+ export function toolNamesFor(groups) {
51
+ const names = new Set();
52
+ for (const group of groups) {
53
+ for (const name of TOOL_GROUPS[group] ?? []) names.add(name);
54
+ }
55
+ return [...names];
56
+ }
57
+
58
+ /**
59
+ * MCP tools are named `mcp__<server>__<tool>`, and any connected server can expose a write,
60
+ * shell, delegation or ask surface under a name this repo has never seen. Enumerating exact
61
+ * names (the old approach) left every such tool invisible: the gate never fired. Instead,
62
+ * each group carries a substring rule matched against the MCP tool's action segment, so a
63
+ * NEW server's `mcp__fs__write_file` or `mcp__shell__exec` is classified by what it does, not
64
+ * by a name we had to know in advance. The rule intentionally over-includes (a false match
65
+ * makes a gate inspect a payload it then finds benign — cheap) rather than under-includes (a
66
+ * miss is a silent hole). `question` stays deny-heavy so autonomous mode cannot be dodged by
67
+ * an MCP ask tool. Native (non-mcp) names are still matched exactly via TOOL_GROUPS.
68
+ */
69
+ const MCP_GROUP_SIGNALS = Object.freeze({
70
+ write:
71
+ /(?:write|edit|create|append|patch|replace|insert|modify|save|update)/i,
72
+ shell:
73
+ /(?:shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
74
+ delegation:
75
+ /(?:agent|task|delegat|subagent|spawn|dispatch|orchestrat|worker)/i,
76
+ question: /(?:ask|question|confirm|prompt|approv|choice|elicit|clarif)/i,
77
+ execution:
78
+ /(?:write|edit|create|append|patch|replace|insert|modify|save|update|shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
79
+ });
80
+
81
+ const MCP_TOOL_PREFIX = 'mcp__';
82
+ // mcp__<server>__<action>: prefix + server + action, three underscore-delimited segments.
83
+ const MCP_TOOL_NAME_SEGMENT_COUNT = 3;
84
+
85
+ /** The action segment of an MCP tool name (`mcp__server__do_thing` -> `do_thing`), or ''. */
86
+ function mcpActionSegment(toolName) {
87
+ if (!toolName.startsWith(MCP_TOOL_PREFIX)) return '';
88
+ const parts = toolName.split('__');
89
+ return parts.length >= MCP_TOOL_NAME_SEGMENT_COUNT
90
+ ? parts.slice(2).join('__')
91
+ : '';
92
+ }
93
+
94
+ /**
95
+ * Whether a tool name belongs to any of the given groups. A native tool matches by exact
96
+ * membership; an MCP tool (`mcp__*`) matches when its action segment hits the group's signal
97
+ * regex. This is what every gate should use instead of a private `Set.has(toolName)` — the
98
+ * private sets were the second half of the MCP blind spot (even a payload that reached the
99
+ * gate was rejected by an exact-name check).
100
+ */
101
+ export function toolInGroups(toolName, groups) {
102
+ if (!toolName) return false;
103
+ const native = toolNamesFor(groups);
104
+ // Native tool names from Claude Code are canonical (`AskUserQuestion`), but match
105
+ // case-insensitively so a differently-cased spelling from any surface can't dodge a gate.
106
+ const lowered = toolName.toLowerCase();
107
+ if (native.some((name) => name.toLowerCase() === lowered)) return true;
108
+ const action = mcpActionSegment(toolName);
109
+ if (!action) return false;
110
+ return groups.some((group) => MCP_GROUP_SIGNALS[group]?.test(action));
111
+ }
112
+
113
+ /**
114
+ * The `matcher` string for a hooks.json entry. Native names are listed explicitly; a trailing
115
+ * `mcp__.*` alternative makes Claude Code also route EVERY MCP tool call to the hook, so the
116
+ * gate can classify it at runtime with `toolInGroups`. Without the `mcp__.*` clause the hook
117
+ * is never even invoked for an MCP tool the deepest layer of the blind spot, since no
118
+ * runtime check can compensate for a hook that never runs.
119
+ */
120
+ export function matcherFor(groups) {
121
+ return [...toolNamesFor(groups), String.raw`mcp__.*`].join('|');
122
+ }
123
+
124
+ /**
125
+ * The content a write-style tool is about to put on disk, across every native and MCP field
126
+ * shape seen in the wild: Write/create (`content`), Edit (`new_string`), NotebookEdit
127
+ * (`new_source`), MultiEdit (`edits[].new_string`), replace_file_content (`new_content`,
128
+ * `ReplacementContent`), and MCP variants (`text`, `data`, `CodeContent`). Returns '' when
129
+ * none is present. A gate that inspects written text MUST read through this, so a differently
130
+ * shaped payload can no longer degrade silently to '' and slip past.
131
+ */
132
+ export function writtenContentOf(toolInput) {
133
+ if (!toolInput || typeof toolInput !== 'object') return '';
134
+ if (Array.isArray(toolInput.edits)) {
135
+ return toolInput.edits
136
+ .map((edit) =>
137
+ String(edit?.new_string ?? edit?.new_source ?? edit?.content ?? ''),
138
+ )
139
+ .join('\n');
140
+ }
141
+ const direct =
142
+ toolInput.content ??
143
+ toolInput.new_string ??
144
+ toolInput.new_source ??
145
+ toolInput.new_content ??
146
+ toolInput.ReplacementContent ??
147
+ toolInput.CodeContent ??
148
+ toolInput.text ??
149
+ toolInput.data ??
150
+ '';
151
+ return String(direct);
152
+ }
153
+
154
+ /** The file path a write-style tool targets, across native and MCP field shapes. */
155
+ export function writtenPathOf(toolInput) {
156
+ if (!toolInput || typeof toolInput !== 'object') return '';
157
+ const path =
158
+ toolInput.file_path ??
159
+ toolInput.path ??
160
+ toolInput.target_file ??
161
+ toolInput.notebook_path ??
162
+ toolInput.filename ??
163
+ toolInput.uri ??
164
+ '';
165
+ return String(path);
166
+ }
167
+
168
+ // Shell forms that CREATE or write a file at a named path, so a gate protecting a path can
169
+ // see a `printf ... > basura.txt` the same way it sees a Write. Each pattern captures the
170
+ // target path. Conservative by design: it over-detects (a gate then finds the path benign)
171
+ // rather than under-detects (a silent hole). It does NOT resolve variables, command
172
+ // substitution, or subshells — a path built dynamically (`> "$f"`) is not extracted; that
173
+ // limitation is documented on the gates that use this, the honest boundary of a regex.
174
+ const SHELL_WRITE_PATTERNS = [
175
+ // redirection: `> file`, `>> file`, `1> file`, `&> file` (not `2>` alone stderr)
176
+ /(?:^|\s|;|&&|\|\|)(?:\d*|&)>>?\s*(['"]?)([^\s'"|;&<>]+)\1/g,
177
+ // touch / tee target(s)
178
+ /\b(?:touch|tee)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
179
+ // cp / mv / install destination is the LAST path; capture the first arg after the command
180
+ // as a cheap proxy (over-detects the source too, which is acceptable — a gate re-checks).
181
+ /\b(?:cp|mv|install)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
182
+ ];
183
+
184
+ /**
185
+ * Every filesystem path a shell command appears to create or write to (redirections, touch,
186
+ * tee, cp/mv destinations). Returns a de-duplicated list, empty when none is found. A gate
187
+ * that protects paths should check these IN ADDITION to writtenPathOf, or a shell redirection
188
+ * slips past it (the exact hole that let `printf x > basura.txt` evade root-whitelist while a
189
+ * Write to the same path was blocked).
190
+ */
191
+ export function shellWrittenPaths(command) {
192
+ const text = String(command ?? '');
193
+ const found = new Set();
194
+ for (const pattern of SHELL_WRITE_PATTERNS) {
195
+ for (const match of text.matchAll(pattern)) {
196
+ const path = match[2];
197
+ // Skip a dynamically built target (a variable/substitution): we cannot resolve `$f`,
198
+ // `${x}` or `$()` to a real path, and guessing would only add false positives. This is
199
+ // the documented limitation the write-tool surface, which carries a concrete path,
200
+ // stays the reliable one.
201
+ if (path && !/[$`]/.test(path)) found.add(path);
202
+ }
203
+ }
204
+ return [...found];
205
+ }
206
+
207
+ /** The brief/prompt a delegation carries, across native and MCP field shapes. */
208
+ export function delegationPromptOf(toolInput) {
209
+ if (!toolInput || typeof toolInput !== 'object') return '';
210
+ const prompt =
211
+ toolInput.prompt ??
212
+ toolInput.Prompt ??
213
+ toolInput.description ??
214
+ toolInput.task ??
215
+ toolInput.instructions ??
216
+ toolInput.message ??
217
+ toolInput.input ??
218
+ '';
219
+ return String(prompt);
220
+ }
221
+
222
+ /** Reads the raw hook payload from stdin. Returns null when stdin cannot be read. */
223
+ export function readHookPayload() {
224
+ try {
225
+ return readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8');
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * The tool name inside a raw payload. Returns null when the payload is unparseable —
233
+ * the caller cannot filter by tool then, so it must run every gate rather than skip
234
+ * silently (erring toward over-running protects; erring toward skipping leaves a mute hole).
235
+ */
236
+ export function toolNameOf(rawPayload) {
237
+ try {
238
+ const payload = JSON.parse(rawPayload);
239
+ return payload?.tool_name ?? payload?.name ?? '';
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ /** The session id inside a raw payload, or null when absent/unparseable. */
246
+ export function sessionIdOf(rawPayload) {
247
+ try {
248
+ return JSON.parse(rawPayload)?.session_id ?? null;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ /** The tool input object inside a raw payload, or {} when absent/unparseable. */
255
+ export function toolInputOf(rawPayload) {
256
+ try {
257
+ const payload = JSON.parse(rawPayload);
258
+ return payload?.tool_input ?? payload?.input ?? {};
259
+ } catch {
260
+ return {};
261
+ }
262
+ }
263
+
264
+ const PRE_TOOL_USE_EVENT = 'PreToolUse';
265
+
266
+ /**
267
+ * A gate answers Claude Code in exactly one of three ways, and every gate uses these
268
+ * emitters so the JSON shape is written once:
269
+ * - deny: the rule is deterministic and the action is wrong. Blocks the tool call.
270
+ * - warn: the question needs judgment; the gate surfaces context at the right moment
271
+ * (additionalContext) and lets the call proceed.
272
+ * - allow: nothing to say. The common path — silent and cheap.
273
+ * Each emitter exits the process (exit 0 always: a PreToolUse deny is expressed in the
274
+ * JSON, not in the exit code — reserving exit codes keeps a crash distinguishable).
275
+ *
276
+ * `label` is the gate id, prefixed to every message so a block names its source.
277
+ */
278
+ export function deny(label, reason) {
279
+ process.stdout.write(
280
+ JSON.stringify({
281
+ hookSpecificOutput: {
282
+ hookEventName: PRE_TOOL_USE_EVENT,
283
+ permissionDecision: 'deny',
284
+ permissionDecisionReason: `[${label}] ${reason}`,
285
+ },
286
+ }),
287
+ );
288
+ process.exit(0);
289
+ }
290
+
291
+ export function warn(label, context) {
292
+ process.stdout.write(
293
+ JSON.stringify({
294
+ hookSpecificOutput: {
295
+ hookEventName: PRE_TOOL_USE_EVENT,
296
+ additionalContext: `[${label}] ${context}`,
297
+ },
298
+ }),
299
+ );
300
+ process.exit(0);
301
+ }
302
+
303
+ export function allow() {
304
+ process.exit(0);
305
+ }
306
+
307
+ /**
308
+ * The scaffolding every gate shares, so a gate file is just its rule. Given a gate's
309
+ * identity and a `check` function, this: reads stdin (allow when unreadable), turns the
310
+ * gate off when the project config disables it (allow), loads the project's params merged
311
+ * over the gate's own defaults, and runs `check`. `check` calls `deny`/`warn` to object,
312
+ * or returns to allow. A throw inside `check` is caught and turned into a deny — a gate
313
+ * that cannot evaluate must not silently permit.
314
+ *
315
+ * @param {object} gate
316
+ * @param {string} gate.id registry id, used as the message label
317
+ * @param {string} gate.configKey the config flag that enables/disables this gate
318
+ * @param {boolean} gate.enabledByDefault the registry default when config is silent
319
+ * @param {object} [gate.defaultParams] the gate's built-in params (the readable defaults)
320
+ * @param {(context: { rawPayload: string, toolName: string, toolInput: object,
321
+ * sessionId: string|null, parameters: object }) => void} check
322
+ */
323
+ export async function runGate(gate, check) {
324
+ // Defaults-dump mode: when the CLI spawns a gate with this flag set, the gate prints its
325
+ // own descriptor (id, configKey, default flag and built-in params) and exits — before
326
+ // touching stdin. This lets `init` materialize each gate's defaults into the config it
327
+ // writes, with the gate as the single source of truth (no duplication in the registry).
328
+ if (process.env.CLAUDE_GATES_DUMP_DEFAULTS) {
329
+ process.stdout.write(
330
+ JSON.stringify({
331
+ id: gate.id,
332
+ configKey: gate.configKey,
333
+ enabledByDefault: gate.enabledByDefault,
334
+ defaultParams: gate.defaultParams ?? {},
335
+ }),
336
+ );
337
+ process.exit(0);
338
+ }
339
+
340
+ const rawPayload = readHookPayload();
341
+ if (rawPayload === null) allow();
342
+
343
+ if (!isGateEnabled(gate.configKey, gate.enabledByDefault, process.cwd()))
344
+ allow();
345
+
346
+ const declared = gateParameters(gate.configKey, process.cwd());
347
+ const parameters = { ...(gate.defaultParams ?? {}), ...declared };
348
+
349
+ try {
350
+ // `check` may be sync or async; awaiting a non-promise is transparent, so the same
351
+ // scaffolding serves both. Crucially, allow() runs only AFTER the check settles — an
352
+ // async gate that permits early would never block.
353
+ await check({
354
+ rawPayload,
355
+ toolName: toolNameOf(rawPayload) ?? '',
356
+ toolInput: toolInputOf(rawPayload),
357
+ sessionId: sessionIdOf(rawPayload),
358
+ parameters,
359
+ });
360
+ } catch (error) {
361
+ deny(
362
+ gate.id,
363
+ `The gate failed to evaluate and blocks the action for safety: ${error?.message ?? error}.`,
364
+ );
365
+ }
366
+ allow();
367
+ }