@iinm/plain-agent 1.10.19 → 1.10.21
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 +10 -38
- package/config/config.predefined.json +8 -8
- package/package.json +1 -1
- package/sandbox/bin/plain-sandbox +0 -2
- package/src/config.d.ts +2 -0
- package/src/config.mjs +3 -0
- package/src/main.mjs +2 -0
- package/src/prompt.mjs +1 -1
- package/src/tool.d.ts +2 -0
- package/src/toolInputValidator.mjs +71 -23
- package/src/toolUseApprover.mjs +4 -1
package/README.md
CHANGED
|
@@ -345,14 +345,13 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
345
345
|
└── agents/ # Project-specific agent roles
|
|
346
346
|
```
|
|
347
347
|
|
|
348
|
-
### Example
|
|
349
|
-
|
|
350
348
|
<details>
|
|
351
349
|
<summary><b>YOLO mode example (requires a sandbox for safety)</b></summary>
|
|
352
350
|
|
|
353
351
|
```js
|
|
354
352
|
{
|
|
355
353
|
"autoApproval": {
|
|
354
|
+
// Deny all actions except explicitly allowed
|
|
356
355
|
"defaultAction": "deny",
|
|
357
356
|
"maxApprovals": 100,
|
|
358
357
|
"patterns": [
|
|
@@ -378,17 +377,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
378
377
|
"sandbox": {
|
|
379
378
|
"command": "plain-sandbox",
|
|
380
379
|
"args": ["--allow-write", "--skip-build", "--keep-alive", "30"],
|
|
381
|
-
"separator": "--"
|
|
382
|
-
"rules": [
|
|
383
|
-
{
|
|
384
|
-
"pattern": {
|
|
385
|
-
"command": "npm",
|
|
386
|
-
"args": ["ci"]
|
|
387
|
-
},
|
|
388
|
-
"mode": "sandbox",
|
|
389
|
-
"additionalArgs": ["--allow-net", "0.0.0.0/0"]
|
|
390
|
-
}
|
|
391
|
-
]
|
|
380
|
+
"separator": "--"
|
|
392
381
|
}
|
|
393
382
|
}
|
|
394
383
|
```
|
|
@@ -401,17 +390,16 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
401
390
|
{
|
|
402
391
|
"autoApproval": {
|
|
403
392
|
// Absolute paths outside the working directory that are allowed. Relative paths are ignored.
|
|
404
|
-
"allowedPaths": ["/
|
|
393
|
+
"allowedPaths": ["/path/to/other/git-repo"],
|
|
394
|
+
// Allow access to git-unmanaged files (default: false).
|
|
395
|
+
// ⚠️ Changes to git-unmanaged files are hard to detect (e.g., node_modules). Sandbox is recommended.
|
|
396
|
+
"allowGitUnmanagedFiles": false,
|
|
397
|
+
// Default action when no patterns match. Can be "ask" (prompt user) or "deny" (block action).
|
|
405
398
|
"defaultAction": "ask",
|
|
406
399
|
// Maximum number of automatic approvals.
|
|
407
400
|
"maxApprovals": 50,
|
|
408
401
|
// Patterns are evaluated in order. First match wins.
|
|
409
402
|
"patterns": [
|
|
410
|
-
{
|
|
411
|
-
"toolName": { "$regex": "^(write_file|patch_file)$" },
|
|
412
|
-
"input": { "filePath": { "$regex": "^\\.plain-agent/memory/.+\\.md$" } },
|
|
413
|
-
"action": "allow"
|
|
414
|
-
},
|
|
415
403
|
{
|
|
416
404
|
"toolName": { "$regex": "^(write_file|patch_file)$" },
|
|
417
405
|
"input": { "filePath": { "$regex": "^src/" } },
|
|
@@ -440,6 +428,7 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
440
428
|
|
|
441
429
|
// Sandbox environment for the exec_command and tmux_command tools
|
|
442
430
|
"sandbox": {
|
|
431
|
+
// Commands are wrapped and executed with this command
|
|
443
432
|
"command": "plain-sandbox",
|
|
444
433
|
"args": ["--allow-write", "--skip-build", "--keep-alive", "30"],
|
|
445
434
|
// separator is inserted between sandbox flags and the user command to prevent bypasses
|
|
@@ -457,16 +446,15 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
457
446
|
{
|
|
458
447
|
"pattern": {
|
|
459
448
|
"command": "npm",
|
|
460
|
-
"args": ["install"]
|
|
449
|
+
"args": [{ "$regex": "^(install|ci)$" }]
|
|
461
450
|
},
|
|
462
451
|
"mode": "sandbox",
|
|
463
|
-
// Allow access to registry.npmjs.org
|
|
464
452
|
"additionalArgs": ["--allow-net", "registry.npmjs.org"]
|
|
465
453
|
}
|
|
466
454
|
]
|
|
467
455
|
},
|
|
468
456
|
|
|
469
|
-
//
|
|
457
|
+
// MCP servers
|
|
470
458
|
"mcpServers": {
|
|
471
459
|
"chrome_devtools": {
|
|
472
460
|
"command": "npx",
|
|
@@ -484,27 +472,11 @@ Files are loaded in the following order. Settings in later files override earlie
|
|
|
484
472
|
// Enable only specific tools. If not specified, all tools are enabled.
|
|
485
473
|
"enabledTools": ["notion-search", "notion-fetch"]
|
|
486
474
|
}
|
|
487
|
-
},
|
|
488
|
-
"aws_knowledge": {
|
|
489
|
-
"command": "npx",
|
|
490
|
-
"args": ["-y", "mcp-remote", "https://knowledge-mcp.global.api.aws"]
|
|
491
|
-
},
|
|
492
|
-
// ⚠️ Add this to config.local.json to avoid committing secrets to Git
|
|
493
|
-
"google_developer-knowledge": {
|
|
494
|
-
"command": "npx",
|
|
495
|
-
"args": ["-y", "mcp-remote", "https://developerknowledge.googleapis.com/mcp", "--header", "X-Goog-Api-Key:<GOOGLE_API_KEY>"]
|
|
496
475
|
}
|
|
497
476
|
},
|
|
498
477
|
|
|
499
478
|
// Override the default notification command
|
|
500
479
|
"notifyCmd": { "command": "plain-notify-desktop", "args": [] }
|
|
501
|
-
|
|
502
|
-
// Voice input. See "Voice Input" below.
|
|
503
|
-
// ⚠️ Add this to config.local.json to avoid committing secrets to Git
|
|
504
|
-
"voiceInput": {
|
|
505
|
-
"provider": "openai",
|
|
506
|
-
"apiKey": "<OPENAI_API_KEY>"
|
|
507
|
-
}
|
|
508
480
|
}
|
|
509
481
|
```
|
|
510
482
|
</details>
|
|
@@ -257,7 +257,7 @@
|
|
|
257
257
|
}
|
|
258
258
|
},
|
|
259
259
|
{
|
|
260
|
-
"name": "claude-opus-4-
|
|
260
|
+
"name": "claude-opus-4-8",
|
|
261
261
|
"variant": "thinking-high",
|
|
262
262
|
"platform": {
|
|
263
263
|
"name": "anthropic",
|
|
@@ -267,7 +267,7 @@
|
|
|
267
267
|
"model": {
|
|
268
268
|
"format": "anthropic",
|
|
269
269
|
"config": {
|
|
270
|
-
"model": "claude-opus-4-
|
|
270
|
+
"model": "claude-opus-4-8",
|
|
271
271
|
"max_tokens": 32768,
|
|
272
272
|
"thinking": { "type": "adaptive" },
|
|
273
273
|
"output_config": { "effort": "high" }
|
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
}
|
|
286
286
|
},
|
|
287
287
|
{
|
|
288
|
-
"name": "claude-opus-4-
|
|
288
|
+
"name": "claude-opus-4-8",
|
|
289
289
|
"variant": "thinking-max",
|
|
290
290
|
"platform": {
|
|
291
291
|
"name": "anthropic",
|
|
@@ -295,7 +295,7 @@
|
|
|
295
295
|
"model": {
|
|
296
296
|
"format": "anthropic",
|
|
297
297
|
"config": {
|
|
298
|
-
"model": "claude-opus-4-
|
|
298
|
+
"model": "claude-opus-4-8",
|
|
299
299
|
"max_tokens": 64000,
|
|
300
300
|
"thinking": { "type": "adaptive" },
|
|
301
301
|
"output_config": { "effort": "max" }
|
|
@@ -420,7 +420,7 @@
|
|
|
420
420
|
}
|
|
421
421
|
},
|
|
422
422
|
{
|
|
423
|
-
"name": "claude-opus-4-
|
|
423
|
+
"name": "claude-opus-4-8",
|
|
424
424
|
"variant": "thinking-high-bedrock",
|
|
425
425
|
"platform": {
|
|
426
426
|
"name": "bedrock",
|
|
@@ -429,7 +429,7 @@
|
|
|
429
429
|
"model": {
|
|
430
430
|
"format": "anthropic",
|
|
431
431
|
"config": {
|
|
432
|
-
"model": "global.anthropic.claude-opus-4-
|
|
432
|
+
"model": "global.anthropic.claude-opus-4-8",
|
|
433
433
|
"max_tokens": 32768,
|
|
434
434
|
"thinking": { "type": "adaptive" },
|
|
435
435
|
"output_config": { "effort": "high" }
|
|
@@ -447,7 +447,7 @@
|
|
|
447
447
|
}
|
|
448
448
|
},
|
|
449
449
|
{
|
|
450
|
-
"name": "claude-opus-4-
|
|
450
|
+
"name": "claude-opus-4-8",
|
|
451
451
|
"variant": "thinking-max-bedrock",
|
|
452
452
|
"platform": {
|
|
453
453
|
"name": "bedrock",
|
|
@@ -456,7 +456,7 @@
|
|
|
456
456
|
"model": {
|
|
457
457
|
"format": "anthropic",
|
|
458
458
|
"config": {
|
|
459
|
-
"model": "global.anthropic.claude-opus-4-
|
|
459
|
+
"model": "global.anthropic.claude-opus-4-8",
|
|
460
460
|
"max_tokens": 64000,
|
|
461
461
|
"thinking": { "type": "adaptive" },
|
|
462
462
|
"output_config": { "effort": "max" }
|
package/package.json
CHANGED
|
@@ -890,8 +890,6 @@ test -L ~/.zsh_history || (touch /persistent/home/.zsh_history; ln -s /persisten
|
|
|
890
890
|
test -L ~/.config || (mkdir -p /persistent/home/.config; ln -s /persistent/home/.config ~/ 2> /dev/null)
|
|
891
891
|
test -L ~/.gitconfig || (touch /persistent/home/.gitconfig; ln -s /persistent/home/.gitconfig ~/ 2> /dev/null)
|
|
892
892
|
test -L ~/.local || (mkdir -p /persistent/home/.local/share; ln -s /persistent/home/.local ~/ 2> /dev/null)
|
|
893
|
-
test -L ~/.gemini || (mkdir -p /persistent/home/.gemini; ln -s /persistent/home/.gemini ~/ 2> /dev/null)
|
|
894
|
-
test -L ~/.codex || (mkdir -p /persistent/home/.codex; ln -s /persistent/home/.codex ~/ 2> /dev/null)
|
|
895
893
|
|
|
896
894
|
eval "$(mise activate bash)"
|
|
897
895
|
exec "$@"
|
package/src/config.d.ts
CHANGED
|
@@ -76,6 +76,8 @@ export type AppConfig = {
|
|
|
76
76
|
defaultAction?: "deny" | "ask";
|
|
77
77
|
/** Additional absolute paths to allow for auto-approval (outside working directory) */
|
|
78
78
|
allowedPaths?: string[];
|
|
79
|
+
/** Allow access to git-unmanaged files (default: false) */
|
|
80
|
+
allowGitUnmanagedFiles?: boolean;
|
|
79
81
|
};
|
|
80
82
|
sandbox?: ExecCommandSanboxConfig;
|
|
81
83
|
tools?: {
|
package/src/config.mjs
CHANGED
|
@@ -77,6 +77,9 @@ export async function loadAppConfig(options = {}) {
|
|
|
77
77
|
...(config.autoApproval?.allowedPaths ?? []),
|
|
78
78
|
...(merged.autoApproval?.allowedPaths ?? []),
|
|
79
79
|
],
|
|
80
|
+
allowGitUnmanagedFiles:
|
|
81
|
+
config.autoApproval?.allowGitUnmanagedFiles ??
|
|
82
|
+
merged.autoApproval?.allowGitUnmanagedFiles,
|
|
80
83
|
},
|
|
81
84
|
sandbox: config.sandbox ?? merged.sandbox,
|
|
82
85
|
tools: {
|
package/src/main.mjs
CHANGED
|
@@ -369,6 +369,8 @@ export async function main(argv = process.argv) {
|
|
|
369
369
|
defaultAction: appConfig.autoApproval?.defaultAction || "ask",
|
|
370
370
|
patterns: appConfig.autoApproval?.patterns || [],
|
|
371
371
|
allowedPaths: appConfig.autoApproval?.allowedPaths ?? [],
|
|
372
|
+
allowGitUnmanagedFiles:
|
|
373
|
+
appConfig.autoApproval?.allowGitUnmanagedFiles ?? false,
|
|
372
374
|
maskApprovalInput: (toolName, input) => {
|
|
373
375
|
for (const tool of builtinTools) {
|
|
374
376
|
if (tool.def.name === toolName && tool.maskApprovalInput) {
|
package/src/prompt.mjs
CHANGED
|
@@ -73,7 +73,7 @@ Always read the target lines with \`read_file\` first to verify line numbers and
|
|
|
73
73
|
|
|
74
74
|
## exec_command
|
|
75
75
|
|
|
76
|
-
- Use relative paths.
|
|
76
|
+
- Use relative paths for files inside the working directory, absolute paths for files outside.
|
|
77
77
|
- Use ${projectMetadataDir}/tmp/ for temporary files.
|
|
78
78
|
- Use bash -c only when pipes (|) or redirection (>, <) are required.
|
|
79
79
|
|
package/src/tool.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type ToolUseApproverConfig = {
|
|
|
39
39
|
defaultAction: "deny" | "ask";
|
|
40
40
|
/** Additional absolute paths to allow for auto-approval (outside working directory) */
|
|
41
41
|
allowedPaths?: string[];
|
|
42
|
+
/** Allow access to git-unmanaged files (default: false) */
|
|
43
|
+
allowGitUnmanagedFiles?: boolean;
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Mask the input before auto-approval checks and recording.
|
|
@@ -18,19 +18,26 @@ const BUILTIN_ALLOWED_PATHS = [
|
|
|
18
18
|
/**
|
|
19
19
|
* @param {unknown} input
|
|
20
20
|
* @param {string[]} [allowedPaths=[]] - Additional allowed paths (outside working directory)
|
|
21
|
+
* @param {boolean} [allowGitUnmanagedFiles=false] - Allow access to git-unmanaged files
|
|
21
22
|
* @returns {boolean}
|
|
22
23
|
*/
|
|
23
|
-
export function isSafeToolInput(
|
|
24
|
+
export function isSafeToolInput(
|
|
25
|
+
input,
|
|
26
|
+
allowedPaths = [],
|
|
27
|
+
allowGitUnmanagedFiles = false,
|
|
28
|
+
) {
|
|
24
29
|
if (["number", "boolean", "undefined"].includes(typeof input)) {
|
|
25
30
|
return true;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
if (typeof input === "string") {
|
|
29
|
-
return isSafeToolInputItem(input, allowedPaths);
|
|
34
|
+
return isSafeToolInputItem(input, allowedPaths, allowGitUnmanagedFiles);
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
if (Array.isArray(input)) {
|
|
33
|
-
return input.every((item) =>
|
|
38
|
+
return input.every((item) =>
|
|
39
|
+
isSafeToolInput(item, allowedPaths, allowGitUnmanagedFiles),
|
|
40
|
+
);
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
if (typeof input === "object") {
|
|
@@ -38,7 +45,7 @@ export function isSafeToolInput(input, allowedPaths = []) {
|
|
|
38
45
|
return true;
|
|
39
46
|
}
|
|
40
47
|
return Object.values(input).every((value) =>
|
|
41
|
-
isSafeToolInput(value, allowedPaths),
|
|
48
|
+
isSafeToolInput(value, allowedPaths, allowGitUnmanagedFiles),
|
|
42
49
|
);
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -48,9 +55,14 @@ export function isSafeToolInput(input, allowedPaths = []) {
|
|
|
48
55
|
/**
|
|
49
56
|
* @param {string} arg
|
|
50
57
|
* @param {string[]} [allowedPaths=[]] - Additional allowed paths (outside working directory)
|
|
58
|
+
* @param {boolean} [allowGitUnmanagedFiles=false] - Allow access to git-unmanaged files
|
|
51
59
|
* @returns {boolean}
|
|
52
60
|
*/
|
|
53
|
-
export function isSafeToolInputItem(
|
|
61
|
+
export function isSafeToolInputItem(
|
|
62
|
+
arg,
|
|
63
|
+
allowedPaths = [],
|
|
64
|
+
allowGitUnmanagedFiles = false,
|
|
65
|
+
) {
|
|
54
66
|
const workingDir = process.cwd();
|
|
55
67
|
|
|
56
68
|
// Note: An argument can be a command option (e.g., '-l').
|
|
@@ -84,18 +96,20 @@ export function isSafeToolInputItem(arg, allowedPaths = []) {
|
|
|
84
96
|
return false;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
//
|
|
88
|
-
if (
|
|
89
|
-
|
|
99
|
+
// Path must be inside the working directory or in user-configured allowed paths
|
|
100
|
+
if (
|
|
101
|
+
!isInsideWorkingDirectory(realPath, workingDir) &&
|
|
102
|
+
!isInUserAllowedPath(realPath, allowedPaths)
|
|
103
|
+
) {
|
|
104
|
+
return false;
|
|
90
105
|
}
|
|
91
106
|
|
|
92
|
-
//
|
|
93
|
-
if (!
|
|
107
|
+
// Deny git-unmanaged files (outside git repo or git-ignored)
|
|
108
|
+
if (!allowGitUnmanagedFiles && !isGitManaged(realPath)) {
|
|
94
109
|
return false;
|
|
95
110
|
}
|
|
96
111
|
|
|
97
|
-
|
|
98
|
-
return !isGitIgnored(realPath);
|
|
112
|
+
return true;
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
/**
|
|
@@ -216,16 +230,33 @@ function isInsideProjectMetadataDir(targetPath) {
|
|
|
216
230
|
}
|
|
217
231
|
|
|
218
232
|
/**
|
|
233
|
+
* Check if the path is managed by git (inside a git repo and not ignored).
|
|
219
234
|
* @param {string} absPath
|
|
220
235
|
* @returns {boolean}
|
|
221
236
|
*/
|
|
222
|
-
function
|
|
237
|
+
function isGitManaged(absPath) {
|
|
238
|
+
const dir = findExistingDirectory(absPath);
|
|
239
|
+
|
|
240
|
+
/** @type {string} */
|
|
241
|
+
let gitRoot;
|
|
223
242
|
try {
|
|
224
|
-
execFileSync("git", ["
|
|
225
|
-
stdio: ["ignore", "
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
243
|
+
gitRoot = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
|
|
244
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
245
|
+
encoding: "utf-8",
|
|
246
|
+
}).trim();
|
|
247
|
+
} catch {
|
|
248
|
+
// Not inside a git repository
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
execFileSync(
|
|
254
|
+
"git",
|
|
255
|
+
["-C", gitRoot, "check-ignore", "--no-index", "-q", absPath],
|
|
256
|
+
{ stdio: ["ignore", "ignore", "ignore"] },
|
|
257
|
+
);
|
|
258
|
+
// File is git-ignored: not managed
|
|
259
|
+
return false;
|
|
229
260
|
} catch (error) {
|
|
230
261
|
if (
|
|
231
262
|
error instanceof Error &&
|
|
@@ -233,11 +264,28 @@ function isGitIgnored(absPath) {
|
|
|
233
264
|
typeof error.status === "number" &&
|
|
234
265
|
error.status === 1
|
|
235
266
|
) {
|
|
236
|
-
//
|
|
237
|
-
return
|
|
267
|
+
// Not ignored: managed
|
|
268
|
+
return true;
|
|
238
269
|
}
|
|
239
|
-
// Other
|
|
240
|
-
|
|
241
|
-
|
|
270
|
+
// Other error: treat as not managed for safety
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @param {string} absPath
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
function findExistingDirectory(absPath) {
|
|
280
|
+
const stats = noThrowSync(() => fs.statSync(absPath));
|
|
281
|
+
if (!(stats instanceof Error)) {
|
|
282
|
+
return stats.isDirectory() ? absPath : path.dirname(absPath);
|
|
283
|
+
}
|
|
284
|
+
let dir = absPath;
|
|
285
|
+
while (!fs.existsSync(dir)) {
|
|
286
|
+
const parent = path.dirname(dir);
|
|
287
|
+
if (parent === dir) break;
|
|
288
|
+
dir = parent;
|
|
242
289
|
}
|
|
290
|
+
return dir;
|
|
243
291
|
}
|
package/src/toolUseApprover.mjs
CHANGED
|
@@ -16,6 +16,7 @@ export function createToolUseApprover({
|
|
|
16
16
|
defaultAction,
|
|
17
17
|
maskApprovalInput,
|
|
18
18
|
allowedPaths = [],
|
|
19
|
+
allowGitUnmanagedFiles = false,
|
|
19
20
|
}) {
|
|
20
21
|
const state = {
|
|
21
22
|
approvalCount: 0,
|
|
@@ -65,7 +66,9 @@ export function createToolUseApprover({
|
|
|
65
66
|
|
|
66
67
|
if (action === "allow") {
|
|
67
68
|
const maskedInput = maskApprovalInput(toolUse.toolName, toolUse.input);
|
|
68
|
-
if (
|
|
69
|
+
if (
|
|
70
|
+
isSafeToolInput(maskedInput, allowedPaths, allowGitUnmanagedFiles)
|
|
71
|
+
) {
|
|
69
72
|
state.approvalCount += 1;
|
|
70
73
|
return state.approvalCount <= max
|
|
71
74
|
? { action: "allow" }
|