@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +39 -4
- package/README.md +34 -5
- package/cli/config.mjs +126 -124
- package/cli/init.mjs +303 -276
- package/cli/install.mjs +281 -175
- package/cli/materialize.mjs +103 -102
- package/cli/registry.mjs +139 -136
- package/cli/smoke-fixtures.json +65 -0
- package/cli/task.mjs +140 -140
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/ask-adoption.mjs +147 -147
- package/plugins/gates/hooks/doctor.mjs +207 -207
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
- package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
- package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
- package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
- package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
- package/plugins/gates/hooks/hooks.json +61 -0
- package/plugins/gates/hooks/lib/config.mjs +179 -172
- package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
- package/plugins/gates/hooks/lib/signals.mjs +172 -127
- package/plugins/gates/hooks/wiring-check.mjs +227 -227
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/hooks.json +26 -26
- package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
- package/plugins/tasks/hooks/register-requests.mjs +145 -145
- package/plugins/tasks/hooks/session-tasks.mjs +108 -108
- 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:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
toolInput.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
toolInput.
|
|
149
|
-
toolInput.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
export function
|
|
237
|
-
try {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return
|
|
249
|
-
} catch {
|
|
250
|
-
return
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
+
}
|