@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 +2 -0
- package/config/config.predefined.json +17 -0
- package/package.json +1 -1
- package/src/cli/formatter.mjs +15 -0
- package/src/cli/interactive.mjs +46 -17
- package/src/cli/{tableDetector.mjs → streamFormatter.mjs} +31 -32
- package/src/config.d.ts +2 -0
- package/src/config.mjs +4 -0
- package/src/main.mjs +1 -0
- package/src/prompt.mjs +11 -4
- package/src/tool.d.ts +2 -0
- package/src/toolInputValidator.mjs +64 -39
- package/src/toolUseApprover.mjs +2 -1
- package/src/tools/patchFile.mjs +11 -3
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
package/src/cli/formatter.mjs
CHANGED
|
@@ -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.
|
package/src/cli/interactive.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
129
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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
|
-
|
|
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
|
|
532
|
-
|
|
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
|
|
560
|
-
*
|
|
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
|
|
564
|
-
const
|
|
592
|
+
function createStreamBuffer() {
|
|
593
|
+
const formatter = createStreamFormatter();
|
|
565
594
|
|
|
566
595
|
function feed(/** @type {string} */ chunk) {
|
|
567
|
-
const { output, warnings } =
|
|
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 } =
|
|
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[] }}
|
|
4
|
+
* @typedef {{ output: string[], warnings: string[] }} StreamFormatterResult
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Creates a
|
|
9
|
-
* in
|
|
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) =>
|
|
19
|
+
* @returns {{ feed: (chunk: string) => StreamFormatterResult, forceFlush: () => StreamFormatterResult }}
|
|
14
20
|
*/
|
|
15
|
-
export function
|
|
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
|
|
34
|
+
* Feed a text chunk to the formatter.
|
|
29
35
|
* @param {string} chunk
|
|
30
|
-
* @returns {
|
|
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 {
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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}
|
|
91
|
-
* @returns {
|
|
86
|
+
* @param {string} rawLine - Line (may or may not include trailing newline)
|
|
87
|
+
* @returns {StreamFormatterResult}
|
|
92
88
|
*/
|
|
93
|
-
function processLine(
|
|
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 (
|
|
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(
|
|
101
|
+
output.push(rawLine);
|
|
106
102
|
return { output, warnings };
|
|
107
103
|
}
|
|
108
104
|
|
|
109
105
|
if (inCodeBlock) {
|
|
110
|
-
output.push(
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
80
|
-
|
|
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
|
-
//
|
|
85
|
-
if (
|
|
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
|
-
*
|
|
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
|
|
160
|
-
const
|
|
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 ===
|
|
166
|
-
targetPath.startsWith(`${
|
|
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
|
-
*
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
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 ===
|
|
184
|
-
targetPath.startsWith(`${
|
|
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}
|
package/src/toolUseApprover.mjs
CHANGED
|
@@ -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" }
|
package/src/tools/patchFile.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|