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