@iinm/plain-agent 1.10.26 → 1.10.27
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/README.md +28 -0
- package/config/config.predefined.json +282 -0
- package/package.json +3 -2
- package/src/cli/args.mjs +25 -1
- package/src/cli/testApproval.mjs +162 -0
- package/src/config.d.ts +7 -0
- package/src/config.mjs +19 -1
- package/src/main.mjs +10 -0
package/README.md
CHANGED
|
@@ -54,6 +54,25 @@ Configure what the agent can do automatically using a small DSL with regex match
|
|
|
54
54
|
"action": "deny",
|
|
55
55
|
"reason": "--method must be specified right after 'api'"
|
|
56
56
|
}
|
|
57
|
+
],
|
|
58
|
+
|
|
59
|
+
// Test cases for verifying patterns. Run: plain test-approval
|
|
60
|
+
"tests": [
|
|
61
|
+
{
|
|
62
|
+
"desc": "fd with safe args should be allowed",
|
|
63
|
+
"toolUse": { "toolName": "exec_command", "input": { "command": "fd", "args": ["README", "./"] } },
|
|
64
|
+
"expectedAction": "allow"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"desc": "fd with -I should require approval",
|
|
68
|
+
"toolUse": { "toolName": "exec_command", "input": { "command": "fd", "args": ["-I", ".env", "./"] } },
|
|
69
|
+
"expectedAction": "ask"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"desc": "gh api without --method should be denied",
|
|
73
|
+
"toolUse": { "toolName": "exec_command", "input": { "command": "gh", "args": ["api", "/repos/owner/repo/pulls"] } },
|
|
74
|
+
"expectedAction": "deny"
|
|
75
|
+
}
|
|
57
76
|
]
|
|
58
77
|
}
|
|
59
78
|
}
|
|
@@ -557,6 +576,15 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
557
576
|
"toolName": { "$regex": "mcp__slack__slack_(read|search)_.+" },
|
|
558
577
|
"action": "allow"
|
|
559
578
|
}
|
|
579
|
+
],
|
|
580
|
+
|
|
581
|
+
// Test cases for verifying patterns. Run: plain test-approval
|
|
582
|
+
"tests": [
|
|
583
|
+
{
|
|
584
|
+
"desc": "npm test should be allowed",
|
|
585
|
+
"toolUse": { "toolName": "exec_command", "input": { "command": "npm", "args": ["run", "test"] } },
|
|
586
|
+
"expectedAction": "allow"
|
|
587
|
+
}
|
|
560
588
|
]
|
|
561
589
|
},
|
|
562
590
|
|
|
@@ -144,6 +144,288 @@
|
|
|
144
144
|
"action": "deny",
|
|
145
145
|
"reason": "--method must be specified right after 'api'"
|
|
146
146
|
}
|
|
147
|
+
],
|
|
148
|
+
"tests": [
|
|
149
|
+
{
|
|
150
|
+
"desc": "ls should be allowed",
|
|
151
|
+
"toolUse": { "toolName": "exec_command", "input": { "command": "ls" } },
|
|
152
|
+
"expectedAction": "allow"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"desc": "rm should not match any pattern",
|
|
156
|
+
"toolUse": { "toolName": "exec_command", "input": { "command": "rm" } },
|
|
157
|
+
"expectedAction": null
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"desc": "fd with safe args should be allowed",
|
|
161
|
+
"toolUse": {
|
|
162
|
+
"toolName": "exec_command",
|
|
163
|
+
"input": { "command": "fd", "args": ["--max-depth", "3"] }
|
|
164
|
+
},
|
|
165
|
+
"expectedAction": "allow"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"desc": "fd with -H only should be allowed",
|
|
169
|
+
"toolUse": {
|
|
170
|
+
"toolName": "exec_command",
|
|
171
|
+
"input": { "command": "fd", "args": ["-H", "pattern"] }
|
|
172
|
+
},
|
|
173
|
+
"expectedAction": "allow"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"desc": "fd with unsafe args should require approval",
|
|
177
|
+
"toolUse": {
|
|
178
|
+
"toolName": "exec_command",
|
|
179
|
+
"input": { "command": "fd", "args": ["--unrestricted"] }
|
|
180
|
+
},
|
|
181
|
+
"expectedAction": "ask"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"desc": "fd with -I option should require approval",
|
|
185
|
+
"toolUse": {
|
|
186
|
+
"toolName": "exec_command",
|
|
187
|
+
"input": { "command": "fd", "args": ["-I", "pattern"] }
|
|
188
|
+
},
|
|
189
|
+
"expectedAction": "ask"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"desc": "fd with -HI combined options should require approval",
|
|
193
|
+
"toolUse": {
|
|
194
|
+
"toolName": "exec_command",
|
|
195
|
+
"input": { "command": "fd", "args": ["-HI", "pattern"] }
|
|
196
|
+
},
|
|
197
|
+
"expectedAction": "ask"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"desc": "fd with -IH combined options should require approval",
|
|
201
|
+
"toolUse": {
|
|
202
|
+
"toolName": "exec_command",
|
|
203
|
+
"input": { "command": "fd", "args": ["-IH", "pattern"] }
|
|
204
|
+
},
|
|
205
|
+
"expectedAction": "ask"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"desc": "fd with -Hx=command combined options with value should require approval",
|
|
209
|
+
"toolUse": {
|
|
210
|
+
"toolName": "exec_command",
|
|
211
|
+
"input": { "command": "fd", "args": ["-Hx=cat", "pattern"] }
|
|
212
|
+
},
|
|
213
|
+
"expectedAction": "ask"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"desc": "rg with safe args should be allowed",
|
|
217
|
+
"toolUse": {
|
|
218
|
+
"toolName": "exec_command",
|
|
219
|
+
"input": { "command": "rg", "args": ["--ignore-case", "pattern"] }
|
|
220
|
+
},
|
|
221
|
+
"expectedAction": "allow"
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"desc": "rg with -H only should be allowed",
|
|
225
|
+
"toolUse": {
|
|
226
|
+
"toolName": "exec_command",
|
|
227
|
+
"input": { "command": "rg", "args": ["-H", "pattern"] }
|
|
228
|
+
},
|
|
229
|
+
"expectedAction": "allow"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"desc": "rg with unsafe args should require approval",
|
|
233
|
+
"toolUse": {
|
|
234
|
+
"toolName": "exec_command",
|
|
235
|
+
"input": { "command": "rg", "args": ["--unrestricted"] }
|
|
236
|
+
},
|
|
237
|
+
"expectedAction": "ask"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"desc": "rg with -u option should require approval",
|
|
241
|
+
"toolUse": {
|
|
242
|
+
"toolName": "exec_command",
|
|
243
|
+
"input": { "command": "rg", "args": ["-u", "pattern"] }
|
|
244
|
+
},
|
|
245
|
+
"expectedAction": "ask"
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"desc": "rg with -Hu combined options should require approval",
|
|
249
|
+
"toolUse": {
|
|
250
|
+
"toolName": "exec_command",
|
|
251
|
+
"input": { "command": "rg", "args": ["-Hu", "pattern"] }
|
|
252
|
+
},
|
|
253
|
+
"expectedAction": "ask"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"desc": "rg with -uH combined options should require approval",
|
|
257
|
+
"toolUse": {
|
|
258
|
+
"toolName": "exec_command",
|
|
259
|
+
"input": { "command": "rg", "args": ["-uH", "pattern"] }
|
|
260
|
+
},
|
|
261
|
+
"expectedAction": "ask"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"desc": "sed with known pattern should be allowed",
|
|
265
|
+
"toolUse": {
|
|
266
|
+
"toolName": "exec_command",
|
|
267
|
+
"input": { "command": "sed", "args": ["-n", "10,20p", "file.txt"] }
|
|
268
|
+
},
|
|
269
|
+
"expectedAction": "allow"
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"desc": "sed with single line pattern should be allowed",
|
|
273
|
+
"toolUse": {
|
|
274
|
+
"toolName": "exec_command",
|
|
275
|
+
"input": { "command": "sed", "args": ["-n", "42p", "file.txt"] }
|
|
276
|
+
},
|
|
277
|
+
"expectedAction": "allow"
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"desc": "git status should be allowed",
|
|
281
|
+
"toolUse": {
|
|
282
|
+
"toolName": "exec_command",
|
|
283
|
+
"input": { "command": "git", "args": ["status"] }
|
|
284
|
+
},
|
|
285
|
+
"expectedAction": "allow"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"desc": "git branch --show-current should be allowed",
|
|
289
|
+
"toolUse": {
|
|
290
|
+
"toolName": "exec_command",
|
|
291
|
+
"input": { "command": "git", "args": ["branch", "--show-current"] }
|
|
292
|
+
},
|
|
293
|
+
"expectedAction": "allow"
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"desc": "git commit should not match any pattern",
|
|
297
|
+
"toolUse": {
|
|
298
|
+
"toolName": "exec_command",
|
|
299
|
+
"input": { "command": "git", "args": ["commit"] }
|
|
300
|
+
},
|
|
301
|
+
"expectedAction": null
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"desc": "docker ps should be allowed",
|
|
305
|
+
"toolUse": {
|
|
306
|
+
"toolName": "exec_command",
|
|
307
|
+
"input": { "command": "docker", "args": ["ps"] }
|
|
308
|
+
},
|
|
309
|
+
"expectedAction": "allow"
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
"desc": "docker compose ps should be allowed",
|
|
313
|
+
"toolUse": {
|
|
314
|
+
"toolName": "exec_command",
|
|
315
|
+
"input": { "command": "docker", "args": ["compose", "ps"] }
|
|
316
|
+
},
|
|
317
|
+
"expectedAction": "allow"
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
"desc": "docker compose logs should be allowed",
|
|
321
|
+
"toolUse": {
|
|
322
|
+
"toolName": "exec_command",
|
|
323
|
+
"input": { "command": "docker", "args": ["compose", "logs"] }
|
|
324
|
+
},
|
|
325
|
+
"expectedAction": "allow"
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"desc": "tmux list-sessions should be allowed",
|
|
329
|
+
"toolUse": {
|
|
330
|
+
"toolName": "tmux_command",
|
|
331
|
+
"input": { "command": "list-sessions" }
|
|
332
|
+
},
|
|
333
|
+
"expectedAction": "allow"
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"desc": "gh pr view should be allowed",
|
|
337
|
+
"toolUse": {
|
|
338
|
+
"toolName": "exec_command",
|
|
339
|
+
"input": { "command": "gh", "args": ["pr", "view"] }
|
|
340
|
+
},
|
|
341
|
+
"expectedAction": "allow"
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
"desc": "gh api for PR comments should be allowed",
|
|
345
|
+
"toolUse": {
|
|
346
|
+
"toolName": "exec_command",
|
|
347
|
+
"input": {
|
|
348
|
+
"command": "gh",
|
|
349
|
+
"args": ["api", "--method", "GET", "repos/owner/repo/pulls/123/comments"]
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
"expectedAction": "allow"
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
"desc": "gh api without --method should be denied",
|
|
356
|
+
"toolUse": {
|
|
357
|
+
"toolName": "exec_command",
|
|
358
|
+
"input": { "command": "gh", "args": ["api", "repos/owner/repo/pulls"] }
|
|
359
|
+
},
|
|
360
|
+
"expectedAction": "deny"
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
"desc": "gh api --method POST should fall through to defaultAction",
|
|
364
|
+
"toolUse": {
|
|
365
|
+
"toolName": "exec_command",
|
|
366
|
+
"input": {
|
|
367
|
+
"command": "gh",
|
|
368
|
+
"args": ["api", "--method", "POST", "repos/owner/repo/issues"]
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
"expectedAction": null
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
"desc": "bash -c without shell features should be denied",
|
|
375
|
+
"toolUse": {
|
|
376
|
+
"toolName": "exec_command",
|
|
377
|
+
"input": { "command": "bash", "args": ["-c", "echo hello"] }
|
|
378
|
+
},
|
|
379
|
+
"expectedAction": "deny"
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
"desc": "bash -c with pipe should not match deny pattern",
|
|
383
|
+
"toolUse": {
|
|
384
|
+
"toolName": "exec_command",
|
|
385
|
+
"input": { "command": "bash", "args": ["-c", "echo hello | grep hello"] }
|
|
386
|
+
},
|
|
387
|
+
"expectedAction": null
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
"desc": "bash -c with redirect should not match deny pattern",
|
|
391
|
+
"toolUse": {
|
|
392
|
+
"toolName": "exec_command",
|
|
393
|
+
"input": { "command": "bash", "args": ["-c", "echo hello > file.txt"] }
|
|
394
|
+
},
|
|
395
|
+
"expectedAction": null
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"desc": "bash -c with ampersand should not match deny pattern",
|
|
399
|
+
"toolUse": {
|
|
400
|
+
"toolName": "exec_command",
|
|
401
|
+
"input": { "command": "bash", "args": ["-c", "cmd1 && cmd2"] }
|
|
402
|
+
},
|
|
403
|
+
"expectedAction": null
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
"desc": "bash -c with semicolon should not match deny pattern",
|
|
407
|
+
"toolUse": {
|
|
408
|
+
"toolName": "exec_command",
|
|
409
|
+
"input": { "command": "bash", "args": ["-c", "for i in *.txt; do echo $i; done"] }
|
|
410
|
+
},
|
|
411
|
+
"expectedAction": null
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
"desc": "bash -c with command substitution should not match deny pattern",
|
|
415
|
+
"toolUse": {
|
|
416
|
+
"toolName": "exec_command",
|
|
417
|
+
"input": { "command": "bash", "args": ["-c", "echo $(date)"] }
|
|
418
|
+
},
|
|
419
|
+
"expectedAction": null
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"desc": "bash -c with backtick should not match deny pattern",
|
|
423
|
+
"toolUse": {
|
|
424
|
+
"toolName": "exec_command",
|
|
425
|
+
"input": { "command": "bash", "args": ["-c", "echo `date`"] }
|
|
426
|
+
},
|
|
427
|
+
"expectedAction": null
|
|
428
|
+
}
|
|
147
429
|
]
|
|
148
430
|
},
|
|
149
431
|
"models": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iinm/plain-agent",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.27",
|
|
4
4
|
"description": "A lightweight terminal-based coding agent focused on safety and low token cost",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
"node": ">=22"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"check": "npm run lint && tsc && npm run test",
|
|
35
|
+
"check": "npm run lint && tsc && npm run test && npm run test:predefined-approval",
|
|
36
36
|
"test": "node --test",
|
|
37
37
|
"coverage": "node --experimental-test-coverage --test-coverage-exclude='src/**/*.test.mjs' --test",
|
|
38
|
+
"test:predefined-approval": "bash -c 'set -e; mkdir -p tmp; cd tmp; env HOME=. ../bin/plain test-approval'",
|
|
38
39
|
"lint": "npx @biomejs/biome check",
|
|
39
40
|
"fix": "npx @biomejs/biome check --fix"
|
|
40
41
|
},
|
package/src/cli/args.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @typedef {HelpSubcommand | InteractiveSubcommand | BatchSubcommand | ListModelsSubcommand | InstallClaudeCodePluginsSubcommand | CostSubcommand | ResumeSubcommand} Subcommand
|
|
2
|
+
* @typedef {HelpSubcommand | InteractiveSubcommand | BatchSubcommand | ListModelsSubcommand | InstallClaudeCodePluginsSubcommand | CostSubcommand | ResumeSubcommand | TestApprovalSubcommand} Subcommand
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
* @typedef {{ type: 'cost', from: string | null, to: string | null }} CostSubcommand
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ type: 'test-approval', config: string[] }} TestApprovalSubcommand
|
|
31
|
+
*/
|
|
32
|
+
|
|
29
33
|
/**
|
|
30
34
|
* Resume a previously interrupted interactive session.
|
|
31
35
|
* - `sessionId === null` and `list === false`: resume the most recently updated session.
|
|
@@ -149,6 +153,25 @@ export function parseCliArgs(argv) {
|
|
|
149
153
|
};
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
if (subcommandName === "test-approval") {
|
|
157
|
+
const subArgs = args.slice(1);
|
|
158
|
+
/** @type {string[]} */
|
|
159
|
+
const config = [];
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < subArgs.length; i++) {
|
|
162
|
+
if (subArgs[i] === "-c" || subArgs[i] === "--config") {
|
|
163
|
+
if (subArgs[i + 1]) {
|
|
164
|
+
config.push(subArgs[i + 1]);
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
subcommand: { type: "test-approval", config },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
152
175
|
if (subcommandName === "cost") {
|
|
153
176
|
const costArgs = args.slice(1);
|
|
154
177
|
let from = null;
|
|
@@ -203,6 +226,7 @@ Subcommands:
|
|
|
203
226
|
most recently updated session. Use --list to
|
|
204
227
|
see resumable sessions. Switching models is
|
|
205
228
|
not supported (-m is rejected).
|
|
229
|
+
test-approval Run auto-approval rule tests defined in config.
|
|
206
230
|
cost Show aggregated token cost per day for a period.
|
|
207
231
|
Defaults to the first day of the current month
|
|
208
232
|
through today.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { AppConfig, AutoApprovalTestCase } from "../config";
|
|
3
|
+
* @import { ToolUsePattern } from "../tool";
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { styleText } from "node:util";
|
|
7
|
+
import { matchValue } from "../utils/matchValue.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {ToolUsePattern & { source?: string }} ToolUsePatternWithSource
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {AutoApprovalTestCase & { source?: string }} AutoApprovalTestCaseWithSource
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {'pass' | 'warn' | 'fail'} TestVerdict
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} TestResult
|
|
23
|
+
* @property {TestVerdict} verdict
|
|
24
|
+
* @property {AutoApprovalTestCaseWithSource} tc
|
|
25
|
+
* @property {string | undefined} got
|
|
26
|
+
* @property {string | undefined} expected
|
|
27
|
+
* @property {string | undefined} patternSource
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run auto-approval rule tests defined in the app config.
|
|
32
|
+
* @param {AppConfig} appConfig
|
|
33
|
+
* @returns {number} exit code (0 = all passed, 1 = any failed)
|
|
34
|
+
*/
|
|
35
|
+
export function runTestApprovalCommand(appConfig) {
|
|
36
|
+
const patterns = /** @type {ToolUsePatternWithSource[]} */ (
|
|
37
|
+
appConfig.autoApproval?.patterns ?? []
|
|
38
|
+
);
|
|
39
|
+
const tests = /** @type {AutoApprovalTestCaseWithSource[]} */ (
|
|
40
|
+
appConfig.autoApproval?.tests ?? []
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (tests.length === 0) {
|
|
44
|
+
console.log("No test cases found in autoApproval.tests.");
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const results = evaluateTests(tests, patterns);
|
|
49
|
+
console.log();
|
|
50
|
+
printResults(results);
|
|
51
|
+
|
|
52
|
+
const failCount = results.filter((r) => r.verdict === "fail").length;
|
|
53
|
+
const warnCount = results.filter((r) => r.verdict === "warn").length;
|
|
54
|
+
|
|
55
|
+
if (failCount > 0) {
|
|
56
|
+
console.error(
|
|
57
|
+
styleText("red", `${failCount} failed`) +
|
|
58
|
+
(warnCount > 0
|
|
59
|
+
? `, ${styleText("yellow", `${warnCount} overridden`)}`
|
|
60
|
+
: "") +
|
|
61
|
+
`, ${tests.length} total`,
|
|
62
|
+
);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (warnCount > 0) {
|
|
67
|
+
console.log(
|
|
68
|
+
styleText("yellow", `${tests.length} passed (${warnCount} overridden)`),
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
console.log(styleText("green", `${tests.length} passed`));
|
|
72
|
+
}
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {AutoApprovalTestCaseWithSource[]} tests
|
|
78
|
+
* @param {ToolUsePatternWithSource[]} patterns
|
|
79
|
+
* @returns {TestResult[]}
|
|
80
|
+
*/
|
|
81
|
+
function evaluateTests(tests, patterns) {
|
|
82
|
+
return tests.map((tc) => {
|
|
83
|
+
const matchedPattern = patterns.find((p) =>
|
|
84
|
+
matchValue(tc.toolUse, {
|
|
85
|
+
toolName: p.toolName,
|
|
86
|
+
...(p.input !== undefined && { input: p.input }),
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const got = matchedPattern?.action;
|
|
91
|
+
const expected = tc.expectedAction === null ? undefined : tc.expectedAction;
|
|
92
|
+
const patternSource = matchedPattern?.source;
|
|
93
|
+
|
|
94
|
+
/** @type {TestVerdict} */
|
|
95
|
+
let verdict;
|
|
96
|
+
if (got === expected) {
|
|
97
|
+
verdict = "pass";
|
|
98
|
+
} else if (isOverriddenByDifferentConfig(tc.source, patternSource, got)) {
|
|
99
|
+
verdict = "warn";
|
|
100
|
+
} else {
|
|
101
|
+
verdict = "fail";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { verdict, tc, got, expected, patternSource };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {TestResult[]} results
|
|
110
|
+
*/
|
|
111
|
+
function printResults(results) {
|
|
112
|
+
/** @type {Map<string, TestResult[]>} */
|
|
113
|
+
const grouped = new Map();
|
|
114
|
+
for (const r of results) {
|
|
115
|
+
const key = r.tc.source ?? "";
|
|
116
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
117
|
+
grouped.get(key)?.push(r);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const [source, group] of grouped) {
|
|
121
|
+
if (source) {
|
|
122
|
+
console.log(styleText("blue", `[${source}]`));
|
|
123
|
+
}
|
|
124
|
+
for (const r of group) {
|
|
125
|
+
printSingleResult(r);
|
|
126
|
+
}
|
|
127
|
+
console.log();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {TestResult} r
|
|
133
|
+
*/
|
|
134
|
+
function printSingleResult(r) {
|
|
135
|
+
const gotStr = r.got ?? "no match";
|
|
136
|
+
const expectedStr = r.expected ?? "no match";
|
|
137
|
+
|
|
138
|
+
if (r.verdict === "pass") {
|
|
139
|
+
console.log(styleText("green", ` ✓ ${r.tc.desc}`));
|
|
140
|
+
} else if (r.verdict === "warn") {
|
|
141
|
+
console.log(
|
|
142
|
+
styleText("yellow", ` ⚠ ${r.tc.desc}`) +
|
|
143
|
+
` — got: ${gotStr} (overridden by ${r.patternSource})`,
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
console.log(styleText("red", ` ✗ ${r.tc.desc}`));
|
|
147
|
+
const sourceLabel = r.patternSource ? ` [${r.patternSource}]` : "";
|
|
148
|
+
console.log(` expected: ${expectedStr}, got: ${gotStr}${sourceLabel}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string | undefined} testSource
|
|
154
|
+
* @param {string | undefined} patternSource
|
|
155
|
+
* @param {string | undefined} got
|
|
156
|
+
* @returns {boolean}
|
|
157
|
+
*/
|
|
158
|
+
function isOverriddenByDifferentConfig(testSource, patternSource, got) {
|
|
159
|
+
if (!testSource || !patternSource) return false;
|
|
160
|
+
if (got === undefined) return false;
|
|
161
|
+
return testSource !== patternSource;
|
|
162
|
+
}
|
package/src/config.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export type AppConfig = {
|
|
|
72
72
|
platforms?: PlatformConfig[];
|
|
73
73
|
autoApproval?: {
|
|
74
74
|
patterns?: ToolUsePattern[];
|
|
75
|
+
tests?: AutoApprovalTestCase[];
|
|
75
76
|
maxApprovals?: number;
|
|
76
77
|
defaultAction?: "deny" | "ask";
|
|
77
78
|
/** Additional absolute paths to allow for auto-approval (outside working directory) */
|
|
@@ -91,6 +92,12 @@ export type AppConfig = {
|
|
|
91
92
|
claudeCodePlugins?: ClaudeCodePluginRepo[];
|
|
92
93
|
};
|
|
93
94
|
|
|
95
|
+
export type AutoApprovalTestCase = {
|
|
96
|
+
desc: string;
|
|
97
|
+
toolUse: { toolName: string; input?: Record<string, unknown> };
|
|
98
|
+
expectedAction: "allow" | "deny" | "ask" | null;
|
|
99
|
+
};
|
|
100
|
+
|
|
94
101
|
export type MCPServerConfig = {
|
|
95
102
|
command: string;
|
|
96
103
|
args?: string[];
|
package/src/config.mjs
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @import { AppConfig } from "./config";
|
|
3
|
+
* @import { ToolUsePattern } from "./tool";
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {ToolUsePattern & { source?: string }} ToolUsePatternWithSource
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import crypto from "node:crypto";
|
|
@@ -67,7 +72,13 @@ export async function loadAppConfig(options = {}) {
|
|
|
67
72
|
config.autoApproval?.defaultAction ??
|
|
68
73
|
merged.autoApproval?.defaultAction,
|
|
69
74
|
patterns: [
|
|
70
|
-
...(config.autoApproval?.patterns ?? [])
|
|
75
|
+
...(config.autoApproval?.patterns ?? []).map(
|
|
76
|
+
(p) =>
|
|
77
|
+
/** @type {ToolUsePatternWithSource} */ ({
|
|
78
|
+
...p,
|
|
79
|
+
source: filePath,
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
71
82
|
...(merged.autoApproval?.patterns ?? []),
|
|
72
83
|
],
|
|
73
84
|
maxApprovals:
|
|
@@ -80,6 +91,13 @@ export async function loadAppConfig(options = {}) {
|
|
|
80
91
|
allowGitUnmanagedFiles:
|
|
81
92
|
config.autoApproval?.allowGitUnmanagedFiles ??
|
|
82
93
|
merged.autoApproval?.allowGitUnmanagedFiles,
|
|
94
|
+
tests: [
|
|
95
|
+
...(config.autoApproval?.tests ?? []).map((t) => ({
|
|
96
|
+
...t,
|
|
97
|
+
source: filePath,
|
|
98
|
+
})),
|
|
99
|
+
...(merged.autoApproval?.tests ?? []),
|
|
100
|
+
],
|
|
83
101
|
},
|
|
84
102
|
sandbox: config.sandbox ?? merged.sandbox,
|
|
85
103
|
tools: {
|
package/src/main.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { parseCliArgs, printHelp } from "./cli/args.mjs";
|
|
|
14
14
|
import { startBatchSession } from "./cli/batch.mjs";
|
|
15
15
|
import { runCostCommand } from "./cli/cost.mjs";
|
|
16
16
|
import { startInteractiveSession } from "./cli/interactive.mjs";
|
|
17
|
+
import { runTestApprovalCommand } from "./cli/testApproval.mjs";
|
|
17
18
|
import { loadAppConfig } from "./config.mjs";
|
|
18
19
|
import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
|
|
19
20
|
import { loadPrompts } from "./context/loadPrompts.mjs";
|
|
@@ -80,6 +81,15 @@ export async function main(argv = process.argv) {
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
if (cliArgs.subcommand.type === "test-approval") {
|
|
85
|
+
const { appConfig } = await loadAppConfig({
|
|
86
|
+
skipTrustCheck: true,
|
|
87
|
+
configFiles: cliArgs.subcommand.config,
|
|
88
|
+
});
|
|
89
|
+
const exitCode = runTestApprovalCommand(appConfig);
|
|
90
|
+
process.exit(exitCode);
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
|
|
84
94
|
const sessions = await listSessions();
|
|
85
95
|
if (sessions.length === 0) {
|