@devrik-tools/claude-gates 0.7.1 → 0.8.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 +69 -9
- package/README.md +68 -7
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +103 -60
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +79 -143
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +83 -11
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +49 -93
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +97 -48
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +98 -206
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -91
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +104 -136
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +112 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +448 -75
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +137 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
- package/registry.json +204 -36
|
@@ -8,10 +8,19 @@
|
|
|
8
8
|
// 2. Translating the registry's semantic tool groups (write/shell/...) into the concrete
|
|
9
9
|
// tool names Claude Code sends — used to derive each gate's `matcher` and, when a gate
|
|
10
10
|
// also inspects delegation prompts, to recognize the tool at runtime.
|
|
11
|
-
// 3. Reading a
|
|
11
|
+
// 3. Reading a shell command, a written path/content, or a delegation prompt out of the
|
|
12
|
+
// many field shapes native and MCP tools use.
|
|
13
|
+
// 4. Compiling config-supplied regex lists safely, and coercing config params to the
|
|
14
|
+
// type the gate's own defaults declare (a typo in .ai/config.json must never turn
|
|
15
|
+
// into "deny every tool call").
|
|
16
|
+
// 5. Emitting a deny/warn/allow decision in the JSON shape Claude Code expects, and
|
|
17
|
+
// recording every deny/warn/block in the project's decision log.
|
|
18
|
+
// 6. The scaffolding (`runGate`, `runStopHook`) that turns a gate file into just its rule.
|
|
12
19
|
|
|
13
20
|
import { readFileSync } from 'node:fs';
|
|
14
21
|
import { gateParameters, isGateEnabled } from './config.mjs';
|
|
22
|
+
import { DECISIONS, logDecision } from './gate-log.mjs';
|
|
23
|
+
import { readSessionState, writeSessionState } from './session-state.mjs';
|
|
15
24
|
|
|
16
25
|
const STDIN_FILE_DESCRIPTOR = 0;
|
|
17
26
|
|
|
@@ -26,24 +35,29 @@ export const TOOL_GROUPS = Object.freeze({
|
|
|
26
35
|
write: [
|
|
27
36
|
'Write',
|
|
28
37
|
'Edit',
|
|
38
|
+
'MultiEdit',
|
|
29
39
|
'NotebookEdit',
|
|
30
40
|
'write_to_file',
|
|
31
41
|
'replace_file_content',
|
|
32
42
|
],
|
|
33
|
-
shell: ['Bash', 'run_command'],
|
|
43
|
+
shell: ['Bash', 'run_command', 'PowerShell'],
|
|
34
44
|
delegation: ['Agent', 'Task', 'invoke_subagent'],
|
|
35
45
|
question: ['AskUserQuestion'],
|
|
36
46
|
// The main agent materializing a change directly, whatever the surface.
|
|
37
47
|
execution: [
|
|
38
48
|
'Write',
|
|
39
49
|
'Edit',
|
|
50
|
+
'MultiEdit',
|
|
40
51
|
'NotebookEdit',
|
|
41
52
|
'write_to_file',
|
|
42
53
|
'replace_file_content',
|
|
43
54
|
'Bash',
|
|
44
55
|
'run_command',
|
|
56
|
+
'PowerShell',
|
|
45
57
|
'mcp__ide__executeCode',
|
|
46
58
|
],
|
|
59
|
+
// Research surfaces: the web and documentation lookups the research gates sequence.
|
|
60
|
+
research: ['WebSearch', 'WebFetch'],
|
|
47
61
|
});
|
|
48
62
|
|
|
49
63
|
/** Every concrete tool name a set of groups expands to, de-duplicated. */
|
|
@@ -76,6 +90,7 @@ const MCP_GROUP_SIGNALS = Object.freeze({
|
|
|
76
90
|
question: /(?:ask|question|confirm|prompt|approv|choice|elicit|clarif)/i,
|
|
77
91
|
execution:
|
|
78
92
|
/(?:write|edit|create|append|patch|replace|insert|modify|save|update|shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
|
|
93
|
+
research: /(?:search|fetch|browse|docs|documentation|library|lookup|query)/i,
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
const MCP_TOOL_PREFIX = 'mcp__';
|
|
@@ -83,7 +98,7 @@ const MCP_TOOL_PREFIX = 'mcp__';
|
|
|
83
98
|
const MCP_TOOL_NAME_SEGMENT_COUNT = 3;
|
|
84
99
|
|
|
85
100
|
/** The action segment of an MCP tool name (`mcp__server__do_thing` -> `do_thing`), or ''. */
|
|
86
|
-
function mcpActionSegment(toolName) {
|
|
101
|
+
export function mcpActionSegment(toolName) {
|
|
87
102
|
if (!toolName.startsWith(MCP_TOOL_PREFIX)) return '';
|
|
88
103
|
const parts = toolName.split('__');
|
|
89
104
|
return parts.length >= MCP_TOOL_NAME_SEGMENT_COUNT
|
|
@@ -91,6 +106,13 @@ function mcpActionSegment(toolName) {
|
|
|
91
106
|
: '';
|
|
92
107
|
}
|
|
93
108
|
|
|
109
|
+
/** The server segment of an MCP tool name (`mcp__engram__mem_save` -> `engram`), or ''. */
|
|
110
|
+
export function mcpServerSegment(toolName) {
|
|
111
|
+
if (!toolName.startsWith(MCP_TOOL_PREFIX)) return '';
|
|
112
|
+
const parts = toolName.split('__');
|
|
113
|
+
return parts.length >= MCP_TOOL_NAME_SEGMENT_COUNT ? parts[1] : '';
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
/**
|
|
95
117
|
* Whether a tool name belongs to any of the given groups. A native tool matches by exact
|
|
96
118
|
* membership; an MCP tool (`mcp__*`) matches when its action segment hits the group's signal
|
|
@@ -103,9 +125,9 @@ export function toolInGroups(toolName, groups) {
|
|
|
103
125
|
const native = toolNamesFor(groups);
|
|
104
126
|
// Native tool names from Claude Code are canonical (`AskUserQuestion`), but match
|
|
105
127
|
// case-insensitively so a differently-cased spelling from any surface can't dodge a gate.
|
|
106
|
-
const lowered = toolName.toLowerCase();
|
|
128
|
+
const lowered = String(toolName).toLowerCase();
|
|
107
129
|
if (native.some((name) => name.toLowerCase() === lowered)) return true;
|
|
108
|
-
const action = mcpActionSegment(toolName);
|
|
130
|
+
const action = mcpActionSegment(String(toolName));
|
|
109
131
|
if (!action) return false;
|
|
110
132
|
return groups.some((group) => MCP_GROUP_SIGNALS[group]?.test(action));
|
|
111
133
|
}
|
|
@@ -148,7 +170,7 @@ export function writtenContentOf(toolInput) {
|
|
|
148
170
|
toolInput.text ??
|
|
149
171
|
toolInput.data ??
|
|
150
172
|
'';
|
|
151
|
-
return String(direct);
|
|
173
|
+
return typeof direct === 'string' ? direct : String(direct ?? '');
|
|
152
174
|
}
|
|
153
175
|
|
|
154
176
|
/** The file path a write-style tool targets, across native and MCP field shapes. */
|
|
@@ -162,46 +184,16 @@ export function writtenPathOf(toolInput) {
|
|
|
162
184
|
toolInput.filename ??
|
|
163
185
|
toolInput.uri ??
|
|
164
186
|
'';
|
|
165
|
-
return String(path);
|
|
187
|
+
return typeof path === 'string' ? path : String(path ?? '');
|
|
166
188
|
}
|
|
167
189
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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];
|
|
190
|
+
/** The command line a shell-style tool is about to run, across field shapes; '' if none. */
|
|
191
|
+
export function shellCommandOf(toolInput) {
|
|
192
|
+
if (typeof toolInput === 'string') return toolInput;
|
|
193
|
+
if (!toolInput || typeof toolInput !== 'object') return '';
|
|
194
|
+
const command =
|
|
195
|
+
toolInput.command ?? toolInput.CommandLine ?? toolInput.cmd ?? '';
|
|
196
|
+
return typeof command === 'string' ? command : String(command ?? '');
|
|
205
197
|
}
|
|
206
198
|
|
|
207
199
|
/** The brief/prompt a delegation carries, across native and MCP field shapes. */
|
|
@@ -216,9 +208,116 @@ export function delegationPromptOf(toolInput) {
|
|
|
216
208
|
toolInput.message ??
|
|
217
209
|
toolInput.input ??
|
|
218
210
|
'';
|
|
219
|
-
return String(prompt);
|
|
211
|
+
return typeof prompt === 'string' ? prompt : String(prompt ?? '');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The text a gate should inspect for a tool in the shell OR delegation group: the command
|
|
216
|
+
* line for a shell tool, the prompt for a delegation. '' for anything else.
|
|
217
|
+
*/
|
|
218
|
+
export function commandOrPromptOf(toolName, toolInput) {
|
|
219
|
+
if (toolInGroups(toolName, ['shell'])) return shellCommandOf(toolInput);
|
|
220
|
+
if (toolInGroups(toolName, ['delegation']))
|
|
221
|
+
return delegationPromptOf(toolInput);
|
|
222
|
+
return '';
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
// ── Shell-created paths ─────────────────────────────────────────────────────────────
|
|
226
|
+
// A quoted (double, single) or bare argument; the bare form stops at shell metacharacters.
|
|
227
|
+
const ARGUMENT = String.raw`"([^"\n]+)"|'([^'\n]+)'|([^\s'"|;&<>()]+)`;
|
|
228
|
+
// Shell forms that CREATE or write a file at a named path, so a gate protecting a path can
|
|
229
|
+
// see a `printf ... > file` the same way it sees a Write. Each pattern captures the target
|
|
230
|
+
// path in groups 1..3 (quoted-double, quoted-single, bare). Conservative by design: it
|
|
231
|
+
// over-detects (a gate then finds the path benign) rather than under-detects. It does NOT
|
|
232
|
+
// resolve variables or command substitution — a path built dynamically (`> "$f"`) is not
|
|
233
|
+
// extracted; that limitation is the honest boundary of a regex.
|
|
234
|
+
const SHELL_WRITE_PATTERNS = [
|
|
235
|
+
// redirection: `> file`, `>> file`, `1> file`, `2> file`, `&> file`, also with no space
|
|
236
|
+
// before the `>` (`echo hi>file`). A `>` that is part of `<>`/`>>` is consumed whole.
|
|
237
|
+
new RegExp(String.raw`(?<![<>])(?:\d|&)?>>?\s*(?:${ARGUMENT})`, 'g'),
|
|
238
|
+
// touch / tee / mkdir target(s): every non-flag argument up to the next separator
|
|
239
|
+
new RegExp(
|
|
240
|
+
String.raw`(?:^|[;&|(]|\|\||&&)\s*(?:touch|tee|mkdir|mkdir -p)\s+(?:-\S+\s+)*((?:(?:"[^"\n]+"|'[^'\n]+'|[^\s'"|;&<>()]+)\s*)+)`,
|
|
241
|
+
'g',
|
|
242
|
+
),
|
|
243
|
+
// cp / mv / install: the LAST argument is the destination
|
|
244
|
+
new RegExp(
|
|
245
|
+
String.raw`(?:^|[;&|(]|\|\||&&)\s*(?:cp|mv|install)\s+(?:-\S+\s+)*((?:(?:"[^"\n]+"|'[^'\n]+'|[^\s'"|;&<>()]+)\s*)+)`,
|
|
246
|
+
'g',
|
|
247
|
+
),
|
|
248
|
+
// git clone [opts] <url> [dest]
|
|
249
|
+
new RegExp(
|
|
250
|
+
String.raw`\bgit\s+clone\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*(\S+)(?:\s+(?:${ARGUMENT}))?`,
|
|
251
|
+
'g',
|
|
252
|
+
),
|
|
253
|
+
// curl -o / wget -O / --output <file>
|
|
254
|
+
new RegExp(
|
|
255
|
+
String.raw`\b(?:curl|wget)\b[^;&|\n]*?\s(?:-o|-O|--output)(?:=|\s+)(?:${ARGUMENT})`,
|
|
256
|
+
'g',
|
|
257
|
+
),
|
|
258
|
+
// PowerShell: Set-Content/Add-Content/Out-File/New-Item/Copy-Item/Move-Item/Tee-Object,
|
|
259
|
+
// with the path as -Path/-FilePath/-Destination/-LiteralPath or the first bare argument
|
|
260
|
+
new RegExp(
|
|
261
|
+
String.raw`\b(?:Set-Content|Add-Content|Out-File|New-Item|Copy-Item|Move-Item|Tee-Object|Export-Csv|ConvertTo-Json\s*\|\s*Out-File)\b(?:\s+-(?:Path|FilePath|Destination|LiteralPath)\s+|\s+(?!-))(?:${ARGUMENT})`,
|
|
262
|
+
'gi',
|
|
263
|
+
),
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
const MULTI_TARGET_PATTERN_INDEXES = new Set([1, 2]);
|
|
267
|
+
const CLONE_PATTERN_INDEX = 3;
|
|
268
|
+
|
|
269
|
+
function argumentValue(match) {
|
|
270
|
+
return match[1] ?? match[2] ?? match[3] ?? '';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function splitTargets(list) {
|
|
274
|
+
const targets = [];
|
|
275
|
+
for (const match of String(list).matchAll(new RegExp(ARGUMENT, 'g')))
|
|
276
|
+
targets.push(argumentValue(match));
|
|
277
|
+
return targets;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function cloneDestination(match) {
|
|
281
|
+
const explicit = match[2] ?? match[3] ?? match[4];
|
|
282
|
+
if (explicit) return explicit;
|
|
283
|
+
const url = String(match[1] ?? '');
|
|
284
|
+
const base = url.split(/[\\/]/).filter(Boolean).at(-1) ?? '';
|
|
285
|
+
return base.replace(/\.git$/i, '');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Every filesystem path a shell command appears to create or write to (redirections, touch,
|
|
290
|
+
* tee, mkdir, cp/mv destinations, git clone, curl/wget output, PowerShell writers). Returns a
|
|
291
|
+
* de-duplicated list, empty when none is found. A gate that protects paths should check these
|
|
292
|
+
* IN ADDITION to writtenPathOf, or a shell redirection slips past it.
|
|
293
|
+
*/
|
|
294
|
+
export function shellWrittenPaths(command) {
|
|
295
|
+
const text = String(command ?? '');
|
|
296
|
+
const found = new Set();
|
|
297
|
+
SHELL_WRITE_PATTERNS.forEach((pattern, index) => {
|
|
298
|
+
for (const match of text.matchAll(pattern)) {
|
|
299
|
+
let targets;
|
|
300
|
+
if (MULTI_TARGET_PATTERN_INDEXES.has(index)) {
|
|
301
|
+
const all = splitTargets(match[1]);
|
|
302
|
+
// cp/mv/install: destination is the last argument; touch/tee/mkdir: all of them.
|
|
303
|
+
targets = index === 2 ? all.slice(-1) : all;
|
|
304
|
+
} else if (index === CLONE_PATTERN_INDEX) {
|
|
305
|
+
targets = [cloneDestination(match)];
|
|
306
|
+
} else {
|
|
307
|
+
targets = [argumentValue(match)];
|
|
308
|
+
}
|
|
309
|
+
for (const path of targets) {
|
|
310
|
+
// Skip a dynamically built target (a variable/substitution): we cannot resolve `$f`,
|
|
311
|
+
// `${x}` or `$(…)` to a real path, and guessing would only add false positives.
|
|
312
|
+
if (path && !/[$`%]/.test(path) && path !== '-') found.add(path);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return [...found];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Payload readers ─────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
222
321
|
/** Reads the raw hook payload from stdin. Returns null when stdin cannot be read. */
|
|
223
322
|
export function readHookPayload() {
|
|
224
323
|
try {
|
|
@@ -228,41 +327,192 @@ export function readHookPayload() {
|
|
|
228
327
|
}
|
|
229
328
|
}
|
|
230
329
|
|
|
330
|
+
function parsePayload(rawPayload) {
|
|
331
|
+
try {
|
|
332
|
+
const payload = JSON.parse(rawPayload);
|
|
333
|
+
return payload && typeof payload === 'object' ? payload : {};
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
231
339
|
/**
|
|
232
340
|
* The tool name inside a raw payload. Returns null when the payload is unparseable —
|
|
233
341
|
* the caller cannot filter by tool then, so it must run every gate rather than skip
|
|
234
342
|
* silently (erring toward over-running protects; erring toward skipping leaves a mute hole).
|
|
235
343
|
*/
|
|
236
344
|
export function toolNameOf(rawPayload) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
} catch {
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
345
|
+
const payload = parsePayload(rawPayload);
|
|
346
|
+
if (payload === null) return null;
|
|
347
|
+
return String(payload.tool_name ?? payload.name ?? '');
|
|
243
348
|
}
|
|
244
349
|
|
|
245
350
|
/** The session id inside a raw payload, or null when absent/unparseable. */
|
|
246
351
|
export function sessionIdOf(rawPayload) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
352
|
+
const payload = parsePayload(rawPayload);
|
|
353
|
+
const id = payload?.session_id;
|
|
354
|
+
return id === undefined || id === null ? null : String(id);
|
|
252
355
|
}
|
|
253
356
|
|
|
254
357
|
/** The tool input object inside a raw payload, or {} when absent/unparseable. */
|
|
255
358
|
export function toolInputOf(rawPayload) {
|
|
359
|
+
const payload = parsePayload(rawPayload);
|
|
360
|
+
const input = payload?.tool_input ?? payload?.input;
|
|
361
|
+
return input && typeof input === 'object' ? input : {};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The tool response (PostToolUse payloads), or null when absent. */
|
|
365
|
+
export function toolResponseOf(rawPayload) {
|
|
366
|
+
const payload = parsePayload(rawPayload);
|
|
367
|
+
return payload?.tool_response ?? payload?.tool_result ?? null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Regex and parameter helpers ─────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
/** Escapes a literal for use inside a RegExp source. */
|
|
373
|
+
export function escapeRegExp(text) {
|
|
374
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** A compiled RegExp from a source, or null when the source is not a valid pattern. */
|
|
378
|
+
export function compileRegex(source, flags = 'i') {
|
|
379
|
+
if (typeof source !== 'string' || source.length === 0) return null;
|
|
256
380
|
try {
|
|
257
|
-
|
|
258
|
-
return payload?.tool_input ?? payload?.input ?? {};
|
|
381
|
+
return new RegExp(source, flags);
|
|
259
382
|
} catch {
|
|
260
|
-
return
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Compiles a config-supplied list of regex sources, SKIPPING malformed or non-string
|
|
389
|
+
* entries instead of throwing. Returns { patterns, invalid } so a gate can mention what it
|
|
390
|
+
* ignored. A non-array input yields no patterns.
|
|
391
|
+
*/
|
|
392
|
+
export function compileRegexList(sources, flags = 'i') {
|
|
393
|
+
const patterns = [];
|
|
394
|
+
const invalid = [];
|
|
395
|
+
for (const source of Array.isArray(sources) ? sources : []) {
|
|
396
|
+
const pattern = compileRegex(source, flags);
|
|
397
|
+
if (pattern) patterns.push(pattern);
|
|
398
|
+
else invalid.push(String(source));
|
|
399
|
+
}
|
|
400
|
+
return { patterns, invalid };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Normalizes a rule list that may mix bare regex sources and `[source, reason]` pairs into
|
|
405
|
+
* `{ source, reason }` objects, dropping entries that are neither. The registry documents
|
|
406
|
+
* the bare form; the gates' defaults ship pairs — both must work.
|
|
407
|
+
*/
|
|
408
|
+
export function normalizeRulePairs(rules, defaultReason) {
|
|
409
|
+
const normalized = [];
|
|
410
|
+
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
411
|
+
if (typeof rule === 'string') {
|
|
412
|
+
normalized.push({ source: rule, reason: defaultReason });
|
|
413
|
+
} else if (Array.isArray(rule) && typeof rule[0] === 'string') {
|
|
414
|
+
normalized.push({
|
|
415
|
+
source: rule[0],
|
|
416
|
+
reason: typeof rule[1] === 'string' ? rule[1] : defaultReason,
|
|
417
|
+
});
|
|
418
|
+
} else if (
|
|
419
|
+
rule &&
|
|
420
|
+
typeof rule === 'object' &&
|
|
421
|
+
typeof rule.source === 'string'
|
|
422
|
+
) {
|
|
423
|
+
normalized.push({
|
|
424
|
+
source: rule.source,
|
|
425
|
+
reason: typeof rule.reason === 'string' ? rule.reason : defaultReason,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return normalized;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// One coercion rule per default type: returns the accepted value, or undefined to reject.
|
|
433
|
+
const COERCIONS = {
|
|
434
|
+
array: (declared) => (Array.isArray(declared) ? declared : undefined),
|
|
435
|
+
number: (declared) => {
|
|
436
|
+
if (declared === '' || declared === null) return undefined;
|
|
437
|
+
const number = Number(declared);
|
|
438
|
+
return Number.isFinite(number) ? number : undefined;
|
|
439
|
+
},
|
|
440
|
+
boolean: (declared) => {
|
|
441
|
+
if (typeof declared === 'boolean') return declared;
|
|
442
|
+
if (declared === 'true' || declared === 'false') return declared === 'true';
|
|
443
|
+
return undefined;
|
|
444
|
+
},
|
|
445
|
+
string: (declared) =>
|
|
446
|
+
typeof declared === 'string' || declared === null ? declared : undefined,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
function expectedTypeOf(fallback) {
|
|
450
|
+
if (Array.isArray(fallback)) return 'array';
|
|
451
|
+
if (fallback === null) return 'string';
|
|
452
|
+
return typeof fallback;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function coerceOne(key, fallback, declared) {
|
|
456
|
+
if (declared === undefined) return { value: fallback };
|
|
457
|
+
const expected = expectedTypeOf(fallback);
|
|
458
|
+
const coerce = COERCIONS[expected];
|
|
459
|
+
if (!coerce) return { value: declared };
|
|
460
|
+
const value = coerce(declared);
|
|
461
|
+
if (value !== undefined) return { value };
|
|
462
|
+
return {
|
|
463
|
+
value: fallback,
|
|
464
|
+
problem: {
|
|
465
|
+
key,
|
|
466
|
+
expected,
|
|
467
|
+
got: declared === null ? 'null' : typeof declared,
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Merges a project's declared params over a gate's defaults, coercing each declared value to
|
|
474
|
+
* the TYPE the default has (array, number, boolean, string). A value of the wrong type is
|
|
475
|
+
* replaced by the default and reported in `problems`, so a typo in .ai/config.json degrades
|
|
476
|
+
* to "built-in behavior" instead of "the gate throws and denies everything". Keys the
|
|
477
|
+
* defaults do not know pass through untouched.
|
|
478
|
+
*/
|
|
479
|
+
export function coerceParameters(defaults, declared) {
|
|
480
|
+
const parameters = { ...(declared ?? {}) };
|
|
481
|
+
const problems = [];
|
|
482
|
+
for (const [key, fallback] of Object.entries(defaults ?? {})) {
|
|
483
|
+
const { value, problem } = coerceOne(key, fallback, declared?.[key]);
|
|
484
|
+
parameters[key] = value;
|
|
485
|
+
if (problem) problems.push(problem);
|
|
261
486
|
}
|
|
487
|
+
return { parameters, problems };
|
|
262
488
|
}
|
|
263
489
|
|
|
490
|
+
// ── Decisions ───────────────────────────────────────────────────────────────────────
|
|
264
491
|
const PRE_TOOL_USE_EVENT = 'PreToolUse';
|
|
265
492
|
|
|
493
|
+
// The gate/tool/session currently being judged, so deny/warn can log a complete line
|
|
494
|
+
// without every gate threading that context through. Set by runGate/runStopHook.
|
|
495
|
+
let decisionContext = null;
|
|
496
|
+
|
|
497
|
+
/** Sets what deny/warn/block will record in the decision log for this process. */
|
|
498
|
+
export function setDecisionContext(context) {
|
|
499
|
+
decisionContext = context;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function record(decision, reason) {
|
|
503
|
+
if (!decisionContext) return;
|
|
504
|
+
logDecision({
|
|
505
|
+
decision,
|
|
506
|
+
gate: decisionContext.gateId,
|
|
507
|
+
configKey: decisionContext.configKey,
|
|
508
|
+
toolName: decisionContext.toolName,
|
|
509
|
+
toolInput: decisionContext.toolInput,
|
|
510
|
+
sessionId: decisionContext.sessionId,
|
|
511
|
+
cwd: decisionContext.cwd,
|
|
512
|
+
reason,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
266
516
|
/**
|
|
267
517
|
* A gate answers Claude Code in exactly one of three ways, and every gate uses these
|
|
268
518
|
* emitters so the JSON shape is written once:
|
|
@@ -273,9 +523,11 @@ const PRE_TOOL_USE_EVENT = 'PreToolUse';
|
|
|
273
523
|
* Each emitter exits the process (exit 0 always: a PreToolUse deny is expressed in the
|
|
274
524
|
* JSON, not in the exit code — reserving exit codes keeps a crash distinguishable).
|
|
275
525
|
*
|
|
276
|
-
* `label` is
|
|
526
|
+
* `label` is what prefixes the message so a block names its source; by convention every
|
|
527
|
+
* gate passes its configKey (the thing a user can toggle in .ai/config.json).
|
|
277
528
|
*/
|
|
278
529
|
export function deny(label, reason) {
|
|
530
|
+
record(DECISIONS.DENY, reason);
|
|
279
531
|
process.stdout.write(
|
|
280
532
|
JSON.stringify({
|
|
281
533
|
hookSpecificOutput: {
|
|
@@ -289,6 +541,7 @@ export function deny(label, reason) {
|
|
|
289
541
|
}
|
|
290
542
|
|
|
291
543
|
export function warn(label, context) {
|
|
544
|
+
record(DECISIONS.WARN, context);
|
|
292
545
|
process.stdout.write(
|
|
293
546
|
JSON.stringify({
|
|
294
547
|
hookSpecificOutput: {
|
|
@@ -304,21 +557,79 @@ export function allow() {
|
|
|
304
557
|
process.exit(0);
|
|
305
558
|
}
|
|
306
559
|
|
|
560
|
+
/** A Stop-hook block: makes the agent continue instead of ending the turn. */
|
|
561
|
+
export function block(label, reason) {
|
|
562
|
+
record(DECISIONS.BLOCK, reason);
|
|
563
|
+
process.stdout.write(
|
|
564
|
+
JSON.stringify({ decision: 'block', reason: `[${label}] ${reason}` }),
|
|
565
|
+
);
|
|
566
|
+
process.exit(0);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export const SEVERITY = Object.freeze({ DENY: 'deny', WARN: 'warn' });
|
|
570
|
+
|
|
571
|
+
function describeType(type) {
|
|
572
|
+
if (type === 'array') return 'a list';
|
|
573
|
+
if (type === 'object') return 'null/object';
|
|
574
|
+
return `a ${type}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function describeProblems(problems) {
|
|
578
|
+
return problems
|
|
579
|
+
.map(
|
|
580
|
+
(problem) =>
|
|
581
|
+
`"${problem.key}" should be ${describeType(problem.expected)} but is ${describeType(problem.got)}`,
|
|
582
|
+
)
|
|
583
|
+
.join('; ');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const PARAMETER_WARNING_STATE = 'param-problems';
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Warns ONCE per session about ignored config params, then only logs. A wrong-typed param
|
|
590
|
+
* is a config bug the user should hear about, but not on every tool call.
|
|
591
|
+
*/
|
|
592
|
+
function surfaceParameterProblems(gate, problems, sessionId) {
|
|
593
|
+
if (problems.length === 0) return;
|
|
594
|
+
const message =
|
|
595
|
+
`Ignored config param(s) for ${gate.configKey} in .ai/config.json (using built-in ` +
|
|
596
|
+
`defaults instead): ${describeProblems(problems)}. Fix the type to have your override apply.`;
|
|
597
|
+
record(DECISIONS.CONFIG, message);
|
|
598
|
+
const state = readSessionState(PARAMETER_WARNING_STATE, sessionId, {});
|
|
599
|
+
if (state[gate.configKey]) return;
|
|
600
|
+
writeSessionState(PARAMETER_WARNING_STATE, sessionId, {
|
|
601
|
+
...state,
|
|
602
|
+
[gate.configKey]: true,
|
|
603
|
+
});
|
|
604
|
+
warn(gate.configKey, message);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function failClosedOrOpen(gate, error) {
|
|
608
|
+
const message = `The gate failed to evaluate: ${error?.message ?? error}.`;
|
|
609
|
+
if (gate.severity === SEVERITY.WARN) {
|
|
610
|
+
warn(gate.configKey, `${message} It only advises, so the action proceeds.`);
|
|
611
|
+
}
|
|
612
|
+
deny(gate.configKey, `${message} It blocks the action for safety.`);
|
|
613
|
+
}
|
|
614
|
+
|
|
307
615
|
/**
|
|
308
616
|
* The scaffolding every gate shares, so a gate file is just its rule. Given a gate's
|
|
309
617
|
* identity and a `check` function, this: reads stdin (allow when unreadable), turns the
|
|
310
618
|
* 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`
|
|
312
|
-
* or returns to allow. A throw inside `check` is caught and turned into a deny
|
|
313
|
-
* that cannot evaluate must not silently permit
|
|
619
|
+
* over the gate's own defaults (type-checked), and runs `check`. `check` calls `deny`/`warn`
|
|
620
|
+
* to object, or returns to allow. A throw inside `check` is caught and turned into a deny
|
|
621
|
+
* for a `severity: 'deny'` gate (a gate that cannot evaluate must not silently permit) and
|
|
622
|
+
* into a warn for a `severity: 'warn'` gate (an advisory gate must never block).
|
|
314
623
|
*
|
|
315
624
|
* @param {object} gate
|
|
316
|
-
* @param {string} gate.id registry id
|
|
625
|
+
* @param {string} gate.id registry id
|
|
317
626
|
* @param {string} gate.configKey the config flag that enables/disables this gate
|
|
318
627
|
* @param {boolean} gate.enabledByDefault the registry default when config is silent
|
|
319
628
|
* @param {object} [gate.defaultParams] the gate's built-in params (the readable defaults)
|
|
629
|
+
* @param {'deny'|'warn'} [gate.severity] what a failure to evaluate turns into (default deny)
|
|
320
630
|
* @param {(context: { rawPayload: string, toolName: string, toolInput: object,
|
|
321
|
-
* sessionId: string|null, parameters: object
|
|
631
|
+
* sessionId: string|null, parameters: object, cwd: string,
|
|
632
|
+
* toolResponse: unknown }) => void} check
|
|
322
633
|
*/
|
|
323
634
|
export async function runGate(gate, check) {
|
|
324
635
|
// Defaults-dump mode: when the CLI spawns a gate with this flag set, the gate prints its
|
|
@@ -331,6 +642,7 @@ export async function runGate(gate, check) {
|
|
|
331
642
|
id: gate.id,
|
|
332
643
|
configKey: gate.configKey,
|
|
333
644
|
enabledByDefault: gate.enabledByDefault,
|
|
645
|
+
severity: gate.severity ?? SEVERITY.DENY,
|
|
334
646
|
defaultParams: gate.defaultParams ?? {},
|
|
335
647
|
}),
|
|
336
648
|
);
|
|
@@ -340,11 +652,24 @@ export async function runGate(gate, check) {
|
|
|
340
652
|
const rawPayload = readHookPayload();
|
|
341
653
|
if (rawPayload === null) allow();
|
|
342
654
|
|
|
343
|
-
|
|
344
|
-
|
|
655
|
+
const cwd = process.cwd();
|
|
656
|
+
if (!isGateEnabled(gate.configKey, gate.enabledByDefault, cwd)) allow();
|
|
345
657
|
|
|
346
|
-
const
|
|
347
|
-
|
|
658
|
+
const { parameters, problems } = coerceParameters(
|
|
659
|
+
gate.defaultParams ?? {},
|
|
660
|
+
gateParameters(gate.configKey, cwd),
|
|
661
|
+
);
|
|
662
|
+
const toolName = toolNameOf(rawPayload) ?? '';
|
|
663
|
+
const toolInput = toolInputOf(rawPayload);
|
|
664
|
+
const sessionId = sessionIdOf(rawPayload);
|
|
665
|
+
setDecisionContext({
|
|
666
|
+
gateId: gate.id,
|
|
667
|
+
configKey: gate.configKey,
|
|
668
|
+
toolName,
|
|
669
|
+
toolInput,
|
|
670
|
+
sessionId,
|
|
671
|
+
cwd,
|
|
672
|
+
});
|
|
348
673
|
|
|
349
674
|
try {
|
|
350
675
|
// `check` may be sync or async; awaiting a non-promise is transparent, so the same
|
|
@@ -352,16 +677,64 @@ export async function runGate(gate, check) {
|
|
|
352
677
|
// async gate that permits early would never block.
|
|
353
678
|
await check({
|
|
354
679
|
rawPayload,
|
|
355
|
-
toolName
|
|
356
|
-
toolInput
|
|
357
|
-
sessionId
|
|
680
|
+
toolName,
|
|
681
|
+
toolInput,
|
|
682
|
+
sessionId,
|
|
358
683
|
parameters,
|
|
684
|
+
cwd,
|
|
685
|
+
toolResponse: toolResponseOf(rawPayload),
|
|
359
686
|
});
|
|
360
687
|
} catch (error) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
688
|
+
failClosedOrOpen(gate, error);
|
|
689
|
+
}
|
|
690
|
+
surfaceParameterProblems(gate, problems, sessionId);
|
|
691
|
+
allow();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The scaffolding for a Stop hook. Same config/params handling as runGate, plus the
|
|
696
|
+
* mandatory loop guard: when Claude Code re-invokes Stop hooks after one already blocked
|
|
697
|
+
* (`stop_hook_active: true`), this allows unconditionally — a session must never hang.
|
|
698
|
+
* `check` calls `block(label, reason)` to keep the turn going, or returns to allow. ANY
|
|
699
|
+
* failure allows: a broken Stop hook is worse than a missed reminder.
|
|
700
|
+
*/
|
|
701
|
+
export async function runStopHook(gate, check) {
|
|
702
|
+
if (process.env.CLAUDE_GATES_DUMP_DEFAULTS) {
|
|
703
|
+
process.stdout.write(
|
|
704
|
+
JSON.stringify({
|
|
705
|
+
id: gate.id,
|
|
706
|
+
configKey: gate.configKey,
|
|
707
|
+
enabledByDefault: gate.enabledByDefault,
|
|
708
|
+
severity: SEVERITY.WARN,
|
|
709
|
+
defaultParams: gate.defaultParams ?? {},
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
process.exit(0);
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
const rawPayload = readHookPayload();
|
|
716
|
+
if (rawPayload === null) allow();
|
|
717
|
+
const payload = parsePayload(rawPayload);
|
|
718
|
+
if (payload === null || payload.stop_hook_active === true) allow();
|
|
719
|
+
|
|
720
|
+
const cwd = process.cwd();
|
|
721
|
+
if (!isGateEnabled(gate.configKey, gate.enabledByDefault, cwd)) allow();
|
|
722
|
+
const { parameters } = coerceParameters(
|
|
723
|
+
gate.defaultParams ?? {},
|
|
724
|
+
gateParameters(gate.configKey, cwd),
|
|
364
725
|
);
|
|
726
|
+
const sessionId = sessionIdOf(rawPayload);
|
|
727
|
+
setDecisionContext({
|
|
728
|
+
gateId: gate.id,
|
|
729
|
+
configKey: gate.configKey,
|
|
730
|
+
toolName: 'Stop',
|
|
731
|
+
toolInput: {},
|
|
732
|
+
sessionId,
|
|
733
|
+
cwd,
|
|
734
|
+
});
|
|
735
|
+
await check({ rawPayload, payload, sessionId, parameters, cwd });
|
|
736
|
+
} catch {
|
|
737
|
+
// A broken Stop hook must never hang the session.
|
|
365
738
|
}
|
|
366
739
|
allow();
|
|
367
740
|
}
|