@iinm/plain-agent 1.10.4 → 1.10.6

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
@@ -395,6 +395,8 @@ Files are loaded in the following order. Settings in later files override earlie
395
395
  ```js
396
396
  {
397
397
  "autoApproval": {
398
+ // Absolute paths outside the working directory to allow access to. Relative paths are ignored.
399
+ "allowedPaths": ["/tmp"],
398
400
  "defaultAction": "ask",
399
401
  // The maximum number of automatic approvals.
400
402
  "maxApprovals": 50,
@@ -125,6 +125,23 @@
125
125
  ]
126
126
  },
127
127
  "action": "allow"
128
+ },
129
+ {
130
+ "toolName": "exec_command",
131
+ "input": {
132
+ "command": "gh",
133
+ "args": ["api", "--method"]
134
+ },
135
+ "action": "ask"
136
+ },
137
+ {
138
+ "toolName": "exec_command",
139
+ "input": {
140
+ "command": "gh",
141
+ "args": ["api"]
142
+ },
143
+ "action": "deny",
144
+ "reason": "--method must be specified"
128
145
  }
129
146
  ]
130
147
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.10.4",
3
+ "version": "1.10.6",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -428,6 +428,21 @@ export async function printMessage(message) {
428
428
  }
429
429
  }
430
430
  }
431
+ /**
432
+ * Convert **bold** Markdown to ANSI bold terminal escape codes.
433
+ * Only matches when ** is preceded by whitespace or line start
434
+ * and followed by whitespace, line end, or punctuation — so inline
435
+ * code like `**bold**` is left untouched.
436
+ * @param {string} text
437
+ * @returns {string}
438
+ */
439
+ export function applyInlineMarkdown(text) {
440
+ return text.replace(
441
+ /(?<=\s|^)\*\*(.+?)\*\*(?=[\s.,;:!?)〕)】」』]|$)/g,
442
+ (_, c) => styleText("bold", c),
443
+ );
444
+ }
445
+
431
446
  /**
432
447
  * Format markdown table lines with aligned columns.
433
448
  * Input lines may have leading/trailing pipes.
@@ -22,7 +22,7 @@ import {
22
22
  import { createInterruptTransform } from "./interruptTransform.mjs";
23
23
  import { createMuteTransform } from "./muteTransform.mjs";
24
24
  import { createPasteHandler } from "./pasteTransform.mjs";
25
- import { createTableDetector } from "./tableDetector.mjs";
25
+ import { createStreamFormatter } from "./streamFormatter.mjs";
26
26
 
27
27
  const HELP_MESSAGE = [
28
28
  "Commands:",
@@ -112,21 +112,26 @@ export function startInteractiveSession({
112
112
  claudeCodePlugins,
113
113
  voiceInput,
114
114
  }) {
115
- /** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string }} */
115
+ /** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string, toolSpinnerIndex: number, toolSpinnerLastTime: number }} */
116
116
  const state = {
117
117
  turn: true,
118
118
  multiLineBuffer: null,
119
119
  subagentName: agentCommands.getActiveSubagent()?.name ?? "",
120
+ toolSpinnerIndex: 0,
121
+ toolSpinnerLastTime: 0,
120
122
  };
121
123
 
124
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
125
+ const SPINNER_INTERVAL_MS = 80;
126
+
122
127
  /**
123
128
  * Active voice input session, or null when not recording.
124
129
  * @type {{ session: VoiceSession, startCursor: number, transcriptLength: number } | null}
125
130
  */
126
131
  let voice = null;
127
132
 
128
- // Create the table buffer instance for this session
129
- const tableBuffer = createTableBuffer();
133
+ // Create the stream buffer instance for this session
134
+ const streamBuffer = createStreamBuffer();
130
135
 
131
136
  // Parse the voice toggle key once at startup so misconfiguration fails
132
137
  // loudly instead of silently falling back.
@@ -466,19 +471,44 @@ export function startInteractiveSession({
466
471
  ? styleText("cyan", `[${state.subagentName}]\n`)
467
472
  : "";
468
473
  const partialContentStr = styleText("gray", `<${partialContent.type}>`);
469
- console.log(`\n${subagentPrefix}${partialContentStr}`);
474
+
475
+ if (partialContent.type === "tool_use") {
476
+ state.toolSpinnerIndex = 0;
477
+ state.toolSpinnerLastTime = Date.now();
478
+ process.stdout.write(
479
+ `\n${subagentPrefix}${partialContentStr} ${styleText("cyan", SPINNER_FRAMES[0])}`,
480
+ );
481
+ } else {
482
+ console.log(`\n${subagentPrefix}${partialContentStr}`);
483
+ }
470
484
  }
471
485
  if (partialContent.content) {
472
486
  if (partialContent.type === "tool_use") {
473
- process.stdout.write(styleText("gray", partialContent.content));
487
+ const now = Date.now();
488
+ if (now - state.toolSpinnerLastTime >= SPINNER_INTERVAL_MS) {
489
+ state.toolSpinnerIndex =
490
+ (state.toolSpinnerIndex + 1) % SPINNER_FRAMES.length;
491
+ state.toolSpinnerLastTime = now;
492
+ process.stdout.write(
493
+ `\r\x1b[K${styleText("gray", `<${partialContent.type}>`)} ${styleText("cyan", SPINNER_FRAMES[state.toolSpinnerIndex])}`,
494
+ );
495
+ }
474
496
  } else if (partialContent.type === "text") {
475
- tableBuffer.feed(partialContent.content);
497
+ streamBuffer.feed(partialContent.content);
476
498
  } else {
477
499
  process.stdout.write(partialContent.content);
478
500
  }
479
501
  }
480
502
  if (partialContent.position === "stop") {
481
- console.log(styleText("gray", `\n</${partialContent.type}>`));
503
+ if (partialContent.type === "tool_use") {
504
+ process.stdout.write(
505
+ `\r\x1b[K${styleText("gray", `<${partialContent.type}>`)}\n`,
506
+ );
507
+ } else {
508
+ // Flush any buffered text before printing the closing tag
509
+ streamBuffer.forceFlush();
510
+ console.log(styleText("gray", `\n</${partialContent.type}>`));
511
+ }
482
512
  }
483
513
  });
484
514
 
@@ -528,8 +558,8 @@ export function startInteractiveSession({
528
558
  });
529
559
 
530
560
  agentEventEmitter.on("turnEnd", async () => {
531
- // Flush any remaining table buffer content
532
- tableBuffer.forceFlush();
561
+ // Flush any remaining stream buffer content
562
+ streamBuffer.forceFlush();
533
563
 
534
564
  const err = notify(notifyCmd);
535
565
  if (err) {
@@ -556,21 +586,20 @@ export function startInteractiveSession({
556
586
  }
557
587
 
558
588
  /**
559
- * Creates a table buffer for detecting and formatting markdown tables
560
- * in streaming text output.
561
- * Thin shell: delegates pure logic to createTableDetector and handles I/O.
589
+ * Creates a stream buffer for formatting streaming text output.
590
+ * Thin shell: delegates pure logic to createStreamFormatter and handles I/O.
562
591
  */
563
- function createTableBuffer() {
564
- const detector = createTableDetector();
592
+ function createStreamBuffer() {
593
+ const formatter = createStreamFormatter();
565
594
 
566
595
  function feed(/** @type {string} */ chunk) {
567
- const { output, warnings } = detector.feed(chunk);
596
+ const { output, warnings } = formatter.feed(chunk);
568
597
  for (const s of output) process.stdout.write(s);
569
598
  for (const w of warnings) console.error(styleText("yellow", w));
570
599
  }
571
600
 
572
601
  function forceFlush() {
573
- const { output, warnings } = detector.forceFlush();
602
+ const { output, warnings } = formatter.forceFlush();
574
603
  for (const s of output) process.stdout.write(s);
575
604
  for (const w of warnings) console.error(styleText("yellow", w));
576
605
  }
@@ -1,18 +1,24 @@
1
- import { formatMarkdownTable } from "./formatter.mjs";
1
+ import { applyInlineMarkdown, formatMarkdownTable } from "./formatter.mjs";
2
2
 
3
3
  /**
4
- * @typedef {{ output: string[], warnings: string[] }} DetectorResult
4
+ * @typedef {{ output: string[], warnings: string[] }} StreamFormatterResult
5
5
  */
6
6
 
7
7
  /**
8
- * Creates a table detector for detecting and formatting markdown tables
9
- * in streaming text output. This is a pure logic module with no I/O side effects.
8
+ * Creates a stream formatter for formatting streaming text output
9
+ * in a terminal. Applies **bold** Markdown styling
10
+ * on completed lines, and detects + formats markdown tables.
11
+ * This is a pure logic module with no I/O side effects.
12
+ *
13
+ * All output is deferred until line completion (\n or forceFlush),
14
+ * so inline Markdown patterns spanning chunk boundaries are handled
15
+ * correctly without special boundary-detection logic.
10
16
  *
11
17
  * @param {(lines: string[], maxWidth?: number) => string} [formatTable=formatMarkdownTable] - Table formatting function (injectable for testing)
12
18
  * @param {number} [maxWidth] - Maximum terminal display width (defaults to process.stdout.columns - 4 or 80)
13
- * @returns {{ feed: (chunk: string) => DetectorResult, forceFlush: () => DetectorResult }}
19
+ * @returns {{ feed: (chunk: string) => StreamFormatterResult, forceFlush: () => StreamFormatterResult }}
14
20
  */
15
- export function createTableDetector(
21
+ export function createStreamFormatter(
16
22
  formatTable = formatMarkdownTable,
17
23
  maxWidth = process.stdout.columns ? process.stdout.columns - 4 : 80,
18
24
  ) {
@@ -25,9 +31,9 @@ export function createTableDetector(
25
31
  const MAX_TABLE_LINES = 200;
26
32
 
27
33
  /**
28
- * Feed a text chunk to the detector.
34
+ * Feed a text chunk to the formatter.
29
35
  * @param {string} chunk
30
- * @returns {DetectorResult}
36
+ * @returns {StreamFormatterResult}
31
37
  */
32
38
  function feed(chunk) {
33
39
  if (chunk.length === 0) return { output: [], warnings: [] };
@@ -48,19 +54,12 @@ export function createTableDetector(
48
54
  warnings.push(...result.warnings);
49
55
  }
50
56
 
51
- // If not buffering a table and pendingLine has no pipe, output immediately
52
- // This ensures non-table text is streamed without delay
53
- if (tableLines.length === 0 && !pendingLine.includes("|")) {
54
- output.push(pendingLine);
55
- pendingLine = "";
56
- }
57
-
58
57
  return { output, warnings };
59
58
  }
60
59
 
61
60
  /**
62
61
  * Force flush any pending content (call on turn end).
63
- * @returns {DetectorResult}
62
+ * @returns {StreamFormatterResult}
64
63
  */
65
64
  function forceFlush() {
66
65
  /** @type {string[]} */
@@ -68,14 +67,11 @@ export function createTableDetector(
68
67
  /** @type {string[]} */
69
68
  const warnings = [];
70
69
 
71
- // Process any remaining pending line
70
+ // Process any remaining pending line as a completed line
72
71
  if (pendingLine.length > 0) {
73
- // If we have a table buffer, add pending line to it or output directly
74
- if (tableLines.length > 0) {
75
- tableLines.push(`${pendingLine}\n`);
76
- } else {
77
- output.push(pendingLine);
78
- }
72
+ const result = processLine(pendingLine);
73
+ output.push(...result.output);
74
+ warnings.push(...result.warnings);
79
75
  pendingLine = "";
80
76
  }
81
77
  const flushResult = flushTable();
@@ -87,30 +83,33 @@ export function createTableDetector(
87
83
 
88
84
  /**
89
85
  * Process a complete line.
90
- * @param {string} line - Line including trailing newline
91
- * @returns {DetectorResult}
86
+ * @param {string} rawLine - Line (may or may not include trailing newline)
87
+ * @returns {StreamFormatterResult}
92
88
  */
93
- function processLine(line) {
89
+ function processLine(rawLine) {
94
90
  /** @type {string[]} */
95
91
  const output = [];
96
92
  /** @type {string[]} */
97
93
  const warnings = [];
98
94
 
99
- // Code block detection
100
- if (line.trimStart().startsWith("```")) {
95
+ // Code block detection (before Markdown conversion — code blocks stay raw)
96
+ if (rawLine.trimStart().startsWith("```")) {
101
97
  inCodeBlock = !inCodeBlock;
102
98
  const flushResult = flushTable(); // Code block terminates any ongoing table
103
99
  output.push(...flushResult.output);
104
100
  warnings.push(...flushResult.warnings);
105
- output.push(line);
101
+ output.push(rawLine);
106
102
  return { output, warnings };
107
103
  }
108
104
 
109
105
  if (inCodeBlock) {
110
- output.push(line);
106
+ output.push(rawLine);
111
107
  return { output, warnings };
112
108
  }
113
109
 
110
+ // Apply inline Markdown styling on completed lines
111
+ const line = applyInlineMarkdown(rawLine);
112
+
114
113
  // Table start: line begins with pipe
115
114
  if (isTableStart(line)) {
116
115
  tableLines.push(line);
@@ -145,7 +144,7 @@ export function createTableDetector(
145
144
 
146
145
  /**
147
146
  * Flush table buffer with formatting.
148
- * @returns {DetectorResult}
147
+ * @returns {StreamFormatterResult}
149
148
  */
150
149
  function flushTable() {
151
150
  if (tableLines.length === 0) return { output: [], warnings: [] };
@@ -193,7 +192,7 @@ export function createTableDetector(
193
192
 
194
193
  /**
195
194
  * Flush table buffer without formatting (for oversized tables).
196
- * @returns {DetectorResult}
195
+ * @returns {StreamFormatterResult}
197
196
  */
198
197
  function flushTableAsIs() {
199
198
  if (tableLines.length === 0) return { output: [], warnings: [] };
package/src/config.d.ts CHANGED
@@ -74,6 +74,8 @@ export type AppConfig = {
74
74
  patterns?: ToolUsePattern[];
75
75
  maxApprovals?: number;
76
76
  defaultAction?: "deny" | "ask";
77
+ /** Additional absolute paths to allow for auto-approval (outside working directory) */
78
+ allowedPaths?: string[];
77
79
  };
78
80
  sandbox?: ExecCommandSanboxConfig;
79
81
  tools?: {
package/src/config.mjs CHANGED
@@ -73,6 +73,10 @@ export async function loadAppConfig(options = {}) {
73
73
  maxApprovals:
74
74
  config.autoApproval?.maxApprovals ??
75
75
  merged.autoApproval?.maxApprovals,
76
+ allowedPaths: [
77
+ ...(config.autoApproval?.allowedPaths ?? []),
78
+ ...(merged.autoApproval?.allowedPaths ?? []),
79
+ ],
76
80
  },
77
81
  sandbox: config.sandbox ?? merged.sandbox,
78
82
  tools: {
package/src/main.mjs CHANGED
@@ -363,6 +363,7 @@ if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
363
363
  maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
364
364
  defaultAction: appConfig.autoApproval?.defaultAction || "ask",
365
365
  patterns: appConfig.autoApproval?.patterns || [],
366
+ allowedPaths: appConfig.autoApproval?.allowedPaths ?? [],
366
367
  maskApprovalInput: (toolName, input) => {
367
368
  for (const tool of builtinTools) {
368
369
  if (tool.def.name === toolName && tool.maskApprovalInput) {
package/src/prompt.mjs CHANGED
@@ -45,14 +45,20 @@ export function createPrompt({
45
45
  .join("\n");
46
46
 
47
47
  return `
48
+ # Communication Style
49
+
50
+ - Respond in the user's language.
51
+ - Call the user by name, not "user".
52
+ - Use emojis sparingly to highlight key points.
53
+
48
54
  # Memory Files
49
55
 
50
- - Create/Update memory files after creating/updating a plan, completing milestones, encountering issues, or making decisions.
51
- - Update existing task memory when continuing the same task.
56
+ - Create/Update memory files when creating/updating a plan, completing milestones, encountering issues, or making decisions.
52
57
  - Ensure self-containment: The file must be standalone. A reader should fully understand the task context, logic and progress without any other references.
53
58
  - Write the memory content in the user's language.
54
59
 
55
60
  Memory files should include:
61
+ - Project discovery status: Whether AGENTS.md has been checked
56
62
  - Task overview: What the task is, why it's being done, requirements and constraints
57
63
  - Context: Relevant documentation, source files, commands, and resources referenced
58
64
  - Progress tracking: Completed milestones with evidence, current status, and next steps
@@ -67,7 +73,8 @@ Call multiple tools at once when they don't depend on each other's results.
67
73
  ## exec_command
68
74
 
69
75
  - Use relative paths.
70
- - Avoid bash -c unless pipes (|) or redirection (>, <) are required.
76
+ - Use ${projectMetadataDir}/tmp/ for temporary files.
77
+ - Use bash -c only when pipes (|) or redirection (>, <) are required.
71
78
 
72
79
  Examples:
73
80
  - List directories or find files: fd [".", "./", "--max-depth", "3", "--type", "d", "--hidden"]
@@ -78,7 +85,7 @@ Examples:
78
85
 
79
86
  ## tmux_command
80
87
 
81
- - Only use when the user explicitly requests it.
88
+ - Use only when the user explicitly requests it.
82
89
  - Create a new session with the given tmux session id.
83
90
 
84
91
  Examples:
package/src/tool.d.ts CHANGED
@@ -37,6 +37,8 @@ export type ToolUseApproverConfig = {
37
37
  patterns: ToolUsePattern[];
38
38
  maxApprovals: number;
39
39
  defaultAction: "deny" | "ask";
40
+ /** Additional absolute paths to allow for auto-approval (outside working directory) */
41
+ allowedPaths?: string[];
40
42
 
41
43
  /**
42
44
  * Mask the input before auto-approval checks and recording.
@@ -9,39 +9,37 @@ import {
9
9
  } from "./env.mjs";
10
10
  import { noThrowSync } from "./utils/noThrow.mjs";
11
11
 
12
- // Paths that must never be auto-approvable as tool input, even when
13
- // git-managed. Sandbox scripts run on the host and the project config files
14
- // drive auto-approval policy itself, so silent in-sandbox modification of
15
- // either could lead to host code execution or self-granted privilege
16
- // escalation.
17
- const UNSAFE_PROJECT_PATHS = [
18
- path.join(AGENT_PROJECT_METADATA_DIR, "sandbox"),
19
- path.join(AGENT_PROJECT_METADATA_DIR, "config.json"),
20
- path.join(AGENT_PROJECT_METADATA_DIR, "config.local.json"),
12
+ const BUILTIN_ALLOWED_PATHS = [
13
+ AGENT_MEMORY_DIR,
14
+ AGENT_TMP_DIR,
15
+ CLAUDE_CODE_PLUGIN_DIR,
21
16
  ];
22
17
 
23
18
  /**
24
19
  * @param {unknown} input
20
+ * @param {string[]} [allowedPaths=[]] - Additional allowed paths (outside working directory)
25
21
  * @returns {boolean}
26
22
  */
27
- export function isSafeToolInput(input) {
23
+ export function isSafeToolInput(input, allowedPaths = []) {
28
24
  if (["number", "boolean", "undefined"].includes(typeof input)) {
29
25
  return true;
30
26
  }
31
27
 
32
28
  if (typeof input === "string") {
33
- return isSafeToolInputItem(input);
29
+ return isSafeToolInputItem(input, allowedPaths);
34
30
  }
35
31
 
36
32
  if (Array.isArray(input)) {
37
- return input.every((item) => isSafeToolInput(item));
33
+ return input.every((item) => isSafeToolInput(item, allowedPaths));
38
34
  }
39
35
 
40
36
  if (typeof input === "object") {
41
37
  if (input === null) {
42
38
  return true;
43
39
  }
44
- return Object.values(input).every((value) => isSafeToolInput(value));
40
+ return Object.values(input).every((value) =>
41
+ isSafeToolInput(value, allowedPaths),
42
+ );
45
43
  }
46
44
 
47
45
  return false;
@@ -49,9 +47,10 @@ export function isSafeToolInput(input) {
49
47
 
50
48
  /**
51
49
  * @param {string} arg
50
+ * @param {string[]} [allowedPaths=[]] - Additional allowed paths (outside working directory)
52
51
  * @returns {boolean}
53
52
  */
54
- export function isSafeToolInputItem(arg) {
53
+ export function isSafeToolInputItem(arg, allowedPaths = []) {
55
54
  const workingDir = process.cwd();
56
55
 
57
56
  // Note: An argument can be a command option (e.g., '-l').
@@ -63,29 +62,38 @@ export function isSafeToolInputItem(arg) {
63
62
  return false;
64
63
  }
65
64
 
66
- // Disallow paths outside the working directory (WITHOUT EXCEPTION)
67
- if (!isInsideWorkingDirectory(realPath, workingDir)) {
68
- return false;
69
- }
70
-
71
65
  // Disallow any input that contains ".." as a path segment (directory traversal)
72
66
  // Example:
73
67
  // - When write_file is allowed for ^safe-dir/.+
74
68
  // - "safe-dir/../unsafe-path" should be disallowed
69
+ // This check must happen before allowedPaths check for security
75
70
  if (arg.split(path.sep).includes("..")) {
76
71
  return false;
77
72
  }
78
73
 
79
- // Always require approval for these, even if git-managed.
80
- if (isUnsafeProjectPath(realPath)) {
74
+ // Built-in allowed paths (memory, tmp, claude-code-plugins) are always safe.
75
+ // This check must come before the .plain-agent/ block below.
76
+ if (isInBuiltinAllowedPath(realPath)) {
77
+ return true;
78
+ }
79
+
80
+ // Any other path under .plain-agent/ is unsafe and cannot be overridden
81
+ // by allowedPaths. This prevents privilege escalation via sandbox scripts
82
+ // or config files even when explicitly listed in allowedPaths.
83
+ if (isInsideProjectMetadataDir(realPath)) {
81
84
  return false;
82
85
  }
83
86
 
84
- // Always allow these even if git-ignored.
85
- if (isSafePath(realPath)) {
87
+ // User-configured allowed paths (outside working directory)
88
+ if (isInUserAllowedPath(realPath, allowedPaths)) {
86
89
  return true;
87
90
  }
88
91
 
92
+ // Disallow paths outside the working directory (not in allowedPaths)
93
+ if (!isInsideWorkingDirectory(realPath, workingDir)) {
94
+ return false;
95
+ }
96
+
89
97
  // Deny git ignored files (which may contain sensitive information or should not be accessed)
90
98
  return !isGitIgnored(realPath);
91
99
  }
@@ -153,43 +161,60 @@ function isInsideWorkingDirectory(targetPath, workingDir) {
153
161
  }
154
162
 
155
163
  /**
156
- * @param {string} targetPath
164
+ * Check if the path is under a built-in allowed directory
165
+ * (.plain-agent/{memory,tmp,claude-code-plugins}).
166
+ * @param {string} targetPath - Must be an absolute path.
157
167
  * @returns {boolean}
158
168
  */
159
- function isSafePath(targetPath) {
160
- const safePaths = [AGENT_MEMORY_DIR, AGENT_TMP_DIR, CLAUDE_CODE_PLUGIN_DIR];
161
-
162
- for (const safePath of safePaths) {
163
- const safeAbsPath = path.resolve(safePath);
169
+ function isInBuiltinAllowedPath(targetPath) {
170
+ for (const builtinPath of BUILTIN_ALLOWED_PATHS) {
171
+ const absPath = path.resolve(builtinPath);
164
172
  if (
165
- targetPath === safeAbsPath ||
166
- targetPath.startsWith(`${safeAbsPath}${path.sep}`)
173
+ targetPath === absPath ||
174
+ targetPath.startsWith(`${absPath}${path.sep}`)
167
175
  ) {
168
176
  return true;
169
177
  }
170
178
  }
171
-
172
179
  return false;
173
180
  }
174
181
 
175
182
  /**
176
- * @param {string} targetPath
183
+ * Check if the path is under a user-configured allowed path.
184
+ * @param {string} targetPath - Must be an absolute path.
185
+ * @param {string[]} allowedPaths - Additional absolute paths (outside working directory)
177
186
  * @returns {boolean}
178
187
  */
179
- function isUnsafeProjectPath(targetPath) {
180
- for (const unsafePath of UNSAFE_PROJECT_PATHS) {
181
- const unsafeAbsPath = path.resolve(unsafePath);
188
+ function isInUserAllowedPath(targetPath, allowedPaths) {
189
+ // User-provided paths must be absolute; relative paths are silently skipped
190
+ // to prevent unintended access from CWD-dependent resolution.
191
+ for (const allowedPath of allowedPaths) {
192
+ if (!path.isAbsolute(allowedPath)) {
193
+ continue;
194
+ }
182
195
  if (
183
- targetPath === unsafeAbsPath ||
184
- targetPath.startsWith(`${unsafeAbsPath}${path.sep}`)
196
+ targetPath === allowedPath ||
197
+ targetPath.startsWith(`${allowedPath}${path.sep}`)
185
198
  ) {
186
199
  return true;
187
200
  }
188
201
  }
189
-
190
202
  return false;
191
203
  }
192
204
 
205
+ /**
206
+ * Check if the path is under .plain-agent/.
207
+ * @param {string} targetPath
208
+ * @returns {boolean}
209
+ */
210
+ function isInsideProjectMetadataDir(targetPath) {
211
+ const metadataAbsPath = path.resolve(AGENT_PROJECT_METADATA_DIR);
212
+ return (
213
+ targetPath === metadataAbsPath ||
214
+ targetPath.startsWith(`${metadataAbsPath}${path.sep}`)
215
+ );
216
+ }
217
+
193
218
  /**
194
219
  * @param {string} absPath
195
220
  * @returns {boolean}
@@ -15,6 +15,7 @@ export function createToolUseApprover({
15
15
  maxApprovals: max,
16
16
  defaultAction,
17
17
  maskApprovalInput,
18
+ allowedPaths = [],
18
19
  }) {
19
20
  const state = {
20
21
  approvalCount: 0,
@@ -64,7 +65,7 @@ export function createToolUseApprover({
64
65
 
65
66
  if (action === "allow") {
66
67
  const maskedInput = maskApprovalInput(toolUse.toolName, toolUse.input);
67
- if (isSafeToolInput(maskedInput)) {
68
+ if (isSafeToolInput(maskedInput, allowedPaths)) {
68
69
  state.approvalCount += 1;
69
70
  return state.approvalCount <= max
70
71
  ? { action: "allow" }
@@ -31,18 +31,26 @@ Format — a single patch string may contain multiple blocks:
31
31
  >>> ${nonce} {start}:{startHash}-{end}:{endHash}
32
32
  new content
33
33
  <<< ${nonce}
34
+
35
+ >>> ${nonce} {start}:{startHash}-{end}:{endHash}
36
+ another new content
37
+ <<< ${nonce}
38
+
34
39
  >>> ${nonce} {N}:{afterHash}+
35
- inserted content
40
+ appended content after line N
36
41
  <<< ${nonce}
42
+
37
43
  >>> ${nonce} 0+
38
44
  prepended content
39
45
  <<< ${nonce}
40
46
 
47
+ >>> ${nonce} 10:ab-15:cd
48
+ (empty body deletes the range)
49
+ <<< ${nonce}
50
+
41
51
  - The nonce "${nonce}" is constant; always use the exact value shown above.
42
52
  - Line numbers are 1-indexed and refer to the original file; "{start}-{end}" is inclusive.
43
53
  - Hashes are 2-character hex hashes of each line's full content as shown by read_file (e.g. "a3").
44
- - "{N}:{afterHash}+" inserts after line N; "0+" prepends (no hash needed). "{lastLine}:{hash}+" appends.
45
- - An empty body deletes the range.
46
54
  `.trim(),
47
55
  type: "string",
48
56
  },