@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 +129 -1
- package/config/config.predefined.json +282 -0
- package/package.json +5 -3
- 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/src/toolInputValidator.mjs +72 -0
package/README.md
CHANGED
|
@@ -1,13 +1,88 @@
|
|
|
1
1
|
# Plain Agent
|
|
2
2
|
|
|
3
|
+
[](https://deepwiki.com/iinm/plain-agent)
|
|
4
|
+
[](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
|
-
//
|
|
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.
|
|
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
|
|