@iinm/plain-agent 1.10.20 → 1.10.22

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": [
@@ -361,7 +360,7 @@ Files are loaded in the following order. Settings in later files override earlie
361
360
  "action": "allow"
362
361
  },
363
362
  {
364
- "toolName": { "$regex": "^(exec_command|tmux_command)$" },
363
+ "toolName": "exec_command",
365
364
  "action": "allow"
366
365
  },
367
366
  {
@@ -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/" } },
@@ -438,8 +426,17 @@ Files are loaded in the following order. Settings in later files override earlie
438
426
  ]
439
427
  },
440
428
 
429
+ "tools": {
430
+ // Enable web tools. See Quick Start section.
431
+ "webSearch": {},
432
+ "webFetch": {},
433
+ // Enable the tmux tool
434
+ "tmux": { "enabled": true }
435
+ },
436
+
441
437
  // Sandbox environment for the exec_command and tmux_command tools
442
438
  "sandbox": {
439
+ // Commands are wrapped and executed with this command
443
440
  "command": "plain-sandbox",
444
441
  "args": ["--allow-write", "--skip-build", "--keep-alive", "30"],
445
442
  // separator is inserted between sandbox flags and the user command to prevent bypasses
@@ -457,16 +454,15 @@ Files are loaded in the following order. Settings in later files override earlie
457
454
  {
458
455
  "pattern": {
459
456
  "command": "npm",
460
- "args": ["install"]
457
+ "args": [{ "$regex": "^(install|ci)$" }]
461
458
  },
462
459
  "mode": "sandbox",
463
- // Allow access to registry.npmjs.org
464
460
  "additionalArgs": ["--allow-net", "registry.npmjs.org"]
465
461
  }
466
462
  ]
467
463
  },
468
464
 
469
- // Configure MCP servers
465
+ // MCP servers
470
466
  "mcpServers": {
471
467
  "chrome_devtools": {
472
468
  "command": "npx",
@@ -484,27 +480,11 @@ Files are loaded in the following order. Settings in later files override earlie
484
480
  // Enable only specific tools. If not specified, all tools are enabled.
485
481
  "enabledTools": ["notion-search", "notion-fetch"]
486
482
  }
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
483
  }
497
484
  },
498
485
 
499
486
  // Override the default notification command
500
487
  "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
488
  }
509
489
  ```
510
490
  </details>
@@ -517,7 +497,7 @@ The agent can use the following tools:
517
497
  - **write_file**: Write a file.
518
498
  - **patch_file**: Patch a file.
519
499
  - **exec_command**: Run a command without shell interpretation.
520
- - **tmux_command**: Run a tmux command.
500
+ - **tmux_command**: Run a tmux command. It is disabled by default.
521
501
  - **web_search**: Search the web with one or more keyword sets and answer a question based on the combined results (requires Google API key, Vertex AI configuration, or the `command` provider with a local search command).
522
502
  - **web_fetch**: Fetch the contents of a single URL and answer a question based on it (requires Google API key, Vertex AI configuration, or the `command` provider with a local fetch command such as `w3m`, `curl`, or `lynx`).
523
503
  - **switch_to_subagent**: Switch to a subagent role within the same conversation, focusing on the specified goal.
@@ -9,6 +9,15 @@
9
9
  "action": "deny",
10
10
  "reason": "Use rg or fd instead"
11
11
  },
12
+ {
13
+ "toolName": "exec_command",
14
+ "input": {
15
+ "command": "bash",
16
+ "args": ["-c", { "$not": { "$regex": "[|><&;$`]" } }]
17
+ },
18
+ "action": "deny",
19
+ "reason": "Use bash -c only when shell features (|, >, <, &, ;, $, `) are required"
20
+ },
12
21
  {
13
22
  "toolName": "exec_command",
14
23
  "input": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.10.20",
3
+ "version": "1.10.22",
4
4
  "description": "A lightweight coding agent for the terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/config.d.ts CHANGED
@@ -76,11 +76,14 @@ 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?: {
82
84
  webSearch?: WebSearchToolConfig;
83
85
  webFetch?: WebFetchToolConfig;
86
+ tmux?: { enabled: boolean };
84
87
  };
85
88
  mcpServers?: Record<string, MCPServerConfig>;
86
89
  notifyCmd?: { command: string; args?: string[] };
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: {
@@ -92,6 +95,12 @@ export async function loadAppConfig(options = {}) {
92
95
  ...config.tools.webFetch,
93
96
  }
94
97
  : merged.tools?.webFetch,
98
+ tmux: config.tools?.tmux
99
+ ? {
100
+ ...(merged.tools?.tmux ?? {}),
101
+ ...config.tools.tmux,
102
+ }
103
+ : merged.tools?.tmux,
95
104
  },
96
105
  mcpServers: {
97
106
  ...(merged.mcpServers ?? {}),
package/src/main.mjs CHANGED
@@ -139,8 +139,6 @@ export async function main(argv = process.argv) {
139
139
  ? new Date(resumedState.startTime)
140
140
  : new Date();
141
141
  const sessionId = resumedState ? resumedState.sessionId : generateSessionId();
142
- const tmuxSessionId = `agent-${sessionId}`;
143
-
144
142
  const isBatchMode = cliArgs.subcommand.type === "batch";
145
143
  /** @type {string[]} */
146
144
  const configFiles =
@@ -294,7 +292,6 @@ export async function main(argv = process.argv) {
294
292
  workingDir: process.cwd(),
295
293
  today: new Date().toISOString().split("T")[0],
296
294
  sessionId,
297
- tmuxSessionId,
298
295
  projectMetadataDir: AGENT_PROJECT_METADATA_DIR,
299
296
  agentRoles,
300
297
  skills: Array.from(prompts.values()).filter((p) => p.isSkill),
@@ -305,12 +302,15 @@ export async function main(argv = process.argv) {
305
302
  readFileTool,
306
303
  writeFileTool,
307
304
  createPatchFileTool(),
308
- createTmuxCommandTool({ sandbox: appConfig.sandbox }),
309
305
  createCompactContextTool(),
310
306
  createSwitchToSubagentTool(),
311
307
  createSwitchToMainAgentTool(),
312
308
  ];
313
309
 
310
+ if (appConfig.tools?.tmux?.enabled) {
311
+ builtinTools.push(createTmuxCommandTool({ sandbox: appConfig.sandbox }));
312
+ }
313
+
314
314
  if (appConfig.tools?.webSearch) {
315
315
  const webSearchConfig = appConfig.tools.webSearch;
316
316
  if (webSearchConfig.provider === "command") {
@@ -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
@@ -7,7 +7,6 @@ import { toOneLine } from "./utils/toOneLine.mjs";
7
7
  * @property {string} workingDir - The current working directory.
8
8
  * @property {string} today - Today's date in YYYY-MM-DD format.
9
9
  * @property {string} sessionId
10
- * @property {string} tmuxSessionId
11
10
  * @property {string} projectMetadataDir - The directory where memory files are stored.
12
11
  * @property {Map<string, import('./context/loadAgentRoles.mjs').AgentRole>} agentRoles - Available agent roles.
13
12
  * @property {{filePath: string, description: string}[]} skills
@@ -22,7 +21,6 @@ export function createPrompt({
22
21
  modelName,
23
22
  sessionId,
24
23
  today,
25
- tmuxSessionId,
26
24
  workingDir,
27
25
  projectMetadataDir,
28
26
  agentRoles,
@@ -73,9 +71,8 @@ Always read the target lines with \`read_file\` first to verify line numbers and
73
71
 
74
72
  ## exec_command
75
73
 
76
- - Use relative paths.
74
+ - Use relative paths for files inside the working directory, absolute paths for files outside.
77
75
  - Use ${projectMetadataDir}/tmp/ for temporary files.
78
- - Use bash -c only when pipes (|) or redirection (>, <) are required.
79
76
 
80
77
  Examples:
81
78
  - List directories or find files: fd [".", "./", "--max-depth", "3", "--type", "d", "--hidden"]
@@ -84,18 +81,6 @@ Examples:
84
81
  Get PR details: gh ["pr", "view", "123", "--json", "title,body,url"]
85
82
  Get PR comment: gh ["api", "--method", "GET", "repos/<owner>/<repo>/pulls/comments/<id>", "--jq", "{user: .user.login, path: .path, line: .line, body: .body}"]
86
83
 
87
- ## tmux_command
88
-
89
- - Use only when the user explicitly requests it.
90
- - Create a new session with the given tmux session id.
91
-
92
- Examples:
93
- - Start session: new-session ["-d", "-s", "<tmux-session-id>"]
94
- - Detect window number to send keys: list-windows ["-t", "<tmux-session-id>"]
95
- - Get output of window before sending keys: capture-pane ["-p", "-t", "<tmux-session-id>:<window>"]
96
- - Send key to session: send-keys ["-t", "<tmux-session-id>:<window>", "echo hello", "Enter"]
97
- - Delete line: send-keys ["-t", "<tmux-session-id>:<window>", "C-a", "C-k"]
98
-
99
84
  # Project Rules and Skills
100
85
 
101
86
  Discover and apply project-specific rules and reusable skills.
@@ -119,7 +104,6 @@ ${skillDescriptions}
119
104
  - Current working directory: ${workingDir}
120
105
  - Today's date: ${today}
121
106
  - Session id: ${sessionId}
122
- - Tmux session id: ${tmuxSessionId}
123
107
  - Memory file path: ${projectMetadataDir}/memory/${sessionId}--<kebab-case-title>.md
124
108
 
125
109
  Available subagents:
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" }
@@ -18,7 +18,17 @@ export function createTmuxCommandTool(config) {
18
18
  return {
19
19
  def: {
20
20
  name: "tmux_command",
21
- description: "Run a tmux command",
21
+ description: [
22
+ "Run a tmux command.",
23
+ "The tmux session id is plain-agent-<session-id>.",
24
+ "",
25
+ "Examples:",
26
+ '- Start session: new-session ["-d", "-s", "<tmux-session-id>"]',
27
+ '- Detect window number to send keys: list-windows ["-t", "<tmux-session-id>"]',
28
+ '- Get output of window before sending keys: capture-pane ["-p", "-t", "<tmux-session-id>:<window>"]',
29
+ '- Send key to session: send-keys ["-t", "<tmux-session-id>:<window>", "echo hello", "Enter"]',
30
+ '- Delete line: send-keys ["-t", "<tmux-session-id>:<window>", "C-a", "C-k"]',
31
+ ].join("\n"),
22
32
  inputSchema: {
23
33
  type: "object",
24
34
  properties: {
@@ -40,6 +40,23 @@ export function evalJSONConfig(configItem) {
40
40
  );
41
41
  }
42
42
 
43
+ if (Object.keys(configItem).length === 1 && "$not" in configItem) {
44
+ const pattern = evalJSONConfig(configItem.$not);
45
+ /** @param {unknown} value */
46
+ return (value) => {
47
+ if (typeof pattern === "string") {
48
+ return value !== pattern;
49
+ }
50
+ if (pattern instanceof RegExp) {
51
+ return typeof value !== "string" || !pattern.test(value);
52
+ }
53
+ if (typeof pattern === "function") {
54
+ return !pattern(value);
55
+ }
56
+ return true;
57
+ };
58
+ }
59
+
43
60
  if (Object.keys(configItem).length === 1 && "$has" in configItem) {
44
61
  const pattern = evalJSONConfig(configItem.$has);
45
62
  /** @param {unknown} value */