@devrik-tools/claude-gates 0.3.1 → 0.6.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 +2 -2
- package/README.es.md +25 -4
- package/README.md +75 -43
- package/cli/config.mjs +126 -124
- package/cli/index.mjs +154 -104
- package/cli/init.mjs +303 -276
- package/cli/install.mjs +281 -175
- package/cli/materialize.mjs +103 -102
- package/cli/registry.mjs +139 -136
- package/cli/smoke-fixtures.json +441 -0
- package/cli/smoke.mjs +129 -0
- package/cli/task.mjs +140 -140
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/ask-adoption.mjs +147 -147
- package/plugins/gates/hooks/doctor.mjs +207 -207
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
- package/plugins/gates/hooks/gates/capability-map/index.mjs +377 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
- package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
- package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
- package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
- package/plugins/gates/hooks/hooks.json +51 -0
- package/plugins/gates/hooks/lib/config.mjs +179 -172
- package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
- package/plugins/gates/hooks/lib/signals.mjs +172 -127
- package/plugins/gates/hooks/wiring-check.mjs +227 -227
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/hooks.json +26 -26
- package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
- package/plugins/tasks/hooks/register-requests.mjs +145 -145
- package/plugins/tasks/hooks/session-tasks.mjs +108 -108
- package/registry.json +171 -1
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "One known-violation payload per gate, derived from each gate's own test.mjs, for the `smoke` command. The runner (cli/smoke.mjs) feeds each payload to the gate and confirms it reacts (deny/warn) as declared. `needsState: true` marks a gate whose violation cannot be reproduced with a single stdin payload alone (it reads a seeded file, a sqlite db, or accumulates state across calls) — the runner SKIPS those honestly instead of miscounting them as a defect. `type` is deny/warn/none. When enabledByDefault is false the runner enables the gate via config before running it. This file is test-fixture data (it embeds regional-Spanish and destructive-command strings on purpose): neutral-spanish:allow",
|
|
3
|
+
"fixtures": [
|
|
4
|
+
{
|
|
5
|
+
"id": "bash-commands",
|
|
6
|
+
"configKey": "blockDestructiveShellCommands",
|
|
7
|
+
"enabledByDefault": true,
|
|
8
|
+
"type": "deny",
|
|
9
|
+
"payload": {
|
|
10
|
+
"tool_name": "Bash",
|
|
11
|
+
"tool_input": { "command": "git reset --hard HEAD~3" }
|
|
12
|
+
},
|
|
13
|
+
"needsState": false
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "block-remote-publish",
|
|
17
|
+
"configKey": "blockRemotePublish",
|
|
18
|
+
"enabledByDefault": true,
|
|
19
|
+
"type": "deny",
|
|
20
|
+
"payload": {
|
|
21
|
+
"tool_name": "Bash",
|
|
22
|
+
"tool_input": { "command": "git push origin main" }
|
|
23
|
+
},
|
|
24
|
+
"needsState": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "protected-paths",
|
|
28
|
+
"configKey": "blockWritesToProtectedPaths",
|
|
29
|
+
"enabledByDefault": true,
|
|
30
|
+
"type": "deny",
|
|
31
|
+
"payload": {
|
|
32
|
+
"tool_name": "Write",
|
|
33
|
+
"tool_input": { "file_path": "/repo/.env" }
|
|
34
|
+
},
|
|
35
|
+
"needsState": false
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "root-whitelist",
|
|
39
|
+
"configKey": "blockPathsOutsideRootWhitelist",
|
|
40
|
+
"enabledByDefault": true,
|
|
41
|
+
"type": "deny",
|
|
42
|
+
"payload": {
|
|
43
|
+
"tool_name": "Write",
|
|
44
|
+
"tool_input": { "file_path": "random-orphan.txt" }
|
|
45
|
+
},
|
|
46
|
+
"needsState": true,
|
|
47
|
+
"note": "file_path must resolve against the scratch project root; a literal alone does not reproduce root detection."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "no-blocking",
|
|
51
|
+
"configKey": "blockWaitingCommands",
|
|
52
|
+
"enabledByDefault": false,
|
|
53
|
+
"type": "deny",
|
|
54
|
+
"payload": {
|
|
55
|
+
"tool_name": "Bash",
|
|
56
|
+
"tool_input": { "command": "sleep 30" }
|
|
57
|
+
},
|
|
58
|
+
"needsState": false
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "brief-before-delegate",
|
|
62
|
+
"configKey": "requireBriefBeforeDelegating",
|
|
63
|
+
"enabledByDefault": false,
|
|
64
|
+
"type": "deny",
|
|
65
|
+
"payload": {
|
|
66
|
+
"tool_name": "Agent",
|
|
67
|
+
"tool_input": { "prompt": "Fix the login bug please, thanks." }
|
|
68
|
+
},
|
|
69
|
+
"needsState": false
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "intent-flow",
|
|
73
|
+
"configKey": "requireScopeListBeforeDelegating",
|
|
74
|
+
"enabledByDefault": false,
|
|
75
|
+
"type": "deny",
|
|
76
|
+
"payload": {
|
|
77
|
+
"tool_name": "Agent",
|
|
78
|
+
"tool_input": {
|
|
79
|
+
"prompt": "Implementa el cobro del pago con la nueva pasarela de dinero para el checkout."
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"needsState": false
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "risk-level",
|
|
86
|
+
"configKey": "requireDeclaredRiskLevel",
|
|
87
|
+
"enabledByDefault": false,
|
|
88
|
+
"type": "deny",
|
|
89
|
+
"payload": {
|
|
90
|
+
"tool_name": "Agent",
|
|
91
|
+
"tool_input": {
|
|
92
|
+
"prompt": "Arregla el boton que no cambia de color en la pagina."
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"needsState": false
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"id": "circuit-breaker",
|
|
99
|
+
"configKey": "requireCircuitBreakerOnDelegation",
|
|
100
|
+
"enabledByDefault": false,
|
|
101
|
+
"type": "deny",
|
|
102
|
+
"payload": {
|
|
103
|
+
"tool_name": "Agent",
|
|
104
|
+
"tool_input": { "prompt": "fix the scoring threshold" }
|
|
105
|
+
},
|
|
106
|
+
"needsState": true,
|
|
107
|
+
"note": "stateful: denies only on the 3rd identical retry under the same session id."
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "no-memory-dependency",
|
|
111
|
+
"configKey": "warnMemoryDependencyInBrief",
|
|
112
|
+
"enabledByDefault": false,
|
|
113
|
+
"type": "deny",
|
|
114
|
+
"payload": {
|
|
115
|
+
"tool_name": "Agent",
|
|
116
|
+
"tool_input": {
|
|
117
|
+
"prompt": "Acordate de lo que hablamos antes y aplica el mismo criterio."
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"needsState": false
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "force-parallel",
|
|
124
|
+
"configKey": "warnSequentialDelegations",
|
|
125
|
+
"enabledByDefault": false,
|
|
126
|
+
"type": "warn",
|
|
127
|
+
"payload": { "tool_name": "Task", "tool_input": { "prompt": "do work" } },
|
|
128
|
+
"needsState": true,
|
|
129
|
+
"note": "stateful: warns only on the 3rd consecutive delegation under one session."
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"id": "feature-catalog",
|
|
133
|
+
"configKey": "requireFeatureCatalog",
|
|
134
|
+
"enabledByDefault": true,
|
|
135
|
+
"type": "deny",
|
|
136
|
+
"payload": {
|
|
137
|
+
"tool_name": "Write",
|
|
138
|
+
"tool_input": {
|
|
139
|
+
"file_path": "feature_list.json",
|
|
140
|
+
"content": "{\"features\":[{\"name\":\"a\",\"status\":\"in_progress\"},{\"name\":\"b\",\"status\":\"in_progress\"}]}"
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"needsState": true,
|
|
144
|
+
"note": "denies >maxInProgress; needs the catalog shape and threshold to line up."
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"id": "sdd-specs",
|
|
148
|
+
"configKey": "requireSpecBeforeImplementing",
|
|
149
|
+
"enabledByDefault": false,
|
|
150
|
+
"type": "deny",
|
|
151
|
+
"payload": {
|
|
152
|
+
"tool_name": "Agent",
|
|
153
|
+
"tool_input": {
|
|
154
|
+
"prompt": "Nivel: STANDARD\nImplementa el flujo de pago."
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"needsState": true,
|
|
158
|
+
"note": "auto-off unless a features tree exists; needs seeded .ai/feature_list.json + features dir."
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"id": "implementation-pipeline",
|
|
162
|
+
"configKey": "requireImplementationPipeline",
|
|
163
|
+
"enabledByDefault": false,
|
|
164
|
+
"type": "deny",
|
|
165
|
+
"payload": {
|
|
166
|
+
"tool_name": "Agent",
|
|
167
|
+
"tool_input": { "prompt": "Nivel: STANDARD\nImplementa el checkout." }
|
|
168
|
+
},
|
|
169
|
+
"needsState": false
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"id": "mandatory-flow",
|
|
173
|
+
"configKey": "requireLiveTaskWhenImplementing",
|
|
174
|
+
"enabledByDefault": false,
|
|
175
|
+
"type": "deny",
|
|
176
|
+
"payload": {
|
|
177
|
+
"tool_name": "Agent",
|
|
178
|
+
"tool_input": { "prompt": "Nivel: STANDARD\nImplementa el checkout." }
|
|
179
|
+
},
|
|
180
|
+
"needsState": false
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"id": "test-matrix",
|
|
184
|
+
"configKey": "requireTestMatrixWhenImplementing",
|
|
185
|
+
"enabledByDefault": false,
|
|
186
|
+
"type": "deny",
|
|
187
|
+
"payload": {
|
|
188
|
+
"tool_name": "Agent",
|
|
189
|
+
"tool_input": {
|
|
190
|
+
"prompt": "Nivel: STANDARD\nImplementa el checkout.",
|
|
191
|
+
"subagent_type": "backend"
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
"needsState": false
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"id": "dependency-skills",
|
|
198
|
+
"configKey": "requireSkillForNewDependency",
|
|
199
|
+
"enabledByDefault": true,
|
|
200
|
+
"type": "deny",
|
|
201
|
+
"payload": {
|
|
202
|
+
"tool_name": "Write",
|
|
203
|
+
"tool_input": {
|
|
204
|
+
"file_path": "/repo/package.json",
|
|
205
|
+
"content": "{\"dependencies\":{\"stripe\":\"1.0.0\"}}"
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
"needsState": false
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"id": "root-cause-first",
|
|
212
|
+
"configKey": "requireRootCauseBeforePatch",
|
|
213
|
+
"enabledByDefault": false,
|
|
214
|
+
"type": "deny",
|
|
215
|
+
"payload": {
|
|
216
|
+
"tool_name": "Write",
|
|
217
|
+
"tool_input": {
|
|
218
|
+
"file_path": "/repo/src/x.js",
|
|
219
|
+
"content": "// TODO fix later patch"
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
"needsState": false
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"id": "audit-before-build",
|
|
226
|
+
"configKey": "requireAuditBeforeBuilding",
|
|
227
|
+
"enabledByDefault": false,
|
|
228
|
+
"type": "deny",
|
|
229
|
+
"payload": {
|
|
230
|
+
"tool_name": "Agent",
|
|
231
|
+
"tool_input": { "prompt": "create a new script that checks X" }
|
|
232
|
+
},
|
|
233
|
+
"needsState": false
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"id": "never-assume",
|
|
237
|
+
"configKey": "requireVerificationBeforeAssuming",
|
|
238
|
+
"enabledByDefault": false,
|
|
239
|
+
"type": "warn",
|
|
240
|
+
"payload": {
|
|
241
|
+
"tool_name": "Write",
|
|
242
|
+
"tool_input": {
|
|
243
|
+
"file_path": "/repo/src/x.js",
|
|
244
|
+
"content": "// i assume the timezone is UTC"
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
"needsState": false
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
"id": "rule-skill-autodiscovery",
|
|
251
|
+
"configKey": "autodiscoverRulesAndSkills",
|
|
252
|
+
"enabledByDefault": false,
|
|
253
|
+
"type": "deny",
|
|
254
|
+
"payload": {
|
|
255
|
+
"tool_name": "Bash",
|
|
256
|
+
"tool_input": { "command": "echo hi" }
|
|
257
|
+
},
|
|
258
|
+
"needsState": true,
|
|
259
|
+
"note": "needs a project rules/ or .claude/skills gate script present that exits non-zero."
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"id": "recurrence-lock",
|
|
263
|
+
"configKey": "blockRegisteredRecurrences",
|
|
264
|
+
"enabledByDefault": true,
|
|
265
|
+
"type": "deny",
|
|
266
|
+
"payload": {
|
|
267
|
+
"tool_name": "Bash",
|
|
268
|
+
"tool_input": { "command": "echo hi" }
|
|
269
|
+
},
|
|
270
|
+
"needsState": true,
|
|
271
|
+
"note": "needs .ai/reincidencias.json with an open class at/above threshold."
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"id": "test-after-implementation",
|
|
275
|
+
"configKey": "warnTestWrittenAfterImplementation",
|
|
276
|
+
"enabledByDefault": false,
|
|
277
|
+
"type": "deny",
|
|
278
|
+
"payload": {
|
|
279
|
+
"tool_name": "Write",
|
|
280
|
+
"tool_input": {
|
|
281
|
+
"file_path": "thing.test.js",
|
|
282
|
+
"content": "test content"
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
"needsState": true,
|
|
286
|
+
"note": "needs a git repo with an uncommitted paired implementation file."
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"id": "no-reconfirm",
|
|
290
|
+
"configKey": "requireNoReconfirmOfApproved",
|
|
291
|
+
"enabledByDefault": true,
|
|
292
|
+
"type": "warn",
|
|
293
|
+
"payload": {
|
|
294
|
+
"tool_name": "AskUserQuestion",
|
|
295
|
+
"tool_input": {
|
|
296
|
+
"questions": [{ "question": "Should I migrate the auth schema?" }]
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
"needsState": true,
|
|
300
|
+
"note": "needs a transcript file with a prior approval of the same topic."
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"id": "neutral-spanish",
|
|
304
|
+
"configKey": "warnNonNeutralSpanish",
|
|
305
|
+
"enabledByDefault": true,
|
|
306
|
+
"type": "deny",
|
|
307
|
+
"payload": {
|
|
308
|
+
"tool_name": "Write",
|
|
309
|
+
"tool_input": {
|
|
310
|
+
"file_path": "/repo/notes.md",
|
|
311
|
+
"content": "che, mira el laburo que hicimos"
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
"needsState": false,
|
|
315
|
+
"note": "neutral-spanish:allow (fixture data uses regional markers on purpose)"
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"id": "diagnosis-before-patch",
|
|
319
|
+
"configKey": "warnTimeoutChangeWithoutDiagnosis",
|
|
320
|
+
"enabledByDefault": true,
|
|
321
|
+
"type": "warn",
|
|
322
|
+
"payload": {
|
|
323
|
+
"tool_name": "Write",
|
|
324
|
+
"tool_input": {
|
|
325
|
+
"file_path": "/repo/src/config.js",
|
|
326
|
+
"content": "const REQUEST_TIMEOUT_MS = 30000;"
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
"needsState": false
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
"id": "lint-commit",
|
|
333
|
+
"configKey": "blockCommitWithFailingLint",
|
|
334
|
+
"enabledByDefault": false,
|
|
335
|
+
"type": "deny",
|
|
336
|
+
"payload": {
|
|
337
|
+
"tool_name": "Bash",
|
|
338
|
+
"tool_input": { "command": "git commit -m x" }
|
|
339
|
+
},
|
|
340
|
+
"needsState": true,
|
|
341
|
+
"note": "needs a project package.json whose lint script exits non-zero."
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
"id": "reuse-before-build",
|
|
345
|
+
"configKey": "requireReuseCheckBeforeBuilding",
|
|
346
|
+
"enabledByDefault": false,
|
|
347
|
+
"type": "deny",
|
|
348
|
+
"payload": {
|
|
349
|
+
"tool_name": "Write",
|
|
350
|
+
"tool_input": {
|
|
351
|
+
"file_path": "scripts/csv-parser.mjs",
|
|
352
|
+
"content": "export function parse() {}"
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
"needsState": false
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
"id": "tool-map",
|
|
359
|
+
"configKey": "maintainToolMap",
|
|
360
|
+
"enabledByDefault": false,
|
|
361
|
+
"type": "none",
|
|
362
|
+
"payload": null,
|
|
363
|
+
"needsState": true,
|
|
364
|
+
"note": "side-effect only: records to .ai/tool-map.json, never denies/warns."
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
"id": "forge-flow",
|
|
368
|
+
"configKey": "requireForgeRunToEdit",
|
|
369
|
+
"enabledByDefault": true,
|
|
370
|
+
"type": "deny",
|
|
371
|
+
"payload": {
|
|
372
|
+
"tool_name": "Write",
|
|
373
|
+
"tool_input": { "file_path": "src/x.js", "content": "x" }
|
|
374
|
+
},
|
|
375
|
+
"needsState": true,
|
|
376
|
+
"note": "needs a forge adoption marker + sqlite db with no active run."
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
"id": "autonomous-mode",
|
|
380
|
+
"configKey": "autonomousMode",
|
|
381
|
+
"enabledByDefault": false,
|
|
382
|
+
"type": "deny",
|
|
383
|
+
"payload": {
|
|
384
|
+
"tool_name": "AskUserQuestion",
|
|
385
|
+
"tool_input": { "questions": [{ "question": "A or B?" }] }
|
|
386
|
+
},
|
|
387
|
+
"needsState": false
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
"id": "no-coauthor",
|
|
391
|
+
"configKey": "blockCoauthorTrailers",
|
|
392
|
+
"enabledByDefault": true,
|
|
393
|
+
"type": "deny",
|
|
394
|
+
"payload": {
|
|
395
|
+
"tool_name": "Bash",
|
|
396
|
+
"tool_input": {
|
|
397
|
+
"command": "git commit -m \"fix\n\nCo-Authored-By: Claude <noreply@anthropic.com>\""
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
"needsState": false
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
"id": "no-lint-suppression",
|
|
404
|
+
"configKey": "blockLintSuppression",
|
|
405
|
+
"enabledByDefault": true,
|
|
406
|
+
"type": "deny",
|
|
407
|
+
"payload": {
|
|
408
|
+
"tool_name": "Write",
|
|
409
|
+
"tool_input": {
|
|
410
|
+
"file_path": "/repo/a.js",
|
|
411
|
+
"content": "const x = 1; // eslint-disable-next-line no-unused-vars"
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
"needsState": false
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
"id": "staged-lint",
|
|
418
|
+
"configKey": "blockCommitWithStagedLintErrors",
|
|
419
|
+
"enabledByDefault": false,
|
|
420
|
+
"type": "deny",
|
|
421
|
+
"payload": {
|
|
422
|
+
"tool_name": "Bash",
|
|
423
|
+
"tool_input": { "command": "git commit -m \"wip\"" }
|
|
424
|
+
},
|
|
425
|
+
"needsState": true,
|
|
426
|
+
"note": "needs a git repo with a staged lintable file that fails lint and a resolvable linter; a bare payload cannot reproduce the staged-file scan."
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"id": "atomic-commit",
|
|
430
|
+
"configKey": "blockNonAtomicCommits",
|
|
431
|
+
"enabledByDefault": false,
|
|
432
|
+
"type": "deny",
|
|
433
|
+
"payload": {
|
|
434
|
+
"tool_name": "Bash",
|
|
435
|
+
"tool_input": { "command": "git commit -m \"stuff\"" }
|
|
436
|
+
},
|
|
437
|
+
"needsState": true,
|
|
438
|
+
"note": "needs a git repo with a staged set that mixes >maxNatures natures or exceeds maxFiles; a bare payload cannot reproduce the staged-file scan."
|
|
439
|
+
}
|
|
440
|
+
]
|
|
441
|
+
}
|
package/cli/smoke.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Gate smoke test: for each gate, feed it a KNOWN violation and confirm it actually reacts
|
|
2
|
+
// (denies / warns), not just that its file exists on disk (that is doctor.mjs's job). This
|
|
3
|
+
// is the check that would have caught neutral-spanish silently warning when the user expected
|
|
4
|
+
// a block, and stop-pending never firing on a fresh install.
|
|
5
|
+
//
|
|
6
|
+
// A gate is run as its own process, exactly as Claude Code runs it: the violation payload on
|
|
7
|
+
// stdin, in a scratch project so its config lookup is isolated. The gate declares in the
|
|
8
|
+
// fixture manifest whether it is expected to DENY or WARN, and whether it needs its config key
|
|
9
|
+
// enabled (gates that are off by default) — the runner writes that config into the scratch
|
|
10
|
+
// project before invoking.
|
|
11
|
+
//
|
|
12
|
+
// Pure of I/O policy: this module runs gates and classifies; the CLI command (index.mjs)
|
|
13
|
+
// owns printing. `runSmoke` returns structured results so it is testable without a terminal.
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const GATES_DIR = join(HERE, '..', 'plugins', 'gates', 'hooks', 'gates');
|
|
23
|
+
|
|
24
|
+
// Max characters of a gate's error message to keep in the report — enough to identify the
|
|
25
|
+
// failure, short enough to keep the per-gate line readable.
|
|
26
|
+
const ERROR_MESSAGE_MAX_LENGTH = 120;
|
|
27
|
+
// A gate that hangs is a defect too; cap how long the smoke test waits for one to respond.
|
|
28
|
+
const GATE_RUN_TIMEOUT_MS = 10000;
|
|
29
|
+
|
|
30
|
+
// Outcomes the runner reports per gate.
|
|
31
|
+
export const OUTCOMES = Object.freeze({
|
|
32
|
+
REACTED: 'reacted', // gate produced the expected deny/warn — the protection works
|
|
33
|
+
NO_REACTION: 'no-reaction', // gate ran but did not react to its own known violation — DEFECT
|
|
34
|
+
SKIPPED: 'skipped', // fixture needs seeded state we do not set up, or has no violation case
|
|
35
|
+
ERROR: 'error', // gate threw / could not be run
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** A scratch project (git marker) with an optional gate config, so the run is isolated. */
|
|
39
|
+
function makeScratchProject(configKey, enable) {
|
|
40
|
+
const project = mkdtempSync(join(tmpdir(), 'gate-smoke-'));
|
|
41
|
+
mkdirSync(join(project, '.git'));
|
|
42
|
+
if (enable && configKey) {
|
|
43
|
+
mkdirSync(join(project, '.ai'));
|
|
44
|
+
writeFileSync(
|
|
45
|
+
join(project, '.ai', 'config.json'),
|
|
46
|
+
JSON.stringify({ gates: { [configKey]: { enabled: true } } }),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return project;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reads a gate's decision from its stdout. null when it allowed (no output). */
|
|
53
|
+
function decisionOf(stdout) {
|
|
54
|
+
const trimmed = stdout.trim();
|
|
55
|
+
if (!trimmed) return null;
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(trimmed);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (parsed?.hookSpecificOutput?.permissionDecision === 'deny') return 'deny';
|
|
63
|
+
if (parsed?.decision === 'block') return 'deny'; // Stop-hook shape
|
|
64
|
+
if (typeof parsed?.hookSpecificOutput?.additionalContext === 'string')
|
|
65
|
+
return 'warn';
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Runs one gate against its fixture and classifies the outcome. */
|
|
70
|
+
export function runGateFixture(fixture, { gatesDirectory = GATES_DIR } = {}) {
|
|
71
|
+
const { id, configKey, enabledByDefault, type, payload, needsState } =
|
|
72
|
+
fixture;
|
|
73
|
+
|
|
74
|
+
if (needsState || type === 'none' || payload == null) {
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
outcome: OUTCOMES.SKIPPED,
|
|
78
|
+
expected: type,
|
|
79
|
+
got: null,
|
|
80
|
+
reason: needsState
|
|
81
|
+
? 'needs seeded state (db/file) not set up by the smoke test'
|
|
82
|
+
: 'gate has no violation case to plant (side-effect or judgment-only)',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const scriptPath = join(gatesDirectory, id, 'index.mjs');
|
|
87
|
+
const project = makeScratchProject(configKey, !enabledByDefault);
|
|
88
|
+
|
|
89
|
+
let stdout;
|
|
90
|
+
try {
|
|
91
|
+
stdout = execFileSync(process.execPath, [scriptPath], {
|
|
92
|
+
input: JSON.stringify(payload),
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
cwd: project,
|
|
95
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
96
|
+
timeout: GATE_RUN_TIMEOUT_MS,
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return {
|
|
100
|
+
id,
|
|
101
|
+
outcome: OUTCOMES.ERROR,
|
|
102
|
+
expected: type,
|
|
103
|
+
got: null,
|
|
104
|
+
reason: String(error?.message ?? error).slice(
|
|
105
|
+
0,
|
|
106
|
+
ERROR_MESSAGE_MAX_LENGTH,
|
|
107
|
+
),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const got = decisionOf(stdout);
|
|
112
|
+
// A gate expected to deny must deny; a gate expected to warn must warn. Anything else
|
|
113
|
+
// (allowed silently, or wrong reaction kind) is a no-reaction defect worth surfacing.
|
|
114
|
+
const reacted = got === type;
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
outcome: reacted ? OUTCOMES.REACTED : OUTCOMES.NO_REACTION,
|
|
118
|
+
expected: type,
|
|
119
|
+
got: got ?? 'allow',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Runs the whole manifest and returns per-gate results plus a summary tally. */
|
|
124
|
+
export function runSmoke(manifest, options = {}) {
|
|
125
|
+
const results = manifest.map((fixture) => runGateFixture(fixture, options));
|
|
126
|
+
const tally = { reacted: 0, 'no-reaction': 0, skipped: 0, error: 0 };
|
|
127
|
+
for (const result of results) tally[result.outcome] += 1;
|
|
128
|
+
return { results, tally };
|
|
129
|
+
}
|