@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.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. 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
- /** The `matcher` string for a hooks.json entry: `Bash|Edit|Write`, or '' when empty. */
59
- export function matcherFor(groups) {
60
- return toolNamesFor(groups).join('|');
61
- }
62
-
63
- /** Reads the raw hook payload from stdin. Returns null when stdin cannot be read. */
64
- export function readHookPayload() {
65
- try {
66
- return readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8');
67
- } catch {
68
- return null;
69
- }
70
- }
71
-
72
- /**
73
- * The tool name inside a raw payload. Returns null when the payload is unparseable —
74
- * the caller cannot filter by tool then, so it must run every gate rather than skip
75
- * silently (erring toward over-running protects; erring toward skipping leaves a mute hole).
76
- */
77
- export function toolNameOf(rawPayload) {
78
- try {
79
- const payload = JSON.parse(rawPayload);
80
- return payload?.tool_name ?? payload?.name ?? '';
81
- } catch {
82
- return null;
83
- }
84
- }
85
-
86
- /** The session id inside a raw payload, or null when absent/unparseable. */
87
- export function sessionIdOf(rawPayload) {
88
- try {
89
- return JSON.parse(rawPayload)?.session_id ?? null;
90
- } catch {
91
- return null;
92
- }
93
- }
94
-
95
- /** The tool input object inside a raw payload, or {} when absent/unparseable. */
96
- export function toolInputOf(rawPayload) {
97
- try {
98
- const payload = JSON.parse(rawPayload);
99
- return payload?.tool_input ?? payload?.input ?? {};
100
- } catch {
101
- return {};
102
- }
103
- }
104
-
105
- const PRE_TOOL_USE_EVENT = 'PreToolUse';
106
-
107
- /**
108
- * A gate answers Claude Code in exactly one of three ways, and every gate uses these
109
- * emitters so the JSON shape is written once:
110
- * - deny: the rule is deterministic and the action is wrong. Blocks the tool call.
111
- * - warn: the question needs judgment; the gate surfaces context at the right moment
112
- * (additionalContext) and lets the call proceed.
113
- * - allow: nothing to say. The common path — silent and cheap.
114
- * Each emitter exits the process (exit 0 always: a PreToolUse deny is expressed in the
115
- * JSON, not in the exit code — reserving exit codes keeps a crash distinguishable).
116
- *
117
- * `label` is the gate id, prefixed to every message so a block names its source.
118
- */
119
- export function deny(label, reason) {
120
- process.stdout.write(
121
- JSON.stringify({
122
- hookSpecificOutput: {
123
- hookEventName: PRE_TOOL_USE_EVENT,
124
- permissionDecision: 'deny',
125
- permissionDecisionReason: `[${label}] ${reason}`,
126
- },
127
- }),
128
- );
129
- process.exit(0);
130
- }
131
-
132
- export function warn(label, context) {
133
- process.stdout.write(
134
- JSON.stringify({
135
- hookSpecificOutput: {
136
- hookEventName: PRE_TOOL_USE_EVENT,
137
- additionalContext: `[${label}] ${context}`,
138
- },
139
- }),
140
- );
141
- process.exit(0);
142
- }
143
-
144
- export function allow() {
145
- process.exit(0);
146
- }
147
-
148
- /**
149
- * The scaffolding every gate shares, so a gate file is just its rule. Given a gate's
150
- * identity and a `check` function, this: reads stdin (allow when unreadable), turns the
151
- * gate off when the project config disables it (allow), loads the project's params merged
152
- * over the gate's own defaults, and runs `check`. `check` calls `deny`/`warn` to object,
153
- * or returns to allow. A throw inside `check` is caught and turned into a deny — a gate
154
- * that cannot evaluate must not silently permit.
155
- *
156
- * @param {object} gate
157
- * @param {string} gate.id registry id, used as the message label
158
- * @param {string} gate.configKey the config flag that enables/disables this gate
159
- * @param {boolean} gate.enabledByDefault the registry default when config is silent
160
- * @param {object} [gate.defaultParams] the gate's built-in params (the readable defaults)
161
- * @param {(context: { rawPayload: string, toolName: string, toolInput: object,
162
- * sessionId: string|null, parameters: object }) => void} check
163
- */
164
- export async function runGate(gate, check) {
165
- // Defaults-dump mode: when the CLI spawns a gate with this flag set, the gate prints its
166
- // own descriptor (id, configKey, default flag and built-in params) and exits — before
167
- // touching stdin. This lets `init` materialize each gate's defaults into the config it
168
- // writes, with the gate as the single source of truth (no duplication in the registry).
169
- if (process.env.CLAUDE_GATES_DUMP_DEFAULTS) {
170
- process.stdout.write(
171
- JSON.stringify({
172
- id: gate.id,
173
- configKey: gate.configKey,
174
- enabledByDefault: gate.enabledByDefault,
175
- defaultParams: gate.defaultParams ?? {},
176
- }),
177
- );
178
- process.exit(0);
179
- }
180
-
181
- const rawPayload = readHookPayload();
182
- if (rawPayload === null) allow();
183
-
184
- if (!isGateEnabled(gate.configKey, gate.enabledByDefault, process.cwd()))
185
- allow();
186
-
187
- const declared = gateParameters(gate.configKey, process.cwd());
188
- const parameters = { ...(gate.defaultParams ?? {}), ...declared };
189
-
190
- try {
191
- // `check` may be sync or async; awaiting a non-promise is transparent, so the same
192
- // scaffolding serves both. Crucially, allow() runs only AFTER the check settles — an
193
- // async gate that permits early would never block.
194
- await check({
195
- rawPayload,
196
- toolName: toolNameOf(rawPayload) ?? '',
197
- toolInput: toolInputOf(rawPayload),
198
- sessionId: sessionIdOf(rawPayload),
199
- parameters,
200
- });
201
- } catch (error) {
202
- deny(
203
- gate.id,
204
- `The gate failed to evaluate and blocks the action for safety: ${error?.message ?? error}.`,
205
- );
206
- }
207
- allow();
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
+ );