@devrik-tools/claude-gates 0.1.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 (80) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.es.md +219 -0
  3. package/README.md +222 -0
  4. package/cli/__tests__/config.test.mjs +101 -0
  5. package/cli/__tests__/init-flags.test.mjs +111 -0
  6. package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
  7. package/cli/__tests__/selection.test.mjs +200 -0
  8. package/cli/config.mjs +113 -0
  9. package/cli/constants.mjs +36 -0
  10. package/cli/index.mjs +97 -0
  11. package/cli/init.mjs +256 -0
  12. package/cli/install.mjs +80 -0
  13. package/cli/materialize.mjs +51 -0
  14. package/cli/registry.mjs +127 -0
  15. package/cli/selection.mjs +83 -0
  16. package/package.json +73 -0
  17. package/plugins/gates/.claude-plugin/plugin.json +9 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
  19. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
  21. package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
  22. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
  23. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
  24. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
  25. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
  26. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
  27. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
  28. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
  29. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
  30. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
  31. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
  32. package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
  33. package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
  34. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
  35. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
  36. package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
  37. package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
  38. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
  39. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
  40. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
  41. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
  42. package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
  43. package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
  44. package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
  45. package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
  46. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
  47. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
  48. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
  49. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
  50. package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
  51. package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
  52. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
  53. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
  54. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
  55. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
  57. package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
  58. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
  59. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
  60. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
  61. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
  62. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
  65. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
  66. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
  67. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
  69. package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
  70. package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
  71. package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
  72. package/plugins/gates/hooks/hooks.json +266 -0
  73. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
  74. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
  75. package/plugins/gates/hooks/lib/config.mjs +165 -0
  76. package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
  77. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
  78. package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
  79. package/plugins/tasks/hooks/register-requests.mjs +108 -0
  80. package/registry.json +668 -0
@@ -0,0 +1,266 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash|run_command|Agent|Task|invoke_subagent",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/bash-commands/index.mjs\"",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ },
14
+ {
15
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/protected-paths/index.mjs\"",
20
+ "timeout": 10
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content",
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/root-whitelist/index.mjs\"",
30
+ "timeout": 10
31
+ }
32
+ ]
33
+ },
34
+ {
35
+ "matcher": "Bash|run_command|Agent|Task|invoke_subagent",
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-blocking/index.mjs\"",
40
+ "timeout": 10
41
+ }
42
+ ]
43
+ },
44
+ {
45
+ "matcher": "Agent|Task|invoke_subagent",
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/brief-before-delegate/index.mjs\"",
50
+ "timeout": 10
51
+ }
52
+ ]
53
+ },
54
+ {
55
+ "matcher": "Agent|Task|invoke_subagent",
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/intent-flow/index.mjs\"",
60
+ "timeout": 10
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "matcher": "Agent|Task|invoke_subagent",
66
+ "hooks": [
67
+ {
68
+ "type": "command",
69
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/risk-level/index.mjs\"",
70
+ "timeout": 10
71
+ }
72
+ ]
73
+ },
74
+ {
75
+ "matcher": "Agent|Task|invoke_subagent",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/circuit-breaker/index.mjs\"",
80
+ "timeout": 10
81
+ }
82
+ ]
83
+ },
84
+ {
85
+ "matcher": "Agent|Task|invoke_subagent",
86
+ "hooks": [
87
+ {
88
+ "type": "command",
89
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-memory-dependency/index.mjs\"",
90
+ "timeout": 10
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content",
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/feature-catalog/index.mjs\"",
100
+ "timeout": 10
101
+ }
102
+ ]
103
+ },
104
+ {
105
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent",
106
+ "hooks": [
107
+ {
108
+ "type": "command",
109
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/sdd-specs/index.mjs\"",
110
+ "timeout": 10
111
+ }
112
+ ]
113
+ },
114
+ {
115
+ "matcher": "Agent|Task|invoke_subagent",
116
+ "hooks": [
117
+ {
118
+ "type": "command",
119
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/implementation-pipeline/index.mjs\"",
120
+ "timeout": 10
121
+ }
122
+ ]
123
+ },
124
+ {
125
+ "matcher": "Agent|Task|invoke_subagent",
126
+ "hooks": [
127
+ {
128
+ "type": "command",
129
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/mandatory-flow/index.mjs\"",
130
+ "timeout": 10
131
+ }
132
+ ]
133
+ },
134
+ {
135
+ "matcher": "Agent|Task|invoke_subagent",
136
+ "hooks": [
137
+ {
138
+ "type": "command",
139
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/test-matrix/index.mjs\"",
140
+ "timeout": 10
141
+ }
142
+ ]
143
+ },
144
+ {
145
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content",
146
+ "hooks": [
147
+ {
148
+ "type": "command",
149
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/dependency-skills/index.mjs\"",
150
+ "timeout": 10
151
+ }
152
+ ]
153
+ },
154
+ {
155
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent",
156
+ "hooks": [
157
+ {
158
+ "type": "command",
159
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/root-cause-first/index.mjs\"",
160
+ "timeout": 10
161
+ }
162
+ ]
163
+ },
164
+ {
165
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent",
166
+ "hooks": [
167
+ {
168
+ "type": "command",
169
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/audit-before-build/index.mjs\"",
170
+ "timeout": 10
171
+ }
172
+ ]
173
+ },
174
+ {
175
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent",
176
+ "hooks": [
177
+ {
178
+ "type": "command",
179
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/never-assume/index.mjs\"",
180
+ "timeout": 10
181
+ }
182
+ ]
183
+ },
184
+ {
185
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__ide__executeCode|Agent|Task|invoke_subagent",
186
+ "hooks": [
187
+ {
188
+ "type": "command",
189
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/rule-skill-autodiscovery/index.mjs\"",
190
+ "timeout": 10
191
+ }
192
+ ]
193
+ },
194
+ {
195
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__ide__executeCode|Agent|Task|invoke_subagent",
196
+ "hooks": [
197
+ {
198
+ "type": "command",
199
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/recurrence-lock/index.mjs\"",
200
+ "timeout": 10
201
+ }
202
+ ]
203
+ },
204
+ {
205
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content",
206
+ "hooks": [
207
+ {
208
+ "type": "command",
209
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/test-after-implementation/index.mjs\"",
210
+ "timeout": 10
211
+ }
212
+ ]
213
+ },
214
+ {
215
+ "matcher": "AskUserQuestion",
216
+ "hooks": [
217
+ {
218
+ "type": "command",
219
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-reconfirm/index.mjs\"",
220
+ "timeout": 10
221
+ }
222
+ ]
223
+ },
224
+ {
225
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content",
226
+ "hooks": [
227
+ {
228
+ "type": "command",
229
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/neutral-spanish/index.mjs\"",
230
+ "timeout": 10
231
+ }
232
+ ]
233
+ },
234
+ {
235
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content",
236
+ "hooks": [
237
+ {
238
+ "type": "command",
239
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/diagnosis-before-patch/index.mjs\"",
240
+ "timeout": 10
241
+ }
242
+ ]
243
+ },
244
+ {
245
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent",
246
+ "hooks": [
247
+ {
248
+ "type": "command",
249
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/reuse-before-build/index.mjs\"",
250
+ "timeout": 10
251
+ }
252
+ ]
253
+ },
254
+ {
255
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content",
256
+ "hooks": [
257
+ {
258
+ "type": "command",
259
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/tool-map/index.mjs\"",
260
+ "timeout": 10
261
+ }
262
+ ]
263
+ }
264
+ ]
265
+ }
266
+ }
@@ -0,0 +1,154 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { isGateEnabled, gateParameters } from '../config.mjs';
7
+
8
+ const GATE_KEY = 'blockDestructiveShellCommands';
9
+
10
+ function temporaryDirectory() {
11
+ return mkdtempSync(join(tmpdir(), 'claude-gates-config-'));
12
+ }
13
+
14
+ /** A project directory with a .git marker so config.mjs finds a project root. */
15
+ function projectDirectory() {
16
+ const root = temporaryDirectory();
17
+ mkdirSync(join(root, '.git'));
18
+ return root;
19
+ }
20
+
21
+ function writeProjectConfig(root, config) {
22
+ mkdirSync(join(root, '.ai'), { recursive: true });
23
+ writeFileSync(join(root, '.ai', 'config.json'), JSON.stringify(config));
24
+ }
25
+
26
+ function writeGlobalConfig(home, config) {
27
+ const directory = join(home, '.claude', 'claude-gates');
28
+ mkdirSync(directory, { recursive: true });
29
+ writeFileSync(join(directory, 'config.json'), JSON.stringify(config));
30
+ }
31
+
32
+ test('isGateEnabled falls back to the registry default when no config exists anywhere', () => {
33
+ const project = projectDirectory();
34
+ const home = temporaryDirectory();
35
+ assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
36
+ assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
37
+ });
38
+
39
+ test('isGateEnabled reads a bool shorthand entry from the project config', () => {
40
+ const project = projectDirectory();
41
+ const home = temporaryDirectory();
42
+ writeProjectConfig(project, { gates: { [GATE_KEY]: false } });
43
+ assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
44
+
45
+ const projectTrue = projectDirectory();
46
+ writeProjectConfig(projectTrue, { gates: { [GATE_KEY]: true } });
47
+ assert.equal(isGateEnabled(GATE_KEY, false, projectTrue, { home }), true);
48
+ });
49
+
50
+ test('isGateEnabled reads an object entry with an explicit enabled flag', () => {
51
+ const project = projectDirectory();
52
+ const home = temporaryDirectory();
53
+ writeProjectConfig(project, {
54
+ gates: { [GATE_KEY]: { enabled: false, someParam: 'x' } },
55
+ });
56
+ assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
57
+ });
58
+
59
+ test('isGateEnabled treats an object entry with no enabled field as silent (uses registry default)', () => {
60
+ const project = projectDirectory();
61
+ const home = temporaryDirectory();
62
+ writeProjectConfig(project, {
63
+ gates: { [GATE_KEY]: { someParam: 'x' } },
64
+ });
65
+ assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
66
+ assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
67
+ });
68
+
69
+ test('isGateEnabled falls back to the global config when the project declares nothing', () => {
70
+ const project = projectDirectory();
71
+ const home = temporaryDirectory();
72
+ writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
73
+ assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
74
+ });
75
+
76
+ test('isGateEnabled: an existing project config wins over the global config even if silent about this gate', () => {
77
+ const project = projectDirectory();
78
+ const home = temporaryDirectory();
79
+ writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
80
+ writeProjectConfig(project, { gates: {} });
81
+ // Project config exists (even without this gate declared): registry default wins,
82
+ // global's false must NOT leak through.
83
+ assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
84
+ });
85
+
86
+ test('isGateEnabled: corrupt project config is treated as absent and falls through to global', () => {
87
+ const project = projectDirectory();
88
+ const home = temporaryDirectory();
89
+ mkdirSync(join(project, '.ai'), { recursive: true });
90
+ writeFileSync(join(project, '.ai', 'config.json'), '{not valid json');
91
+ writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
92
+ assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
93
+ });
94
+
95
+ test('isGateEnabled: a malformed entry (string/number) is ignored, falls back to registry default', () => {
96
+ const project = projectDirectory();
97
+ const home = temporaryDirectory();
98
+ writeProjectConfig(project, { gates: { [GATE_KEY]: 'yes' } });
99
+ assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
100
+ assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
101
+
102
+ const projectNumber = projectDirectory();
103
+ writeProjectConfig(projectNumber, { gates: { [GATE_KEY]: 1 } });
104
+ assert.equal(isGateEnabled(GATE_KEY, false, projectNumber, { home }), false);
105
+ });
106
+
107
+ test('gateParameters returns {} when nothing is declared for the gate', () => {
108
+ const project = projectDirectory();
109
+ const home = temporaryDirectory();
110
+ writeProjectConfig(project, { gates: {} });
111
+ assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
112
+ });
113
+
114
+ test('gateParameters returns {} for a bool shorthand entry (no params, just enabled)', () => {
115
+ const project = projectDirectory();
116
+ const home = temporaryDirectory();
117
+ writeProjectConfig(project, { gates: { [GATE_KEY]: true } });
118
+ assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
119
+ });
120
+
121
+ test('gateParameters strips enabled and returns the remaining params from the project config', () => {
122
+ const project = projectDirectory();
123
+ const home = temporaryDirectory();
124
+ writeProjectConfig(project, {
125
+ gates: {
126
+ [GATE_KEY]: { enabled: true, protectedPaths: ['a', 'b'] },
127
+ },
128
+ });
129
+ assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {
130
+ protectedPaths: ['a', 'b'],
131
+ });
132
+ });
133
+
134
+ test('gateParameters: project params replace (do not merge with) global params', () => {
135
+ const project = projectDirectory();
136
+ const home = temporaryDirectory();
137
+ writeGlobalConfig(home, {
138
+ gates: {
139
+ [GATE_KEY]: { protectedPaths: ['global-a'], extra: 'g' },
140
+ },
141
+ });
142
+ writeProjectConfig(project, {
143
+ gates: { [GATE_KEY]: { protectedPaths: ['project-a'] } },
144
+ });
145
+ assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {
146
+ protectedPaths: ['project-a'],
147
+ });
148
+ });
149
+
150
+ test('gateParameters returns {} when no config exists anywhere', () => {
151
+ const project = projectDirectory();
152
+ const home = temporaryDirectory();
153
+ assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
154
+ });
@@ -0,0 +1,154 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { test } from 'node:test';
4
+ import { pathToFileURL } from 'node:url';
5
+ import {
6
+ toolNamesFor,
7
+ matcherFor,
8
+ toolNameOf,
9
+ sessionIdOf,
10
+ toolInputOf,
11
+ } from '../hook-io.mjs';
12
+
13
+ // deny/warn/allow/runGate call process.exit and cannot be invoked in-process without
14
+ // killing the test runner. They are exercised here as a child process instead; each
15
+ // gate's own tests additionally cover runGate's wiring through its real check function.
16
+
17
+ test('toolNamesFor expands a single group', () => {
18
+ assert.deepEqual(toolNamesFor(['question']), ['AskUserQuestion']);
19
+ });
20
+
21
+ test('toolNamesFor expands multiple groups and de-duplicates overlapping names', () => {
22
+ const names = toolNamesFor(['write', 'execution']);
23
+ // 'execution' repeats every 'write' tool plus Bash/run_command/mcp__ide__executeCode.
24
+ assert.equal(names.filter((name) => name === 'Write').length, 1);
25
+ assert.ok(names.includes('Write'));
26
+ assert.ok(names.includes('Bash'));
27
+ assert.ok(names.includes('mcp__ide__executeCode'));
28
+ });
29
+
30
+ test('toolNamesFor returns an empty list for an empty group set', () => {
31
+ assert.deepEqual(toolNamesFor([]), []);
32
+ });
33
+
34
+ test('toolNamesFor ignores an unknown group name', () => {
35
+ assert.deepEqual(toolNamesFor(['not-a-real-group']), []);
36
+ });
37
+
38
+ test('matcherFor joins tool names with a pipe', () => {
39
+ assert.equal(matcherFor(['question']), 'AskUserQuestion');
40
+ assert.equal(matcherFor(['shell']), 'Bash|run_command');
41
+ });
42
+
43
+ test('matcherFor returns an empty string for an empty group set', () => {
44
+ assert.equal(matcherFor([]), '');
45
+ });
46
+
47
+ test('toolNameOf reads tool_name, falls back to name, defaults to empty string', () => {
48
+ assert.equal(toolNameOf(JSON.stringify({ tool_name: 'Bash' })), 'Bash');
49
+ assert.equal(toolNameOf(JSON.stringify({ name: 'Edit' })), 'Edit');
50
+ assert.equal(
51
+ toolNameOf(JSON.stringify({ tool_name: 'Bash', name: 'Edit' })),
52
+ 'Bash',
53
+ );
54
+ assert.equal(toolNameOf(JSON.stringify({})), '');
55
+ });
56
+
57
+ test('toolNameOf returns null for unparseable payloads', () => {
58
+ assert.equal(toolNameOf('{not json'), null);
59
+ assert.equal(toolNameOf(undefined), null);
60
+ });
61
+
62
+ test('sessionIdOf reads session_id or returns null when absent/unparseable', () => {
63
+ assert.equal(sessionIdOf(JSON.stringify({ session_id: 'abc123' })), 'abc123');
64
+ assert.equal(sessionIdOf(JSON.stringify({})), null);
65
+ assert.equal(sessionIdOf('{not json'), null);
66
+ });
67
+
68
+ test('toolInputOf reads tool_input, falls back to input, defaults to {}', () => {
69
+ assert.deepEqual(
70
+ toolInputOf(JSON.stringify({ tool_input: { command: 'ls' } })),
71
+ { command: 'ls' },
72
+ );
73
+ assert.deepEqual(toolInputOf(JSON.stringify({ input: { file: 'a' } })), {
74
+ file: 'a',
75
+ });
76
+ assert.deepEqual(
77
+ toolInputOf(JSON.stringify({ tool_input: { a: 1 }, input: { b: 2 } })),
78
+ { a: 1 },
79
+ );
80
+ assert.deepEqual(toolInputOf(JSON.stringify({})), {});
81
+ });
82
+
83
+ test('toolInputOf returns {} for unparseable payloads', () => {
84
+ assert.deepEqual(toolInputOf('{not json'), {});
85
+ assert.deepEqual(toolInputOf(undefined), {});
86
+ });
87
+
88
+ const hookIoModuleUrl = pathToFileURL(
89
+ new URL('../hook-io.mjs', import.meta.url).pathname.replace(
90
+ /^\/([A-Za-z]:)/,
91
+ '$1',
92
+ ),
93
+ ).href;
94
+
95
+ /** Runs `body` (a callback expression referencing `mod`) in a child process after
96
+ * dynamically importing hook-io.mjs there, so process.exit-calling exports can be
97
+ * exercised without killing the test runner. */
98
+ function runInChildProcess(body, execOptions) {
99
+ const script = `import('${hookIoModuleUrl}').then((mod) => { ${body} });`;
100
+ return execFileSync(process.execPath, ['--input-type=module', '-e', script], {
101
+ encoding: 'utf8',
102
+ ...execOptions,
103
+ });
104
+ }
105
+
106
+ test('readHookPayload reads whatever is piped on stdin, including empty input', () => {
107
+ const output = runInChildProcess(
108
+ 'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
109
+ { input: '{"tool_name":"Bash"}' },
110
+ );
111
+ assert.equal(output, JSON.stringify('{"tool_name":"Bash"}'));
112
+
113
+ const emptyOutput = runInChildProcess(
114
+ 'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
115
+ { stdio: ['ignore', 'pipe', 'pipe'] },
116
+ );
117
+ // With no stdin available, readFileSync(0) resolves to an empty string rather than
118
+ // throwing on this platform, so readHookPayload returns '' — not null. The null
119
+ // path (an unreadable fd 0) is exercised indirectly by every gate's own tests via
120
+ // runGate, since forcing an EBADF/EAGAIN on fd 0 portably from node:test is not
121
+ // reliable across platforms.
122
+ assert.equal(emptyOutput, JSON.stringify(''));
123
+ });
124
+
125
+ test('deny writes a permissionDecision:deny hookSpecificOutput and exits 0', () => {
126
+ const output = runInChildProcess("mod.deny('my-gate', 'because reasons')", {
127
+ input: '',
128
+ });
129
+ const parsed = JSON.parse(output);
130
+ assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
131
+ assert.equal(parsed.hookSpecificOutput.permissionDecision, 'deny');
132
+ assert.equal(
133
+ parsed.hookSpecificOutput.permissionDecisionReason,
134
+ '[my-gate] because reasons',
135
+ );
136
+ });
137
+
138
+ test('warn writes additionalContext and exits 0', () => {
139
+ const output = runInChildProcess("mod.warn('my-gate', 'consider this')", {
140
+ input: '',
141
+ });
142
+ const parsed = JSON.parse(output);
143
+ assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
144
+ assert.equal(
145
+ parsed.hookSpecificOutput.additionalContext,
146
+ '[my-gate] consider this',
147
+ );
148
+ assert.equal(parsed.hookSpecificOutput.permissionDecision, undefined);
149
+ });
150
+
151
+ test('allow exits 0 with no stdout', () => {
152
+ const output = runInChildProcess('mod.allow()', { input: '' });
153
+ assert.equal(output, '');
154
+ });