@devrik-tools/claude-gates 0.7.2 → 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.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. 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 deny/warn decision out of a gate's own JSON output shape.
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,54 +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
- // 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
- // Anchored to the START of a command (start of string, or right after a separator like
182
- // `;`/`&&`/`||`/`|`), NOT `\b`, which matched "install" as a bare word anywhere — including
183
- // as npm/pip/yarn's SUBCOMMAND (`npm install -D daisyui` was misread as the Unix `install`
184
- // utility, capturing the package name "daisyui" as a phantom root-level file target and
185
- // tripping root-whitelist on a plain dependency install). `npm install`/`pip install`/
186
- // `yarn install` never start a shell command with the bare word "install" as argv[0], so
187
- // anchoring at the command boundary excludes them while still catching a real `install ...`
188
- // invocation (the coreutils/BSD command) at the start of a command.
189
- /(?:^|[;&|])\s*(?:cp|mv|install)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
190
- ];
191
-
192
- /**
193
- * Every filesystem path a shell command appears to create or write to (redirections, touch,
194
- * tee, cp/mv destinations). Returns a de-duplicated list, empty when none is found. A gate
195
- * that protects paths should check these IN ADDITION to writtenPathOf, or a shell redirection
196
- * slips past it (the exact hole that let `printf x > basura.txt` evade root-whitelist while a
197
- * Write to the same path was blocked).
198
- */
199
- export function shellWrittenPaths(command) {
200
- const text = String(command ?? '');
201
- const found = new Set();
202
- for (const pattern of SHELL_WRITE_PATTERNS) {
203
- for (const match of text.matchAll(pattern)) {
204
- const path = match[2];
205
- // Skip a dynamically built target (a variable/substitution): we cannot resolve `$f`,
206
- // `${x}` or `$(…)` to a real path, and guessing would only add false positives. This is
207
- // the documented limitation — the write-tool surface, which carries a concrete path,
208
- // stays the reliable one.
209
- if (path && !/[$`]/.test(path)) found.add(path);
210
- }
211
- }
212
- 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 ?? '');
213
197
  }
214
198
 
215
199
  /** The brief/prompt a delegation carries, across native and MCP field shapes. */
@@ -224,9 +208,116 @@ export function delegationPromptOf(toolInput) {
224
208
  toolInput.message ??
225
209
  toolInput.input ??
226
210
  '';
227
- 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 '';
228
223
  }
229
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
+
230
321
  /** Reads the raw hook payload from stdin. Returns null when stdin cannot be read. */
231
322
  export function readHookPayload() {
232
323
  try {
@@ -236,41 +327,192 @@ export function readHookPayload() {
236
327
  }
237
328
  }
238
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
+
239
339
  /**
240
340
  * The tool name inside a raw payload. Returns null when the payload is unparseable —
241
341
  * the caller cannot filter by tool then, so it must run every gate rather than skip
242
342
  * silently (erring toward over-running protects; erring toward skipping leaves a mute hole).
243
343
  */
244
344
  export function toolNameOf(rawPayload) {
245
- try {
246
- const payload = JSON.parse(rawPayload);
247
- return payload?.tool_name ?? payload?.name ?? '';
248
- } catch {
249
- return null;
250
- }
345
+ const payload = parsePayload(rawPayload);
346
+ if (payload === null) return null;
347
+ return String(payload.tool_name ?? payload.name ?? '');
251
348
  }
252
349
 
253
350
  /** The session id inside a raw payload, or null when absent/unparseable. */
254
351
  export function sessionIdOf(rawPayload) {
255
- try {
256
- return JSON.parse(rawPayload)?.session_id ?? null;
257
- } catch {
258
- return null;
259
- }
352
+ const payload = parsePayload(rawPayload);
353
+ const id = payload?.session_id;
354
+ return id === undefined || id === null ? null : String(id);
260
355
  }
261
356
 
262
357
  /** The tool input object inside a raw payload, or {} when absent/unparseable. */
263
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;
264
380
  try {
265
- const payload = JSON.parse(rawPayload);
266
- return payload?.tool_input ?? payload?.input ?? {};
381
+ return new RegExp(source, flags);
267
382
  } catch {
268
- 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);
269
486
  }
487
+ return { parameters, problems };
270
488
  }
271
489
 
490
+ // ── Decisions ───────────────────────────────────────────────────────────────────────
272
491
  const PRE_TOOL_USE_EVENT = 'PreToolUse';
273
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
+
274
516
  /**
275
517
  * A gate answers Claude Code in exactly one of three ways, and every gate uses these
276
518
  * emitters so the JSON shape is written once:
@@ -281,9 +523,11 @@ const PRE_TOOL_USE_EVENT = 'PreToolUse';
281
523
  * Each emitter exits the process (exit 0 always: a PreToolUse deny is expressed in the
282
524
  * JSON, not in the exit code — reserving exit codes keeps a crash distinguishable).
283
525
  *
284
- * `label` is the gate id, prefixed to every message so a block names its source.
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).
285
528
  */
286
529
  export function deny(label, reason) {
530
+ record(DECISIONS.DENY, reason);
287
531
  process.stdout.write(
288
532
  JSON.stringify({
289
533
  hookSpecificOutput: {
@@ -297,6 +541,7 @@ export function deny(label, reason) {
297
541
  }
298
542
 
299
543
  export function warn(label, context) {
544
+ record(DECISIONS.WARN, context);
300
545
  process.stdout.write(
301
546
  JSON.stringify({
302
547
  hookSpecificOutput: {
@@ -312,21 +557,79 @@ export function allow() {
312
557
  process.exit(0);
313
558
  }
314
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
+
315
615
  /**
316
616
  * The scaffolding every gate shares, so a gate file is just its rule. Given a gate's
317
617
  * identity and a `check` function, this: reads stdin (allow when unreadable), turns the
318
618
  * gate off when the project config disables it (allow), loads the project's params merged
319
- * over the gate's own defaults, and runs `check`. `check` calls `deny`/`warn` to object,
320
- * or returns to allow. A throw inside `check` is caught and turned into a deny — a gate
321
- * 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).
322
623
  *
323
624
  * @param {object} gate
324
- * @param {string} gate.id registry id, used as the message label
625
+ * @param {string} gate.id registry id
325
626
  * @param {string} gate.configKey the config flag that enables/disables this gate
326
627
  * @param {boolean} gate.enabledByDefault the registry default when config is silent
327
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)
328
630
  * @param {(context: { rawPayload: string, toolName: string, toolInput: object,
329
- * sessionId: string|null, parameters: object }) => void} check
631
+ * sessionId: string|null, parameters: object, cwd: string,
632
+ * toolResponse: unknown }) => void} check
330
633
  */
331
634
  export async function runGate(gate, check) {
332
635
  // Defaults-dump mode: when the CLI spawns a gate with this flag set, the gate prints its
@@ -339,6 +642,7 @@ export async function runGate(gate, check) {
339
642
  id: gate.id,
340
643
  configKey: gate.configKey,
341
644
  enabledByDefault: gate.enabledByDefault,
645
+ severity: gate.severity ?? SEVERITY.DENY,
342
646
  defaultParams: gate.defaultParams ?? {},
343
647
  }),
344
648
  );
@@ -348,11 +652,24 @@ export async function runGate(gate, check) {
348
652
  const rawPayload = readHookPayload();
349
653
  if (rawPayload === null) allow();
350
654
 
351
- if (!isGateEnabled(gate.configKey, gate.enabledByDefault, process.cwd()))
352
- allow();
655
+ const cwd = process.cwd();
656
+ if (!isGateEnabled(gate.configKey, gate.enabledByDefault, cwd)) allow();
353
657
 
354
- const declared = gateParameters(gate.configKey, process.cwd());
355
- const parameters = { ...(gate.defaultParams ?? {}), ...declared };
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
+ });
356
673
 
357
674
  try {
358
675
  // `check` may be sync or async; awaiting a non-promise is transparent, so the same
@@ -360,16 +677,64 @@ export async function runGate(gate, check) {
360
677
  // async gate that permits early would never block.
361
678
  await check({
362
679
  rawPayload,
363
- toolName: toolNameOf(rawPayload) ?? '',
364
- toolInput: toolInputOf(rawPayload),
365
- sessionId: sessionIdOf(rawPayload),
680
+ toolName,
681
+ toolInput,
682
+ sessionId,
366
683
  parameters,
684
+ cwd,
685
+ toolResponse: toolResponseOf(rawPayload),
367
686
  });
368
687
  } catch (error) {
369
- deny(
370
- gate.configKey,
371
- `The gate failed to evaluate and blocks the action for safety: ${error?.message ?? error}.`,
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),
372
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.
373
738
  }
374
739
  allow();
375
740
  }