@iinm/plain-agent 1.10.26 → 1.10.28

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 CHANGED
@@ -1,13 +1,88 @@
1
1
  # Plain Agent
2
2
 
3
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iinm/plain-agent)
4
+ [![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.10.28)](https://socket.dev/npm/package/@iinm/plain-agent)
5
+
3
6
  A lightweight terminal-based coding agent focused on safety and low token cost
4
7
 
8
+ ## Table of Contents
9
+
10
+ - [Design](#design)
11
+ - [Multi-provider support](#multi-provider-support)
12
+ - [Auto-approval](#auto-approval)
13
+ - [Path Validation](#path-validation)
14
+ - [Sandbox](#sandbox)
15
+ - [Memory file](#memory-file)
16
+ - [Token Efficiency](#token-efficiency)
17
+ - [Claude Code Compatibility](#claude-code-compatibility)
18
+ - [Requirements](#requirements)
19
+ - [Quick Start](#quick-start)
20
+ - [Configuration](#configuration)
21
+ - [Available Tools](#available-tools)
22
+ - [Prompts](#prompts)
23
+ - [Subagents](#subagents)
24
+ - [Claude Code Plugin Support](#claude-code-plugin-support)
25
+ - [Voice Input](#voice-input)
26
+ - [Appendix: Creating Least-Privilege Users for Cloud Providers](#appendix-creating-least-privilege-users-for-cloud-providers)
27
+ - [Developer Notes](#developer-notes)
28
+
5
29
  ## Design
6
30
 
7
31
  ### Multi-provider support
8
32
 
9
33
  Supports Claude, OpenAI, Gemini, and any OpenAI-compatible provider. Bedrock, Vertex AI, and Azure are also supported for teams working in environments restricted to managed cloud providers.
10
34
 
35
+ Each model definition has two independent parts:
36
+
37
+ - **`platform`** — where to send the request and how to authenticate (Anthropic, Bedrock, Vertex AI, Azure, etc.)
38
+ - **`model.format`** — which API format to use (`anthropic`, `gemini`, `openai-responses`, `openai-messages`, `bedrock-converse`)
39
+
40
+ Because these are separate, the same API format works across different platforms. For example, Claude models use the `anthropic` format whether you call Anthropic directly or through Bedrock.
41
+
42
+ ```js
43
+ // Anthropic direct
44
+ {
45
+ "name": "claude-sonnet-4-6",
46
+ "variant": "thinking-high",
47
+ "platform": {
48
+ "name": "anthropic",
49
+ "variant": "default"
50
+ },
51
+ "model": {
52
+ "format": "anthropic",
53
+ "config": {
54
+ "model": "claude-sonnet-4-6",
55
+ "max_tokens": 32768,
56
+ "thinking": { "type": "adaptive" },
57
+ "output_config": { "effort": "high" }
58
+ }
59
+ }
60
+ }
61
+
62
+ // Bedrock — same format, different platform
63
+ {
64
+ "name": "claude-sonnet-4-6",
65
+ "variant": "thinking-high-bedrock-jp",
66
+ "platform": {
67
+ "name": "bedrock",
68
+ "variant": "jp"
69
+ },
70
+ "model": {
71
+ "format": "anthropic",
72
+ "config": {
73
+ "model": "jp.anthropic.claude-sonnet-4-6",
74
+ "max_tokens": 32768,
75
+ "thinking": { "type": "adaptive" },
76
+ "output_config": { "effort": "high" }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ Models are identified by `name+variant` (e.g., `claude-sonnet-4-6+thinking-high`). You can define multiple variants of the same model with different settings — such as thinking budget or region — and switch between them as needed.
83
+
84
+ You can also add entries to `platforms` and `models` to use any OpenAI-compatible endpoint, such as Ollama or Fireworks. See the Quick Start section for examples.
85
+
11
86
  ### Auto-approval
12
87
 
13
88
  Configure what the agent can do automatically using a small DSL with regex matching. Below is an excerpt from the [default config](https://github.com/iinm/plain-agent/blob/main/config/config.predefined.json).
@@ -54,6 +129,25 @@ Configure what the agent can do automatically using a small DSL with regex match
54
129
  "action": "deny",
55
130
  "reason": "--method must be specified right after 'api'"
56
131
  }
132
+ ],
133
+
134
+ // Test cases for verifying patterns. Run: plain test-approval
135
+ "tests": [
136
+ {
137
+ "desc": "fd with safe args should be allowed",
138
+ "toolUse": { "toolName": "exec_command", "input": { "command": "fd", "args": ["README", "./"] } },
139
+ "expectedAction": "allow"
140
+ },
141
+ {
142
+ "desc": "fd with --exec should require approval",
143
+ "toolUse": { "toolName": "exec_command", "input": { "command": "fd", "args": [".env", "./", "--exec", "cat", "{}"] } },
144
+ "expectedAction": "ask"
145
+ },
146
+ {
147
+ "desc": "gh api without --method should be denied",
148
+ "toolUse": { "toolName": "exec_command", "input": { "command": "gh", "args": ["api", "/repos/owner/repo/pulls"] } },
149
+ "expectedAction": "deny"
150
+ }
57
151
  ]
58
152
  }
59
153
  }
@@ -65,8 +159,23 @@ String values in tool inputs are treated as file paths and validated against the
65
159
 
66
160
  - The path must be under the working directory or a path listed in `autoApproval.allowedPaths`
67
161
  - No directory traversal (`..` is not allowed)
162
+ - Symlinks are resolved to their real path before validation — a symlink inside the working directory that points outside is rejected. Broken and circular symlinks are also rejected.
68
163
  - The file must be tracked by Git (not ignored)
69
164
 
165
+ Commands are executed without a shell — shell operators like `&&`, `|`, `;`, and redirects are not interpreted unless the agent explicitly uses `bash -c`. This makes each argument a discrete token that can be validated individually.
166
+
167
+ Compound arguments are decomposed before validation — embedded paths are extracted and checked individually:
168
+
169
+ | Pattern | Example | Extracted |
170
+ |---|---|---|
171
+ | `@<path>` | `@data.json` | `data.json` |
172
+ | `--opt=<val>` | `--prefix=/tmp/foo` | `/tmp/foo` |
173
+ | `-X<val>` | `-I/usr/include` | `/usr/include` |
174
+ | `VAR=<val>` | `OUTPUT=/etc/passwd` | `/etc/passwd` |
175
+ | `proto://…` | `file:///etc/passwd` | `/etc/passwd` |
176
+
177
+ `--opt=<val>`, `-X<val>`, and `VAR=<val>` are checked recursively, so chained patterns like `-DINSTALL_DIR=/etc` decompose fully (`-D` → `INSTALL_DIR=/etc` → `/etc`).
178
+
70
179
  **Note**: validation only applies when the agent explicitly passes file paths to tools. It cannot catch file access inside scripts the agent writes — something like `bash -c "rm -rf /"` is beyond its reach. Always use a sandbox when auto-approving script execution.
71
180
 
72
181
  ### Sandbox
@@ -107,12 +216,22 @@ A Docker-based wrapper called `plain-sandbox` is included, but the interface is
107
216
  }
108
217
  ```
109
218
 
219
+ ### Memory file
220
+
221
+ The agent maintains a memory file (`.plain-agent/memory/`) for each session to:
222
+
223
+ - Keep task state human-readable — you can open the file to see exactly where things stand.
224
+ - Resume cleanly — the agent can restart a task from the memory file with a clean context.
225
+ - Pass information between dependent tasks — subagents write their results to the memory file, which the main agent or a follow-up session reads to continue.
226
+
110
227
  ### Token Efficiency
111
228
 
112
229
  A few design choices keep token usage low:
113
230
 
114
231
  - Minimal system prompt — the [system prompt](https://github.com/iinm/plain-agent/blob/main/src/prompt.mjs) contains only what the agent needs to function.
115
232
  - Output truncation — when a command or MCP tool produces large output, it is truncated and saved to a file. The agent can then read only the relevant parts.
233
+ - Context compaction — when the context grows large, you can run `/compact` to discard old messages and reload task state from a memory file.
234
+ - MCP tool filtering — MCP servers often expose many tools. Use `enabledTools` in the server config to enable only the ones you need, which reduces the number of tool definitions sent to the model.
116
235
 
117
236
  ### Claude Code Compatibility
118
237
 
@@ -507,7 +626,7 @@ Files are loaded in the following order. Settings in later files override earlie
507
626
  ]
508
627
  },
509
628
  "sandbox": {
510
- // ⚠️ Build the image before first use: plain-sandbox --verbose echo done
629
+ // Build the image before first use: plain-sandbox --verbose echo done
511
630
  "command": "plain-sandbox",
512
631
  "args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--skip-build", "--keep-alive", "30"],
513
632
  // ↑ --mount-readonly: prevents the agent from overwriting its own config
@@ -557,6 +676,15 @@ Files are loaded in the following order. Settings in later files override earlie
557
676
  "toolName": { "$regex": "mcp__slack__slack_(read|search)_.+" },
558
677
  "action": "allow"
559
678
  }
679
+ ],
680
+
681
+ // Test cases for verifying patterns. Run: plain test-approval
682
+ "tests": [
683
+ {
684
+ "desc": "npm test should be allowed",
685
+ "toolUse": { "toolName": "exec_command", "input": { "command": "npm", "args": ["run", "test"] } },
686
+ "expectedAction": "allow"
687
+ }
560
688
  ]
561
689
  },
562
690
 
@@ -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.26",
3
+ "version": "1.10.28",
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,11 +32,13 @@
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
- "fix": "npx @biomejs/biome check --fix"
40
+ "fix": "npx @biomejs/biome check --fix",
41
+ "version": "sed -i \"s|badge.socket.dev/npm/package/@iinm/plain-agent/[0-9][0-9.]*|badge.socket.dev/npm/package/@iinm/plain-agent/${npm_package_version}|g\" README.md && git add README.md"
40
42
  },
41
43
  "dependencies": {},
42
44
  "devDependencies": {
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) {
@@ -62,6 +62,78 @@ export function isSafeToolInputItem(
62
62
  arg,
63
63
  allowedPaths = [],
64
64
  allowGitUnmanagedFiles = false,
65
+ ) {
66
+ // @<path> pattern (e.g., curl -d @file.json)
67
+ if (arg.startsWith("@")) {
68
+ const pathPart = arg.slice(1);
69
+ return (
70
+ isSafeToolInputItemRaw(arg, allowedPaths, allowGitUnmanagedFiles) &&
71
+ isSafeToolInputItemRaw(pathPart, allowedPaths, allowGitUnmanagedFiles)
72
+ );
73
+ }
74
+
75
+ // --opt=val pattern (e.g., npm --prefix=foo)
76
+ const longOptMatch = arg.match(/^--[^=]+=(.+)$/);
77
+ if (longOptMatch) {
78
+ return (
79
+ isSafeToolInputItemRaw(arg, allowedPaths, allowGitUnmanagedFiles) &&
80
+ isSafeToolInputItem(longOptMatch[1], allowedPaths, allowGitUnmanagedFiles)
81
+ );
82
+ }
83
+
84
+ // -X<val> pattern (e.g., gcc -oout, gcc -I/usr/include)
85
+ const shortOptMatch = arg.match(/^-[a-zA-Z](.+)$/);
86
+ if (shortOptMatch) {
87
+ return (
88
+ isSafeToolInputItemRaw(arg, allowedPaths, allowGitUnmanagedFiles) &&
89
+ isSafeToolInputItem(
90
+ shortOptMatch[1],
91
+ allowedPaths,
92
+ allowGitUnmanagedFiles,
93
+ )
94
+ );
95
+ }
96
+
97
+ // VAR=val pattern (e.g., make OUTPUT=/path, env KEY=val)
98
+ // Must not start with - or @ (already handled above)
99
+ const keyValueMatch = arg.match(/^[^-@][^=]*=(.+)$/);
100
+ if (keyValueMatch) {
101
+ return (
102
+ isSafeToolInputItemRaw(arg, allowedPaths, allowGitUnmanagedFiles) &&
103
+ isSafeToolInputItem(
104
+ keyValueMatch[1],
105
+ allowedPaths,
106
+ allowGitUnmanagedFiles,
107
+ )
108
+ );
109
+ }
110
+
111
+ // proto://path pattern (e.g., file:///etc/passwd)
112
+ const protoMatch = arg.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(.+)$/);
113
+ if (protoMatch) {
114
+ return (
115
+ isSafeToolInputItemRaw(arg, allowedPaths, allowGitUnmanagedFiles) &&
116
+ isSafeToolInputItemRaw(
117
+ `/${protoMatch[1]}`,
118
+ allowedPaths,
119
+ allowGitUnmanagedFiles,
120
+ )
121
+ );
122
+ }
123
+
124
+ return isSafeToolInputItemRaw(arg, allowedPaths, allowGitUnmanagedFiles);
125
+ }
126
+
127
+ /**
128
+ * @param {string} arg
129
+ * @param {string[]} [allowedPaths=[]] - Additional allowed paths (outside working directory)
130
+ * @param {boolean} [allowGitUnmanagedFiles=false] - Allow access to git-unmanaged files
131
+ * @returns {boolean}
132
+ */
133
+ function isSafeToolInputItemRaw(
134
+ arg,
135
+ allowedPaths = [],
136
+ allowGitUnmanagedFiles = false,
65
137
  ) {
66
138
  const workingDir = process.cwd();
67
139