@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- 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 +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- 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 +101 -66
- 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 +71 -140
- 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 +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- 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 +53 -95
- 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 +338 -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 +48 -95
- 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/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- 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 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- 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 +152 -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 +451 -83
- 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 +148 -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 +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- 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
|
|
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
|
-
|
|
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
|
-
// 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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
320
|
-
* or returns to allow. A throw inside `check` is caught and turned into a deny
|
|
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
|
|
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
|
|
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
|
-
|
|
352
|
-
|
|
658
|
+
const cwd = process.cwd();
|
|
659
|
+
if (!isGateEnabled(gate.configKey, gate.enabledByDefault, cwd)) allow();
|
|
353
660
|
|
|
354
|
-
const
|
|
355
|
-
|
|
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
|
|
364
|
-
toolInput
|
|
365
|
-
sessionId
|
|
683
|
+
toolName,
|
|
684
|
+
toolInput,
|
|
685
|
+
sessionId,
|
|
366
686
|
parameters,
|
|
687
|
+
cwd,
|
|
688
|
+
toolResponse: toolResponseOf(rawPayload),
|
|
367
689
|
});
|
|
368
690
|
} catch (error) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
}
|