@iinm/plain-agent 1.10.4 → 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 +2 -0
- package/config/config.predefined.json +17 -0
- package/package.json +1 -1
- package/src/cli/interactive.mjs +32 -4
- 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/interactive.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
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
|
-
|
|
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
|
|
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
|
},
|