@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 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": ["/tmp"],
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
- // Configure MCP servers
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-7",
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-7",
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-7",
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-7",
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-7",
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-7",
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-7",
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-7",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.10.19",
3
+ "version": "1.10.21",
4
4
  "description": "A lightweight coding agent for the terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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(input, allowedPaths = []) {
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) => isSafeToolInput(item, allowedPaths));
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(arg, allowedPaths = []) {
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
- // User-configured allowed paths (outside working directory)
88
- if (isInUserAllowedPath(realPath, allowedPaths)) {
89
- return true;
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
- // Disallow paths outside the working directory (not in allowedPaths)
93
- if (!isInsideWorkingDirectory(realPath, workingDir)) {
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
- // Deny git ignored files (which may contain sensitive information or should not be accessed)
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 isGitIgnored(absPath) {
237
+ function isGitManaged(absPath) {
238
+ const dir = findExistingDirectory(absPath);
239
+
240
+ /** @type {string} */
241
+ let gitRoot;
223
242
  try {
224
- execFileSync("git", ["check-ignore", "--no-index", "-q", absPath], {
225
- stdio: ["ignore", "ignore", "ignore"],
226
- });
227
- // The path is ignored (exit code 0)
228
- return true;
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
- // Path is not ignored
237
- return false;
267
+ // Not ignored: managed
268
+ return true;
238
269
  }
239
- // Other errors (e.g., status 128 if not a git repo or other git error)
240
- // We treat this as "effectively ignored" to be safe.
241
- return true;
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
  }
@@ -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 (isSafeToolInput(maskedInput, allowedPaths)) {
69
+ if (
70
+ isSafeToolInput(maskedInput, allowedPaths, allowGitUnmanagedFiles)
71
+ ) {
69
72
  state.approvalCount += 1;
70
73
  return state.approvalCount <= max
71
74
  ? { action: "allow" }