@iinm/plain-agent 1.10.3 → 1.10.5

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.3",
3
+ "version": "1.10.5",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "config",
20
20
  "src/**/*.mjs",
21
21
  "src/**/*.d.ts",
22
+ "src/**/*.json",
22
23
  "!src/**/*.test.mjs",
23
24
  "!src/**/*.test.*.mjs",
24
25
  "!src/**/*.playground.mjs",
@@ -0,0 +1,124 @@
1
+ [
2
+ [4352, 4447],
3
+ [8986, 8987],
4
+ [9001, 9002],
5
+ [9193, 9196],
6
+ [9200, 9200],
7
+ [9203, 9203],
8
+ [9725, 9726],
9
+ [9748, 9749],
10
+ [9776, 9783],
11
+ [9800, 9811],
12
+ [9855, 9855],
13
+ [9866, 9871],
14
+ [9875, 9875],
15
+ [9889, 9889],
16
+ [9898, 9899],
17
+ [9917, 9918],
18
+ [9924, 9925],
19
+ [9934, 9934],
20
+ [9940, 9940],
21
+ [9962, 9962],
22
+ [9970, 9971],
23
+ [9973, 9973],
24
+ [9978, 9978],
25
+ [9981, 9981],
26
+ [9989, 9989],
27
+ [9994, 9995],
28
+ [10024, 10024],
29
+ [10060, 10060],
30
+ [10062, 10062],
31
+ [10067, 10069],
32
+ [10071, 10071],
33
+ [10133, 10135],
34
+ [10160, 10160],
35
+ [10175, 10175],
36
+ [11035, 11036],
37
+ [11088, 11088],
38
+ [11093, 11093],
39
+ [11904, 11929],
40
+ [11931, 12019],
41
+ [12032, 12245],
42
+ [12272, 12350],
43
+ [12353, 12438],
44
+ [12441, 12543],
45
+ [12549, 12591],
46
+ [12593, 12686],
47
+ [12688, 12773],
48
+ [12783, 12830],
49
+ [12832, 12871],
50
+ [12880, 42124],
51
+ [42128, 42182],
52
+ [43360, 43388],
53
+ [44032, 55203],
54
+ [63744, 64255],
55
+ [65040, 65049],
56
+ [65072, 65106],
57
+ [65108, 65126],
58
+ [65128, 65131],
59
+ [65281, 65376],
60
+ [65504, 65510],
61
+ [94176, 94180],
62
+ [94192, 94193],
63
+ [94208, 100343],
64
+ [100352, 101589],
65
+ [101631, 101640],
66
+ [110576, 110579],
67
+ [110581, 110587],
68
+ [110589, 110590],
69
+ [110592, 110882],
70
+ [110898, 110898],
71
+ [110928, 110930],
72
+ [110933, 110933],
73
+ [110948, 110951],
74
+ [110960, 111355],
75
+ [119552, 119638],
76
+ [119648, 119670],
77
+ [126980, 126980],
78
+ [127183, 127183],
79
+ [127374, 127374],
80
+ [127377, 127386],
81
+ [127488, 127490],
82
+ [127504, 127547],
83
+ [127552, 127560],
84
+ [127568, 127569],
85
+ [127584, 127589],
86
+ [127744, 127776],
87
+ [127789, 127797],
88
+ [127799, 127868],
89
+ [127870, 127891],
90
+ [127904, 127946],
91
+ [127951, 127955],
92
+ [127968, 127984],
93
+ [127988, 127988],
94
+ [127992, 128062],
95
+ [128064, 128064],
96
+ [128066, 128252],
97
+ [128255, 128317],
98
+ [128331, 128334],
99
+ [128336, 128359],
100
+ [128378, 128378],
101
+ [128405, 128406],
102
+ [128420, 128420],
103
+ [128507, 128591],
104
+ [128640, 128709],
105
+ [128716, 128716],
106
+ [128720, 128722],
107
+ [128725, 128727],
108
+ [128732, 128735],
109
+ [128747, 128748],
110
+ [128756, 128764],
111
+ [128992, 129003],
112
+ [129008, 129008],
113
+ [129292, 129338],
114
+ [129340, 129349],
115
+ [129351, 129535],
116
+ [129648, 129660],
117
+ [129664, 129673],
118
+ [129679, 129734],
119
+ [129742, 129756],
120
+ [129759, 129769],
121
+ [129776, 129784],
122
+ [131072, 196605],
123
+ [196608, 262141]
124
+ ]
@@ -640,110 +640,32 @@ function wrapCell(text, width) {
640
640
  return lines;
641
641
  }
642
642
 
643
+ /**
644
+ * Sorted, merged [start, end] ranges of Unicode code points with
645
+ * East_Asian_Width property "W" (Wide) or "F" (Fullwidth).
646
+ *
647
+ * Generated by: node scripts/fetchEastAsianWideRanges.mjs
648
+ * Source: https://www.unicode.org/Public/16.0.0/ucd/EastAsianWidth.txt
649
+ */
650
+ import WIDE_RANGES from "./eastAsianWideRanges.json" with { type: "json" };
651
+
643
652
  /**
644
653
  * Check if a Unicode code point is a wide (double-width) character.
645
- * Extracted from charDisplayWidth for reuse in wrapCell.
654
+ * Uses binary search over the sorted WIDE_RANGES for efficiency.
646
655
  * @param {number} code
647
656
  * @returns {boolean}
648
657
  */
649
658
  function isWideChar(code) {
650
- return (
651
- (code >= 0x1100 && code <= 0x115f) ||
652
- (code >= 0x2e80 && code <= 0xa4cf) ||
653
- (code >= 0xac00 && code <= 0xd7a3) ||
654
- (code >= 0xf900 && code <= 0xfaff) ||
655
- (code >= 0xfe10 && code <= 0xfe19) ||
656
- (code >= 0xfe30 && code <= 0xfe6f) ||
657
- (code >= 0xff00 && code <= 0xff60) ||
658
- (code >= 0xffe0 && code <= 0xffe6) ||
659
- (code >= 0x2614 && code <= 0x2615) ||
660
- (code >= 0x2630 && code <= 0x2637) ||
661
- (code >= 0x2648 && code <= 0x2653) ||
662
- code === 0x267f ||
663
- (code >= 0x268a && code <= 0x268f) ||
664
- code === 0x2693 ||
665
- code === 0x26a1 ||
666
- (code >= 0x26aa && code <= 0x26ab) ||
667
- (code >= 0x26bd && code <= 0x26be) ||
668
- (code >= 0x26c4 && code <= 0x26c5) ||
669
- code === 0x26ce ||
670
- code === 0x26d4 ||
671
- code === 0x26ea ||
672
- (code >= 0x26f2 && code <= 0x26f3) ||
673
- code === 0x26f5 ||
674
- code === 0x26fa ||
675
- code === 0x26fd ||
676
- code === 0x2705 ||
677
- (code >= 0x270a && code <= 0x270b) ||
678
- code === 0x2728 ||
679
- code === 0x274c ||
680
- code === 0x274e ||
681
- (code >= 0x2753 && code <= 0x2755) ||
682
- code === 0x2757 ||
683
- (code >= 0x2795 && code <= 0x2797) ||
684
- code === 0x27b0 ||
685
- code === 0x27bf ||
686
- (code >= 0x2b1b && code <= 0x2b1c) ||
687
- code === 0x2b50 ||
688
- code === 0x2b55 ||
689
- (code >= 0x231a && code <= 0x231b) ||
690
- code === 0x2329 ||
691
- code === 0x232a ||
692
- (code >= 0x23e9 && code <= 0x23ec) ||
693
- code === 0x23f0 ||
694
- code === 0x23f3 ||
695
- code === 0x1f004 ||
696
- code === 0x1f0cf ||
697
- code === 0x1f18e ||
698
- (code >= 0x1f191 && code <= 0x1f19a) ||
699
- (code >= 0x1f200 && code <= 0x1f202) ||
700
- (code >= 0x1f210 && code <= 0x1f23b) ||
701
- (code >= 0x1f240 && code <= 0x1f248) ||
702
- (code >= 0x1f250 && code <= 0x1f251) ||
703
- (code >= 0x1f260 && code <= 0x1f265) ||
704
- (code >= 0x1f300 && code <= 0x1f320) ||
705
- (code >= 0x1f32d && code <= 0x1f335) ||
706
- (code >= 0x1f337 && code <= 0x1f37c) ||
707
- (code >= 0x1f37e && code <= 0x1f393) ||
708
- (code >= 0x1f3a0 && code <= 0x1f3ca) ||
709
- (code >= 0x1f3cf && code <= 0x1f3d3) ||
710
- (code >= 0x1f3e0 && code <= 0x1f3f0) ||
711
- code === 0x1f3f4 ||
712
- (code >= 0x1f3f8 && code <= 0x1f3fa) ||
713
- (code >= 0x1f3fb && code <= 0x1f3ff) ||
714
- (code >= 0x1f400 && code <= 0x1f43e) ||
715
- code === 0x1f440 ||
716
- (code >= 0x1f442 && code <= 0x1f4fc) ||
717
- (code >= 0x1f4ff && code <= 0x1f53d) ||
718
- (code >= 0x1f54b && code <= 0x1f54e) ||
719
- (code >= 0x1f550 && code <= 0x1f567) ||
720
- code === 0x1f57a ||
721
- (code >= 0x1f595 && code <= 0x1f596) ||
722
- code === 0x1f5a4 ||
723
- (code >= 0x1f5fb && code <= 0x1f5ff) ||
724
- (code >= 0x1f600 && code <= 0x1f64f) ||
725
- (code >= 0x1f680 && code <= 0x1f6c5) ||
726
- code === 0x1f6cc ||
727
- (code >= 0x1f6d0 && code <= 0x1f6d2) ||
728
- (code >= 0x1f6d5 && code <= 0x1f6d8) ||
729
- (code >= 0x1f6dc && code <= 0x1f6df) ||
730
- (code >= 0x1f6eb && code <= 0x1f6ec) ||
731
- (code >= 0x1f6f4 && code <= 0x1f6fc) ||
732
- (code >= 0x1f7e0 && code <= 0x1f7eb) ||
733
- code === 0x1f7f0 ||
734
- (code >= 0x1f90c && code <= 0x1f93a) ||
735
- (code >= 0x1f93c && code <= 0x1f945) ||
736
- (code >= 0x1f947 && code <= 0x1f9ff) ||
737
- (code >= 0x1fa70 && code <= 0x1fa7c) ||
738
- (code >= 0x1fa80 && code <= 0x1fa8a) ||
739
- (code >= 0x1fa8e && code <= 0x1fac6) ||
740
- code === 0x1fac8 ||
741
- (code >= 0x1facd && code <= 0x1fadc) ||
742
- (code >= 0x1fadf && code <= 0x1faea) ||
743
- (code >= 0x1faef && code <= 0x1faf8) ||
744
- (code >= 0x20000 && code <= 0x2fffd) ||
745
- (code >= 0x30000 && code <= 0x3fffd)
746
- );
659
+ let lo = 0;
660
+ let hi = WIDE_RANGES.length - 1;
661
+ while (lo <= hi) {
662
+ const mid = (lo + hi) >>> 1;
663
+ const [start, end] = WIDE_RANGES[mid];
664
+ if (code < start) hi = mid - 1;
665
+ else if (code > end) lo = mid + 1;
666
+ else return true;
667
+ }
668
+ return false;
747
669
  }
748
670
 
749
671
  /**
@@ -112,13 +112,18 @@ 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}
@@ -466,11 +471,28 @@ 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
497
  tableBuffer.feed(partialContent.content);
476
498
  } else {
@@ -478,7 +500,13 @@ export function startInteractiveSession({
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}>`)} ${styleText("green", "✓")}\n`,
506
+ );
507
+ } else {
508
+ console.log(styleText("gray", `\n</${partialContent.type}>`));
509
+ }
482
510
  }
483
511
  });
484
512
 
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
  },