@iinm/plain-agent 1.10.23 → 1.10.25
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 +171 -41
- package/package.json +2 -2
- package/sandbox/bin/plain-sandbox +34 -6
- package/src/cli/interactive.mjs +2 -0
- package/src/main.mjs +1 -0
package/README.md
CHANGED
|
@@ -1,17 +1,129 @@
|
|
|
1
1
|
# Plain Agent
|
|
2
2
|
|
|
3
|
-
A lightweight coding agent
|
|
3
|
+
A lightweight terminal-based coding agent focused on safety and low token cost
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- **Fine-grained auto-approval** — Auto-approve tool calls by matching tool names and inputs against configurable patterns, while validating string inputs as paths for safety.
|
|
7
|
-
- **Sandboxed execution** — Run commands in a Docker container with filesystem and network isolation.
|
|
8
|
-
- **Supports Claude Code resources** — Use Claude Code plugins, commands, subagents, and skills from `.claude/`.
|
|
9
|
-
- **Zero external dependencies** — Built using only Node.js standard libraries.
|
|
5
|
+
## Design
|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
### Multi-provider support
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
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
|
+
|
|
11
|
+
### Auto-approval
|
|
12
|
+
|
|
13
|
+
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).
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
{
|
|
17
|
+
"autoApproval": {
|
|
18
|
+
// What to do when no pattern matches: ask - prompt the user, deny - reject automatically
|
|
19
|
+
"defaultAction": "ask",
|
|
20
|
+
|
|
21
|
+
// Patterns are evaluated top-to-bottom; first match wins
|
|
22
|
+
"patterns": [
|
|
23
|
+
// fd example:
|
|
24
|
+
// Ask for approval when risky flags like --exec or --no-ignore are present
|
|
25
|
+
{
|
|
26
|
+
"toolName": "exec_command",
|
|
27
|
+
"input": {
|
|
28
|
+
"command": "fd",
|
|
29
|
+
"args": { "$has": { "$regex": "^(--unrestricted|--no-ignore|--exec|--exec-batch|--follow|-[^-]*[uIxXL])" } }
|
|
30
|
+
},
|
|
31
|
+
"action": "ask"
|
|
32
|
+
},
|
|
33
|
+
// Allow all other fd calls
|
|
34
|
+
{
|
|
35
|
+
"toolName": "exec_command",
|
|
36
|
+
"input": { "command": "fd" },
|
|
37
|
+
"action": "allow"
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// GitHub CLI example:
|
|
41
|
+
// Allow read access to PRs and issues
|
|
42
|
+
{
|
|
43
|
+
"toolName": "exec_command",
|
|
44
|
+
"input": {
|
|
45
|
+
"command": "gh",
|
|
46
|
+
"args": ["api", "--method", "GET", { "$regex": "^repos/[^/]+/[^/]+/(pulls|issues)/" }]
|
|
47
|
+
},
|
|
48
|
+
"action": "allow"
|
|
49
|
+
},
|
|
50
|
+
// Require --method to be explicit, so GET calls can be safely auto-approved
|
|
51
|
+
{
|
|
52
|
+
"toolName": "exec_command",
|
|
53
|
+
"input": { "command": "gh", "args": ["api", "--method"] },
|
|
54
|
+
"action": "ask"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"toolName": "exec_command",
|
|
58
|
+
"input": { "command": "gh", "args": ["api"] },
|
|
59
|
+
"action": "deny",
|
|
60
|
+
"reason": "--method must be specified"
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Path Validation
|
|
68
|
+
|
|
69
|
+
String values in tool inputs are treated as file paths and validated against these rules. This takes precedence over `autoApproval` — even if a pattern marks an action as `allow`, a validation failure falls back to `defaultAction`.
|
|
70
|
+
|
|
71
|
+
- The path must be under the working directory or a path listed in `autoApproval.allowedPaths`
|
|
72
|
+
- No directory traversal (`..` is not allowed)
|
|
73
|
+
- The file must be tracked by Git (not ignored)
|
|
74
|
+
|
|
75
|
+
**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.
|
|
76
|
+
|
|
77
|
+
### Sandbox
|
|
78
|
+
|
|
79
|
+
The agent can run arbitrary commands via `exec_command` and `tmux_command`. You can configure a wrapper command that intercepts both.
|
|
80
|
+
A Docker-based wrapper called `plain-sandbox` is included, but the interface is designed to work with other tools as well, such as [Anthropic Sandbox Runtime (srt)](https://github.com/anthropic-experimental/sandbox-runtime).
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
{
|
|
84
|
+
"sandbox": {
|
|
85
|
+
// Commands are wrapped and executed with this command
|
|
86
|
+
// Build the image before first use: plain-sandbox --verbose echo done
|
|
87
|
+
"command": "plain-sandbox",
|
|
88
|
+
"args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--skip-build", "--keep-alive", "30"],
|
|
89
|
+
// ↑ --mount-readonly: prevents the agent from overwriting its own config
|
|
90
|
+
// separator is inserted between sandbox flags and the user command to prevent bypasses
|
|
91
|
+
"separator": "--",
|
|
92
|
+
|
|
93
|
+
"rules": [
|
|
94
|
+
// Run specific commands outside the sandbox
|
|
95
|
+
{
|
|
96
|
+
"pattern": {
|
|
97
|
+
"command": { "$regex": "^(gh|docker)$" }
|
|
98
|
+
},
|
|
99
|
+
"mode": "unsandboxed"
|
|
100
|
+
},
|
|
101
|
+
// Run commands in the sandbox with network access
|
|
102
|
+
{
|
|
103
|
+
"pattern": {
|
|
104
|
+
"command": "npm",
|
|
105
|
+
"args": [{ "$regex": "^(install|ci)$" }]
|
|
106
|
+
},
|
|
107
|
+
"mode": "sandbox",
|
|
108
|
+
"additionalArgs": ["--allow-net", "registry.npmjs.org"]
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Token Efficiency
|
|
116
|
+
|
|
117
|
+
A few design choices keep token usage low:
|
|
118
|
+
|
|
119
|
+
- 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.
|
|
120
|
+
- 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.
|
|
121
|
+
|
|
122
|
+
### Claude Code Compatibility
|
|
123
|
+
|
|
124
|
+
Claude Code has a plugin ecosystem and is widely used across teams. plain-agent supports `.claude/` commands, subagents, and skills so you can share project skills with Claude Code users. Plugins can also be installed.
|
|
125
|
+
|
|
126
|
+
**Limitation:** Subagents run sequentially, not in parallel. The upside is that their activity is fully observable and they don't spike token usage. They also inherit the main context rather than starting fresh — a simplification chosen for ease of implementation, which also avoids redundant file reads and reduces the chance of losing context between handoffs.
|
|
15
127
|
|
|
16
128
|
## Requirements
|
|
17
129
|
|
|
@@ -345,6 +457,31 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
345
457
|
└── agents/ # Project-specific agent roles
|
|
346
458
|
```
|
|
347
459
|
|
|
460
|
+
|
|
461
|
+
<details>
|
|
462
|
+
<summary><b>Minimal example (file editing and web search only — no script execution, no sandbox required)</b></summary>
|
|
463
|
+
|
|
464
|
+
```js
|
|
465
|
+
{
|
|
466
|
+
"autoApproval": {
|
|
467
|
+
"defaultAction": "ask",
|
|
468
|
+
"maxApprovals": 100,
|
|
469
|
+
"patterns": [
|
|
470
|
+
{
|
|
471
|
+
"toolName": { "$regex": "^(write_file|patch_file)$" },
|
|
472
|
+
"action": "allow"
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
"toolName": { "$regex": "^(web_search|web_fetch)$" },
|
|
476
|
+
"action": "allow"
|
|
477
|
+
}
|
|
478
|
+
]
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
</details>
|
|
484
|
+
|
|
348
485
|
<details>
|
|
349
486
|
<summary><b>YOLO mode example (requires a sandbox for safety)</b></summary>
|
|
350
487
|
|
|
@@ -360,11 +497,11 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
360
497
|
"action": "allow"
|
|
361
498
|
},
|
|
362
499
|
{
|
|
363
|
-
"toolName": "
|
|
500
|
+
"toolName": { "$regex": "^(web_search|web_fetch)$" },
|
|
364
501
|
"action": "allow"
|
|
365
502
|
},
|
|
366
503
|
{
|
|
367
|
-
"toolName":
|
|
504
|
+
"toolName": "exec_command",
|
|
368
505
|
"action": "allow"
|
|
369
506
|
}
|
|
370
507
|
// ⚠️ Never do this. MCP runs outside the sandbox, so it can send anything externally.
|
|
@@ -375,8 +512,10 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
375
512
|
]
|
|
376
513
|
},
|
|
377
514
|
"sandbox": {
|
|
515
|
+
// ⚠️ Build the image before first use: plain-sandbox --verbose echo done
|
|
378
516
|
"command": "plain-sandbox",
|
|
379
|
-
"args": ["--allow-write", "--skip-build", "--keep-alive", "30"],
|
|
517
|
+
"args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--skip-build", "--keep-alive", "30"],
|
|
518
|
+
// ↑ --mount-readonly: prevents the agent from overwriting its own config
|
|
380
519
|
"separator": "--"
|
|
381
520
|
}
|
|
382
521
|
}
|
|
@@ -406,7 +545,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
406
545
|
"action": "allow"
|
|
407
546
|
},
|
|
408
547
|
|
|
409
|
-
// ⚠️
|
|
548
|
+
// ⚠️ When auto-approving execution, scripts can access unauthorized files and networks. Always use a sandbox.
|
|
410
549
|
{
|
|
411
550
|
"toolName": "exec_command",
|
|
412
551
|
"input": { "command": "npm", "args": ["run", { "$regex": "^(lint|test)$" }] },
|
|
@@ -437,8 +576,10 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
437
576
|
// Sandbox environment for the exec_command and tmux_command tools
|
|
438
577
|
"sandbox": {
|
|
439
578
|
// Commands are wrapped and executed with this command
|
|
579
|
+
// Build the image before first use: plain-sandbox --verbose echo done
|
|
440
580
|
"command": "plain-sandbox",
|
|
441
|
-
"args": ["--allow-write", "--skip-build", "--keep-alive", "30"],
|
|
581
|
+
"args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--skip-build", "--keep-alive", "30"],
|
|
582
|
+
// ↑ --mount-readonly: prevents the agent from overwriting its own config
|
|
442
583
|
// separator is inserted between sandbox flags and the user command to prevent bypasses
|
|
443
584
|
"separator": "--",
|
|
444
585
|
|
|
@@ -637,33 +778,6 @@ Press **Ctrl-O** to start recording, then press it again to stop. Partial transc
|
|
|
637
778
|
is a letter (a-z) or one of `[ \ ] ^ _`. Defaults to `"ctrl-o"`.
|
|
638
779
|
- `recorder` — Override automatic recorder detection, e.g. `{ "command": "sox", "args": ["-q", "-d", "-b", "16", "-c", "1", "-r", "24000", "-e", "signed-integer", "-t", "raw", "-"] }`. It must write raw 16-bit little-endian mono PCM to stdout at 24 kHz (OpenAI) or 16 kHz (Gemini).
|
|
639
780
|
|
|
640
|
-
## Development
|
|
641
|
-
|
|
642
|
-
```sh
|
|
643
|
-
# Run lint, typecheck, and tests
|
|
644
|
-
npm run check
|
|
645
|
-
|
|
646
|
-
# Fix lint issues
|
|
647
|
-
npm run fix
|
|
648
|
-
# or
|
|
649
|
-
npm run fix -- --unsafe
|
|
650
|
-
|
|
651
|
-
# Update dependencies
|
|
652
|
-
npx npm-check-updates -t minor -c 3
|
|
653
|
-
npx npm-check-updates -t minor -c 3 -u
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
## Release
|
|
657
|
-
|
|
658
|
-
```sh
|
|
659
|
-
npm version <major|minor|patch>
|
|
660
|
-
|
|
661
|
-
git push --follow-tags
|
|
662
|
-
gh release create $(git describe --tags) --generate-notes
|
|
663
|
-
|
|
664
|
-
npm publish --access public
|
|
665
|
-
```
|
|
666
|
-
|
|
667
781
|
## Appendix: Creating Least-Privilege Users for Cloud Providers
|
|
668
782
|
|
|
669
783
|
<details>
|
|
@@ -809,3 +923,19 @@ gcloud iam service-accounts add-iam-policy-binding "$service_account_email" \
|
|
|
809
923
|
gcloud auth print-access-token --impersonate-service-account "$service_account_email"
|
|
810
924
|
```
|
|
811
925
|
</details>
|
|
926
|
+
|
|
927
|
+
## Developer Notes
|
|
928
|
+
|
|
929
|
+
<details>
|
|
930
|
+
<summary>Release</summary>
|
|
931
|
+
|
|
932
|
+
```sh
|
|
933
|
+
npm version <major|minor|patch>
|
|
934
|
+
|
|
935
|
+
git push --follow-tags
|
|
936
|
+
gh release create $(git describe --tags) --generate-notes
|
|
937
|
+
|
|
938
|
+
npm publish --access public
|
|
939
|
+
```
|
|
940
|
+
</details>
|
|
941
|
+
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iinm/plain-agent",
|
|
3
|
-
"version": "1.10.
|
|
4
|
-
"description": "A lightweight coding agent
|
|
3
|
+
"version": "1.10.25",
|
|
4
|
+
"description": "A lightweight terminal-based coding agent focused on safety and low token cost",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
@@ -18,8 +18,8 @@ Usage: $SCRIPT_NAME [--dockerfile FILE]
|
|
|
18
18
|
[--env-file FILE]
|
|
19
19
|
[--allow-write] [--allow-net [DESTINATIONS|--]]
|
|
20
20
|
[--volume [NAME:]PATH]
|
|
21
|
-
[--mount-writable
|
|
22
|
-
[--mount-readonly
|
|
21
|
+
[--mount-writable HOST_PATH[:CONTAINER_PATH]]
|
|
22
|
+
[--mount-readonly HOST_PATH[:CONTAINER_PATH]]
|
|
23
23
|
[--publish [HOST_ADDRESS:]HOST_PORT:CONTAINER_PORT]
|
|
24
24
|
[--tty]
|
|
25
25
|
[--no-cache]
|
|
@@ -50,11 +50,19 @@ Options:
|
|
|
50
50
|
inside the container.
|
|
51
51
|
If NAME is not given, the volume name is generated.
|
|
52
52
|
|
|
53
|
-
--mount-readonly
|
|
53
|
+
--mount-readonly HOST_PATH[:CONTAINER_PATH]
|
|
54
54
|
Mount a host file or directory to a container path as read-only.
|
|
55
|
+
If CONTAINER_PATH is omitted, the resolved absolute host path is used.
|
|
56
|
+
Relative HOST_PATH is resolved from the current working directory.
|
|
57
|
+
Relative CONTAINER_PATH is resolved from the working directory
|
|
58
|
+
inside the container.
|
|
55
59
|
|
|
56
|
-
--mount-writable
|
|
60
|
+
--mount-writable HOST_PATH[:CONTAINER_PATH]
|
|
57
61
|
Mount a host file or directory to a container path as writable.
|
|
62
|
+
If CONTAINER_PATH is omitted, the resolved absolute host path is used.
|
|
63
|
+
Relative HOST_PATH is resolved from the current working directory.
|
|
64
|
+
Relative CONTAINER_PATH is resolved from the working directory
|
|
65
|
+
inside the container.
|
|
58
66
|
|
|
59
67
|
--publish [HOST_ADDRESS:]HOST_PORT:CONTAINER_PORT
|
|
60
68
|
Publish container port(s) to the host.
|
|
@@ -384,12 +392,22 @@ main() {
|
|
|
384
392
|
for mount in "${readonly_mounts[@]}"; do
|
|
385
393
|
local host_path
|
|
386
394
|
local container_path
|
|
387
|
-
|
|
395
|
+
if grep -qE ':' <<< "$mount"; then
|
|
396
|
+
IFS=':' read -r host_path container_path <<< "$mount"
|
|
397
|
+
else
|
|
398
|
+
host_path="$mount"
|
|
399
|
+
container_path=""
|
|
400
|
+
fi
|
|
388
401
|
local host_abs_path="$host_path"
|
|
389
402
|
if ! grep -qE '^/' <<< "$host_abs_path"; then
|
|
390
403
|
# shellcheck disable=SC2001
|
|
391
404
|
host_abs_path=$(readlink -f "$(sed "s,~/,$HOME/," <<< "$host_abs_path")")
|
|
392
405
|
fi
|
|
406
|
+
if test -z "$container_path"; then
|
|
407
|
+
container_path="$host_abs_path"
|
|
408
|
+
elif ! grep -qE '^/' <<< "$container_path"; then
|
|
409
|
+
container_path="${container_workdir}/${container_path}"
|
|
410
|
+
fi
|
|
393
411
|
docker_run_opts+=(--mount "type=bind,source=${host_abs_path},target=${container_path},readonly,consistency=delegated")
|
|
394
412
|
done
|
|
395
413
|
fi
|
|
@@ -398,12 +416,22 @@ main() {
|
|
|
398
416
|
for mount in "${writable_mounts[@]}"; do
|
|
399
417
|
local host_path
|
|
400
418
|
local container_path
|
|
401
|
-
|
|
419
|
+
if grep -qE ':' <<< "$mount"; then
|
|
420
|
+
IFS=':' read -r host_path container_path <<< "$mount"
|
|
421
|
+
else
|
|
422
|
+
host_path="$mount"
|
|
423
|
+
container_path=""
|
|
424
|
+
fi
|
|
402
425
|
local host_abs_path="$host_path"
|
|
403
426
|
if ! grep -qE '^/' <<< "$host_abs_path"; then
|
|
404
427
|
# shellcheck disable=SC2001
|
|
405
428
|
host_abs_path=$(readlink -f "$(sed "s,~/,$HOME/," <<< "$host_abs_path")")
|
|
406
429
|
fi
|
|
430
|
+
if test -z "$container_path"; then
|
|
431
|
+
container_path="$host_abs_path"
|
|
432
|
+
elif ! grep -qE '^/' <<< "$container_path"; then
|
|
433
|
+
container_path="${container_workdir}/${container_path}"
|
|
434
|
+
fi
|
|
407
435
|
docker_run_opts+=(--mount "type=bind,source=${host_abs_path},target=${container_path},consistency=delegated")
|
|
408
436
|
done
|
|
409
437
|
fi
|
package/src/cli/interactive.mjs
CHANGED
|
@@ -391,6 +391,8 @@ export function startInteractiveSession({
|
|
|
391
391
|
|
|
392
392
|
// Handle readline close (e.g., stdin closed externally)
|
|
393
393
|
cli.on("close", handleExit);
|
|
394
|
+
process.on("SIGTERM", handleExit);
|
|
395
|
+
process.on("SIGHUP", handleExit);
|
|
394
396
|
|
|
395
397
|
const handleCommand = createCommandHandler({
|
|
396
398
|
agentCommands,
|
package/src/main.mjs
CHANGED
|
@@ -165,6 +165,7 @@ export async function main(argv = process.argv) {
|
|
|
165
165
|
const sandboxStr = [
|
|
166
166
|
appConfig.sandbox.command,
|
|
167
167
|
...(appConfig.sandbox.args || []),
|
|
168
|
+
...(appConfig.sandbox.separator ? [appConfig.sandbox.separator] : []),
|
|
168
169
|
].join(" ");
|
|
169
170
|
console.log(styleText("green", "\n📦 Sandbox: on"));
|
|
170
171
|
console.log(` ⤷ ${sandboxStr}`);
|