@devrik-tools/claude-gates 0.1.2 → 0.2.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 +7 -1
- package/cli/__tests__/config.test.mjs +113 -101
- package/cli/__tests__/install.test.mjs +102 -0
- package/cli/__tests__/materialize.test.mjs +95 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
- package/cli/__tests__/task.test.mjs +124 -0
- package/cli/__tests__/version-consistency.test.mjs +44 -0
- package/cli/config.mjs +124 -113
- package/cli/index.mjs +100 -97
- package/cli/init.mjs +259 -256
- package/cli/install.mjs +150 -80
- package/cli/materialize.mjs +102 -51
- package/cli/registry.mjs +136 -127
- package/cli/task.mjs +140 -0
- package/package.json +2 -1
- package/plugins/gates/.claude-plugin/plugin.json +8 -2
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
- package/plugins/gates/hooks/ask-adoption.mjs +147 -0
- package/plugins/gates/hooks/doctor.mjs +207 -0
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
- package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
- package/plugins/gates/hooks/hooks.json +346 -286
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
- package/plugins/gates/hooks/lib/config.mjs +172 -165
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
- package/plugins/gates/hooks/lib/signals.mjs +127 -0
- package/plugins/gates/hooks/wiring-check.mjs +227 -0
- package/plugins/tasks/.claude-plugin/plugin.json +14 -0
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
- package/plugins/tasks/hooks/hooks.json +26 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
- package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
- package/plugins/tasks/hooks/register-requests.mjs +141 -108
- package/plugins/tasks/hooks/session-tasks.mjs +108 -0
- package/registry.json +793 -686
|
@@ -1,208 +1,318 @@
|
|
|
1
|
-
// Process I/O shared by every gate. Self-contained: Node built-ins only, so a gate keeps
|
|
2
|
-
// working when installed outside this repo.
|
|
3
|
-
//
|
|
4
|
-
// There is no dispatcher: Claude Code runs one hook entry per gate natively (each with its
|
|
5
|
-
// own `matcher`, all matching entries in parallel), so each gate is its own process and
|
|
6
|
-
// reads its own stdin. This module holds what every gate needs, none of it gate-specific:
|
|
7
|
-
// 1. Reading and parsing the hook payload Claude Code writes to stdin.
|
|
8
|
-
// 2. Translating the registry's semantic tool groups (write/shell/...) into the concrete
|
|
9
|
-
// tool names Claude Code sends — used to derive each gate's `matcher` and, when a gate
|
|
10
|
-
// also inspects delegation prompts, to recognize the tool at runtime.
|
|
11
|
-
// 3. Reading a deny/warn decision out of a gate's own JSON output shape.
|
|
12
|
-
|
|
13
|
-
import { readFileSync } from 'node:fs';
|
|
14
|
-
import { gateParameters, isGateEnabled } from './config.mjs';
|
|
15
|
-
|
|
16
|
-
const STDIN_FILE_DESCRIPTOR = 0;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Semantic tool groups (as declared in registry.json's `tools`) mapped to the concrete
|
|
20
|
-
* tool names Claude Code emits. Keeping the map here (not in the registry) keeps the
|
|
21
|
-
* catalog declarative: which tool names exist is a fact about the Claude Code runtime,
|
|
22
|
-
* not about the gate. `matcherFor` turns a group set into the `Tool1|Tool2` string a
|
|
23
|
-
* hooks.json entry uses; `toolNamesFor` gives the flat list a gate matches at runtime.
|
|
24
|
-
*/
|
|
25
|
-
export const TOOL_GROUPS = Object.freeze({
|
|
26
|
-
write: [
|
|
27
|
-
'Write',
|
|
28
|
-
'Edit',
|
|
29
|
-
'NotebookEdit',
|
|
30
|
-
'write_to_file',
|
|
31
|
-
'replace_file_content',
|
|
32
|
-
],
|
|
33
|
-
shell: ['Bash', 'run_command'],
|
|
34
|
-
delegation: ['Agent', 'Task', 'invoke_subagent'],
|
|
35
|
-
question: ['AskUserQuestion'],
|
|
36
|
-
// The main agent materializing a change directly, whatever the surface.
|
|
37
|
-
execution: [
|
|
38
|
-
'Write',
|
|
39
|
-
'Edit',
|
|
40
|
-
'NotebookEdit',
|
|
41
|
-
'write_to_file',
|
|
42
|
-
'replace_file_content',
|
|
43
|
-
'Bash',
|
|
44
|
-
'run_command',
|
|
45
|
-
'mcp__ide__executeCode',
|
|
46
|
-
],
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
/** Every concrete tool name a set of groups expands to, de-duplicated. */
|
|
50
|
-
export function toolNamesFor(groups) {
|
|
51
|
-
const names = new Set();
|
|
52
|
-
for (const group of groups) {
|
|
53
|
-
for (const name of TOOL_GROUPS[group] ?? []) names.add(name);
|
|
54
|
-
}
|
|
55
|
-
return [...names];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
1
|
+
// Process I/O shared by every gate. Self-contained: Node built-ins only, so a gate keeps
|
|
2
|
+
// working when installed outside this repo.
|
|
3
|
+
//
|
|
4
|
+
// There is no dispatcher: Claude Code runs one hook entry per gate natively (each with its
|
|
5
|
+
// own `matcher`, all matching entries in parallel), so each gate is its own process and
|
|
6
|
+
// reads its own stdin. This module holds what every gate needs, none of it gate-specific:
|
|
7
|
+
// 1. Reading and parsing the hook payload Claude Code writes to stdin.
|
|
8
|
+
// 2. Translating the registry's semantic tool groups (write/shell/...) into the concrete
|
|
9
|
+
// tool names Claude Code sends — used to derive each gate's `matcher` and, when a gate
|
|
10
|
+
// also inspects delegation prompts, to recognize the tool at runtime.
|
|
11
|
+
// 3. Reading a deny/warn decision out of a gate's own JSON output shape.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { gateParameters, isGateEnabled } from './config.mjs';
|
|
15
|
+
|
|
16
|
+
const STDIN_FILE_DESCRIPTOR = 0;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Semantic tool groups (as declared in registry.json's `tools`) mapped to the concrete
|
|
20
|
+
* tool names Claude Code emits. Keeping the map here (not in the registry) keeps the
|
|
21
|
+
* catalog declarative: which tool names exist is a fact about the Claude Code runtime,
|
|
22
|
+
* not about the gate. `matcherFor` turns a group set into the `Tool1|Tool2` string a
|
|
23
|
+
* hooks.json entry uses; `toolNamesFor` gives the flat list a gate matches at runtime.
|
|
24
|
+
*/
|
|
25
|
+
export const TOOL_GROUPS = Object.freeze({
|
|
26
|
+
write: [
|
|
27
|
+
'Write',
|
|
28
|
+
'Edit',
|
|
29
|
+
'NotebookEdit',
|
|
30
|
+
'write_to_file',
|
|
31
|
+
'replace_file_content',
|
|
32
|
+
],
|
|
33
|
+
shell: ['Bash', 'run_command'],
|
|
34
|
+
delegation: ['Agent', 'Task', 'invoke_subagent'],
|
|
35
|
+
question: ['AskUserQuestion'],
|
|
36
|
+
// The main agent materializing a change directly, whatever the surface.
|
|
37
|
+
execution: [
|
|
38
|
+
'Write',
|
|
39
|
+
'Edit',
|
|
40
|
+
'NotebookEdit',
|
|
41
|
+
'write_to_file',
|
|
42
|
+
'replace_file_content',
|
|
43
|
+
'Bash',
|
|
44
|
+
'run_command',
|
|
45
|
+
'mcp__ide__executeCode',
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Every concrete tool name a set of groups expands to, de-duplicated. */
|
|
50
|
+
export function toolNamesFor(groups) {
|
|
51
|
+
const names = new Set();
|
|
52
|
+
for (const group of groups) {
|
|
53
|
+
for (const name of TOOL_GROUPS[group] ?? []) names.add(name);
|
|
54
|
+
}
|
|
55
|
+
return [...names];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* MCP tools are named `mcp__<server>__<tool>`, and any connected server can expose a write,
|
|
60
|
+
* shell, delegation or ask surface under a name this repo has never seen. Enumerating exact
|
|
61
|
+
* names (the old approach) left every such tool invisible: the gate never fired. Instead,
|
|
62
|
+
* each group carries a substring rule matched against the MCP tool's action segment, so a
|
|
63
|
+
* NEW server's `mcp__fs__write_file` or `mcp__shell__exec` is classified by what it does, not
|
|
64
|
+
* by a name we had to know in advance. The rule intentionally over-includes (a false match
|
|
65
|
+
* makes a gate inspect a payload it then finds benign — cheap) rather than under-includes (a
|
|
66
|
+
* miss is a silent hole). `question` stays deny-heavy so autonomous mode cannot be dodged by
|
|
67
|
+
* an MCP ask tool. Native (non-mcp) names are still matched exactly via TOOL_GROUPS.
|
|
68
|
+
*/
|
|
69
|
+
const MCP_GROUP_SIGNALS = Object.freeze({
|
|
70
|
+
write: /(?:write|edit|create|append|patch|replace|insert|modify|save|update)/i,
|
|
71
|
+
shell: /(?:shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
|
|
72
|
+
delegation: /(?:agent|task|delegat|subagent|spawn|dispatch|orchestrat|worker)/i,
|
|
73
|
+
question: /(?:ask|question|confirm|prompt|approv|choice|elicit|clarif)/i,
|
|
74
|
+
execution: /(?:write|edit|create|append|patch|replace|insert|modify|save|update|shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const MCP_TOOL_PREFIX = 'mcp__';
|
|
78
|
+
|
|
79
|
+
/** The action segment of an MCP tool name (`mcp__server__do_thing` -> `do_thing`), or ''. */
|
|
80
|
+
function mcpActionSegment(toolName) {
|
|
81
|
+
if (!toolName.startsWith(MCP_TOOL_PREFIX)) return '';
|
|
82
|
+
const parts = toolName.split('__');
|
|
83
|
+
return parts.length >= 3 ? parts.slice(2).join('__') : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether a tool name belongs to any of the given groups. A native tool matches by exact
|
|
88
|
+
* membership; an MCP tool (`mcp__*`) matches when its action segment hits the group's signal
|
|
89
|
+
* regex. This is what every gate should use instead of a private `Set.has(toolName)` — the
|
|
90
|
+
* private sets were the second half of the MCP blind spot (even a payload that reached the
|
|
91
|
+
* gate was rejected by an exact-name check).
|
|
92
|
+
*/
|
|
93
|
+
export function toolInGroups(toolName, groups) {
|
|
94
|
+
if (!toolName) return false;
|
|
95
|
+
const native = toolNamesFor(groups);
|
|
96
|
+
// Native tool names from Claude Code are canonical (`AskUserQuestion`), but match
|
|
97
|
+
// case-insensitively so a differently-cased spelling from any surface can't dodge a gate.
|
|
98
|
+
const lowered = toolName.toLowerCase();
|
|
99
|
+
if (native.some((name) => name.toLowerCase() === lowered)) return true;
|
|
100
|
+
const action = mcpActionSegment(toolName);
|
|
101
|
+
if (!action) return false;
|
|
102
|
+
return groups.some((group) => MCP_GROUP_SIGNALS[group]?.test(action));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The `matcher` string for a hooks.json entry. Native names are listed explicitly; a trailing
|
|
107
|
+
* `mcp__.*` alternative makes Claude Code also route EVERY MCP tool call to the hook, so the
|
|
108
|
+
* gate can classify it at runtime with `toolInGroups`. Without the `mcp__.*` clause the hook
|
|
109
|
+
* is never even invoked for an MCP tool — the deepest layer of the blind spot, since no
|
|
110
|
+
* runtime check can compensate for a hook that never runs.
|
|
111
|
+
*/
|
|
112
|
+
export function matcherFor(groups) {
|
|
113
|
+
return [...toolNamesFor(groups), String.raw`mcp__.*`].join('|');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The content a write-style tool is about to put on disk, across every native and MCP field
|
|
118
|
+
* shape seen in the wild: Write/create (`content`), Edit (`new_string`), NotebookEdit
|
|
119
|
+
* (`new_source`), MultiEdit (`edits[].new_string`), replace_file_content (`new_content`,
|
|
120
|
+
* `ReplacementContent`), and MCP variants (`text`, `data`, `CodeContent`). Returns '' when
|
|
121
|
+
* none is present. A gate that inspects written text MUST read through this, so a differently
|
|
122
|
+
* shaped payload can no longer degrade silently to '' and slip past.
|
|
123
|
+
*/
|
|
124
|
+
export function writtenContentOf(toolInput) {
|
|
125
|
+
if (!toolInput || typeof toolInput !== 'object') return '';
|
|
126
|
+
if (Array.isArray(toolInput.edits)) {
|
|
127
|
+
return toolInput.edits
|
|
128
|
+
.map((edit) => String(edit?.new_string ?? edit?.new_source ?? edit?.content ?? ''))
|
|
129
|
+
.join('\n');
|
|
130
|
+
}
|
|
131
|
+
const direct =
|
|
132
|
+
toolInput.content ??
|
|
133
|
+
toolInput.new_string ??
|
|
134
|
+
toolInput.new_source ??
|
|
135
|
+
toolInput.new_content ??
|
|
136
|
+
toolInput.ReplacementContent ??
|
|
137
|
+
toolInput.CodeContent ??
|
|
138
|
+
toolInput.text ??
|
|
139
|
+
toolInput.data ??
|
|
140
|
+
'';
|
|
141
|
+
return String(direct);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The file path a write-style tool targets, across native and MCP field shapes. */
|
|
145
|
+
export function writtenPathOf(toolInput) {
|
|
146
|
+
if (!toolInput || typeof toolInput !== 'object') return '';
|
|
147
|
+
const path =
|
|
148
|
+
toolInput.file_path ??
|
|
149
|
+
toolInput.path ??
|
|
150
|
+
toolInput.target_file ??
|
|
151
|
+
toolInput.notebook_path ??
|
|
152
|
+
toolInput.filename ??
|
|
153
|
+
toolInput.uri ??
|
|
154
|
+
'';
|
|
155
|
+
return String(path);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The brief/prompt a delegation carries, across native and MCP field shapes. */
|
|
159
|
+
export function delegationPromptOf(toolInput) {
|
|
160
|
+
if (!toolInput || typeof toolInput !== 'object') return '';
|
|
161
|
+
const prompt =
|
|
162
|
+
toolInput.prompt ??
|
|
163
|
+
toolInput.Prompt ??
|
|
164
|
+
toolInput.description ??
|
|
165
|
+
toolInput.task ??
|
|
166
|
+
toolInput.instructions ??
|
|
167
|
+
toolInput.message ??
|
|
168
|
+
toolInput.input ??
|
|
169
|
+
'';
|
|
170
|
+
return String(prompt);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Reads the raw hook payload from stdin. Returns null when stdin cannot be read. */
|
|
174
|
+
export function readHookPayload() {
|
|
175
|
+
try {
|
|
176
|
+
return readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8');
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The tool name inside a raw payload. Returns null when the payload is unparseable —
|
|
184
|
+
* the caller cannot filter by tool then, so it must run every gate rather than skip
|
|
185
|
+
* silently (erring toward over-running protects; erring toward skipping leaves a mute hole).
|
|
186
|
+
*/
|
|
187
|
+
export function toolNameOf(rawPayload) {
|
|
188
|
+
try {
|
|
189
|
+
const payload = JSON.parse(rawPayload);
|
|
190
|
+
return payload?.tool_name ?? payload?.name ?? '';
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The session id inside a raw payload, or null when absent/unparseable. */
|
|
197
|
+
export function sessionIdOf(rawPayload) {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(rawPayload)?.session_id ?? null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The tool input object inside a raw payload, or {} when absent/unparseable. */
|
|
206
|
+
export function toolInputOf(rawPayload) {
|
|
207
|
+
try {
|
|
208
|
+
const payload = JSON.parse(rawPayload);
|
|
209
|
+
return payload?.tool_input ?? payload?.input ?? {};
|
|
210
|
+
} catch {
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const PRE_TOOL_USE_EVENT = 'PreToolUse';
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A gate answers Claude Code in exactly one of three ways, and every gate uses these
|
|
219
|
+
* emitters so the JSON shape is written once:
|
|
220
|
+
* - deny: the rule is deterministic and the action is wrong. Blocks the tool call.
|
|
221
|
+
* - warn: the question needs judgment; the gate surfaces context at the right moment
|
|
222
|
+
* (additionalContext) and lets the call proceed.
|
|
223
|
+
* - allow: nothing to say. The common path — silent and cheap.
|
|
224
|
+
* Each emitter exits the process (exit 0 always: a PreToolUse deny is expressed in the
|
|
225
|
+
* JSON, not in the exit code — reserving exit codes keeps a crash distinguishable).
|
|
226
|
+
*
|
|
227
|
+
* `label` is the gate id, prefixed to every message so a block names its source.
|
|
228
|
+
*/
|
|
229
|
+
export function deny(label, reason) {
|
|
230
|
+
process.stdout.write(
|
|
231
|
+
JSON.stringify({
|
|
232
|
+
hookSpecificOutput: {
|
|
233
|
+
hookEventName: PRE_TOOL_USE_EVENT,
|
|
234
|
+
permissionDecision: 'deny',
|
|
235
|
+
permissionDecisionReason: `[${label}] ${reason}`,
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
process.exit(0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function warn(label, context) {
|
|
243
|
+
process.stdout.write(
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
hookSpecificOutput: {
|
|
246
|
+
hookEventName: PRE_TOOL_USE_EVENT,
|
|
247
|
+
additionalContext: `[${label}] ${context}`,
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function allow() {
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The scaffolding every gate shares, so a gate file is just its rule. Given a gate's
|
|
260
|
+
* identity and a `check` function, this: reads stdin (allow when unreadable), turns the
|
|
261
|
+
* gate off when the project config disables it (allow), loads the project's params merged
|
|
262
|
+
* over the gate's own defaults, and runs `check`. `check` calls `deny`/`warn` to object,
|
|
263
|
+
* or returns to allow. A throw inside `check` is caught and turned into a deny — a gate
|
|
264
|
+
* that cannot evaluate must not silently permit.
|
|
265
|
+
*
|
|
266
|
+
* @param {object} gate
|
|
267
|
+
* @param {string} gate.id registry id, used as the message label
|
|
268
|
+
* @param {string} gate.configKey the config flag that enables/disables this gate
|
|
269
|
+
* @param {boolean} gate.enabledByDefault the registry default when config is silent
|
|
270
|
+
* @param {object} [gate.defaultParams] the gate's built-in params (the readable defaults)
|
|
271
|
+
* @param {(context: { rawPayload: string, toolName: string, toolInput: object,
|
|
272
|
+
* sessionId: string|null, parameters: object }) => void} check
|
|
273
|
+
*/
|
|
274
|
+
export async function runGate(gate, check) {
|
|
275
|
+
// Defaults-dump mode: when the CLI spawns a gate with this flag set, the gate prints its
|
|
276
|
+
// own descriptor (id, configKey, default flag and built-in params) and exits — before
|
|
277
|
+
// touching stdin. This lets `init` materialize each gate's defaults into the config it
|
|
278
|
+
// writes, with the gate as the single source of truth (no duplication in the registry).
|
|
279
|
+
if (process.env.CLAUDE_GATES_DUMP_DEFAULTS) {
|
|
280
|
+
process.stdout.write(
|
|
281
|
+
JSON.stringify({
|
|
282
|
+
id: gate.id,
|
|
283
|
+
configKey: gate.configKey,
|
|
284
|
+
enabledByDefault: gate.enabledByDefault,
|
|
285
|
+
defaultParams: gate.defaultParams ?? {},
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
process.exit(0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const rawPayload = readHookPayload();
|
|
292
|
+
if (rawPayload === null) allow();
|
|
293
|
+
|
|
294
|
+
if (!isGateEnabled(gate.configKey, gate.enabledByDefault, process.cwd()))
|
|
295
|
+
allow();
|
|
296
|
+
|
|
297
|
+
const declared = gateParameters(gate.configKey, process.cwd());
|
|
298
|
+
const parameters = { ...(gate.defaultParams ?? {}), ...declared };
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
// `check` may be sync or async; awaiting a non-promise is transparent, so the same
|
|
302
|
+
// scaffolding serves both. Crucially, allow() runs only AFTER the check settles — an
|
|
303
|
+
// async gate that permits early would never block.
|
|
304
|
+
await check({
|
|
305
|
+
rawPayload,
|
|
306
|
+
toolName: toolNameOf(rawPayload) ?? '',
|
|
307
|
+
toolInput: toolInputOf(rawPayload),
|
|
308
|
+
sessionId: sessionIdOf(rawPayload),
|
|
309
|
+
parameters,
|
|
310
|
+
});
|
|
311
|
+
} catch (error) {
|
|
312
|
+
deny(
|
|
313
|
+
gate.id,
|
|
314
|
+
`The gate failed to evaluate and blocks the action for safety: ${error?.message ?? error}.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
allow();
|
|
318
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// signals.mjs — central catalog of LEXICAL prose signals, each covering Spanish AND
|
|
2
|
+
// English, for every gate that matches free text (briefs, delegation prompts, written
|
|
3
|
+
// content). Written once here; gates import instead of keeping their own monolingual or
|
|
4
|
+
// partially-bilingual copy.
|
|
5
|
+
//
|
|
6
|
+
// ── Scope: lexical only ──────────────────────────────────────────────────────────────
|
|
7
|
+
// A gate whose real signal lives in the ACTION (a shell command, a tool name, a file
|
|
8
|
+
// path) keeps using structural detection for that part — this module does not replace
|
|
9
|
+
// toolInGroups/writtenPathOf/etc., and a gate should prefer the structural check first
|
|
10
|
+
// wherever the signal is genuinely structural. This module exists for the remaining
|
|
11
|
+
// case: prose written by a human or a model, in either language, that a gate must
|
|
12
|
+
// recognize regardless of which language it was written in.
|
|
13
|
+
//
|
|
14
|
+
// ── Honest limitation ────────────────────────────────────────────────────────────────
|
|
15
|
+
// Coverage is ES + EN only. A prompt written in a third language (or a language-neutral
|
|
16
|
+
// paraphrase using none of these terms) is NOT covered by any pattern here — this module
|
|
17
|
+
// closes the specific ES/EN gap the gates had, it does not claim language-agnostic
|
|
18
|
+
// detection. Where the underlying signal can instead be read off the action itself
|
|
19
|
+
// (structural), that stays the primary defense; lexical matching is the fallback layer
|
|
20
|
+
// for prose that never had a structural signal to read.
|
|
21
|
+
//
|
|
22
|
+
// ── Word-boundary and accent handling ────────────────────────────────────────────────
|
|
23
|
+
// `withUnicodeWordBoundary` uses lookarounds over `\p{L}|\p{N}|_` (not `\b`, which does
|
|
24
|
+
// not treat accented letters as word characters) so short/dangerous terms ("auth",
|
|
25
|
+
// "prod") require a full word match and do not fire inside "autor" or "producto".
|
|
26
|
+
// Spanish terms that carry an accent in correct spelling (contraseña, sesión, migración)
|
|
27
|
+
// are matched with an optional accent (`sesi[oó]n`) because normalized/ASCII input is
|
|
28
|
+
// common in prompts and commit messages.
|
|
29
|
+
|
|
30
|
+
/** Unicode-aware word boundary: matches only when the alternation is not adjacent to
|
|
31
|
+
* another letter/digit/underscore, so short terms cannot match as a substring of a
|
|
32
|
+
* longer unrelated word. */
|
|
33
|
+
export function withUnicodeWordBoundary(alternatives) {
|
|
34
|
+
return new RegExp(
|
|
35
|
+
`(?<![\\p{L}\\p{N}_])(?:${alternatives})(?![\\p{L}\\p{N}_])`,
|
|
36
|
+
'iu',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── RISK_SIGNAL: high-impact mutation area (money / auth / destructive-deploy) ───────
|
|
41
|
+
// Used where a gate must recognize that a request touches a sensitive domain,
|
|
42
|
+
// regardless of whether it was phrased in Spanish or English. Three sub-domains kept
|
|
43
|
+
// separate so a gate can compose only the ones it needs, and one combined export for
|
|
44
|
+
// gates that want the whole class.
|
|
45
|
+
|
|
46
|
+
const MONEY_TERMS =
|
|
47
|
+
'money|payment|pago|dinero|cobro|charge|invoice|factura|precio|price|saldo|balance|amount|monto';
|
|
48
|
+
|
|
49
|
+
const AUTH_TERMS =
|
|
50
|
+
'auth|autenticaci[oó]n|authentication|credencial|credential|contrase[nñ]a|password|' +
|
|
51
|
+
'token|sesi[oó]n|session|permiso|permission|role|rol';
|
|
52
|
+
|
|
53
|
+
const DESTRUCTIVE_DEPLOY_TERMS =
|
|
54
|
+
'deploy|desplieg(?:a|ue|ar)?|producci[oó]n|production|prod|borrar|delete|drop|truncate|' +
|
|
55
|
+
'migraci[oó]n|migration|migrate|migr(?:a|ar)|eliminar';
|
|
56
|
+
|
|
57
|
+
export const MONEY_SIGNAL = withUnicodeWordBoundary(MONEY_TERMS);
|
|
58
|
+
export const AUTH_SIGNAL = withUnicodeWordBoundary(AUTH_TERMS);
|
|
59
|
+
export const DESTRUCTIVE_DEPLOY_SIGNAL = withUnicodeWordBoundary(
|
|
60
|
+
DESTRUCTIVE_DEPLOY_TERMS,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/** Combined high-impact mutation signal: money OR auth OR a destructive/deploy verb. */
|
|
64
|
+
export const RISK_SIGNAL = withUnicodeWordBoundary(
|
|
65
|
+
`${MONEY_TERMS}|${AUTH_TERMS}|${DESTRUCTIVE_DEPLOY_TERMS}`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** RISK_SIGNAL as plain regex-source alternatives (no boundary/flags), for gates that
|
|
69
|
+
* build their own combined pattern (e.g. joined with other domain-specific sources). */
|
|
70
|
+
export const RISK_SIGNAL_SOURCES = [MONEY_TERMS, AUTH_TERMS, DESTRUCTIVE_DEPLOY_TERMS];
|
|
71
|
+
|
|
72
|
+
// ── MUTATION_RISK_SIGNAL: broader than RISK_SIGNAL, used only to VOID an exemption ──
|
|
73
|
+
// Several gates let a whitelisted read-only subagent name (or an exempt-query verb)
|
|
74
|
+
// skip their check — but only when the prompt itself carries no mutation risk. That
|
|
75
|
+
// exemption-voiding check is deliberately broader than RISK_SIGNAL: it also counts a
|
|
76
|
+
// bare "data"/"datos"/"write"/"escrib*" mention, because the name/label is unverified
|
|
77
|
+
// and a false positive here only means the underlying check still runs (cheap), while a
|
|
78
|
+
// false negative would let a real mutator dodge the check via a trusted-sounding label.
|
|
79
|
+
// Kept as a distinct export from RISK_SIGNAL because the two answer different
|
|
80
|
+
// questions (RISK_SIGNAL: "is this domain high-impact?" vs MUTATION_RISK_SIGNAL: "is it
|
|
81
|
+
// unsafe to trust this label/verb at all?").
|
|
82
|
+
const MUTATION_RISK_TERMS =
|
|
83
|
+
`${MONEY_TERMS}|${AUTH_TERMS}|credencial|credential|data|datos|borrar|delete|drop|` +
|
|
84
|
+
`write|escrib|${DESTRUCTIVE_DEPLOY_TERMS}`;
|
|
85
|
+
|
|
86
|
+
export const MUTATION_RISK_SIGNAL = withUnicodeWordBoundary(MUTATION_RISK_TERMS);
|
|
87
|
+
|
|
88
|
+
// ── CONJECTURE: never-assume phrasing ────────────────────────────────────────────────
|
|
89
|
+
// Prose stating an unverified assumption instead of a checked fact.
|
|
90
|
+
|
|
91
|
+
export const CONJECTURE_SOURCES = [
|
|
92
|
+
'i assume',
|
|
93
|
+
'assuming that',
|
|
94
|
+
'probably',
|
|
95
|
+
'i guess',
|
|
96
|
+
'should be',
|
|
97
|
+
"i'll default to",
|
|
98
|
+
'might be',
|
|
99
|
+
'supongo',
|
|
100
|
+
'asumo',
|
|
101
|
+
'asumiendo que',
|
|
102
|
+
'probablemente',
|
|
103
|
+
'deber[ií]a ser',
|
|
104
|
+
'quiz[aá]s',
|
|
105
|
+
'tal vez',
|
|
106
|
+
'creo que',
|
|
107
|
+
'me imagino que',
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
export const CONJECTURE = withUnicodeWordBoundary(CONJECTURE_SOURCES.join('|'));
|
|
111
|
+
|
|
112
|
+
// ── PERSISTENCE_VERB: an instruction to save/persist/store something ────────────────
|
|
113
|
+
// Used to detect a REAL persistence instruction nearby a memory-dependency phrase (see
|
|
114
|
+
// no-memory-dependency), so a bare mention of a memory phrase with a genuine "save this
|
|
115
|
+
// to <file>" nearby is not flagged as depending on model memory.
|
|
116
|
+
|
|
117
|
+
export const PERSISTENCE_VERB_SOURCES = [
|
|
118
|
+
'save|persist|store',
|
|
119
|
+
'guarda(?:l[oa])?|guard[aá]|persist[eií]|persistir|persistido|almacena|almacenar',
|
|
120
|
+
'escrib(?:e|í|i) .{0,20}en|escribir .{0,20}en|write .{0,20}(?:to|in)',
|
|
121
|
+
'anota|anotar|registra|registrar',
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
export const PERSISTENCE_VERB = new RegExp(
|
|
125
|
+
PERSISTENCE_VERB_SOURCES.join('|'),
|
|
126
|
+
'iu',
|
|
127
|
+
);
|