@link-assistant/agent 0.23.0 → 0.24.0
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 +12 -0
- package/package.json +1 -1
- package/src/cli/continuous-mode.js +27 -0
- package/src/cli/event-handler.js +21 -0
- package/src/cli/input-queue.js +63 -7
- package/src/cli/run-options.js +23 -0
- package/src/config/config.ts +37 -0
- package/src/permission/index.ts +564 -0
- package/src/session/prompt.ts +6 -0
- package/src/session/system.ts +14 -0
- package/src/tool/bash.ts +41 -1
- package/src/tool/batch.ts +8 -0
- package/src/tool/edit.ts +12 -1
- package/src/tool/patch.ts +19 -1
- package/src/tool/registry.ts +36 -0
- package/src/tool/webfetch.ts +12 -1
- package/src/tool/write.ts +13 -1
package/README.md
CHANGED
|
@@ -293,6 +293,18 @@ Options:
|
|
|
293
293
|
--append-system-message Append to the default system message
|
|
294
294
|
--append-system-message-file Append to the default system message from file
|
|
295
295
|
|
|
296
|
+
Permission Options:
|
|
297
|
+
--read-only Enforceable read-only / planning mode.
|
|
298
|
+
Disables all filesystem-mutating and shell
|
|
299
|
+
tools (bash, edit, write, multiedit, patch)
|
|
300
|
+
so the agent can only read, search and plan.
|
|
301
|
+
Env: LINK_ASSISTANT_AGENT_READ_ONLY=true
|
|
302
|
+
--disable-tools <list> Comma-separated list of tool ids to disable
|
|
303
|
+
(e.g. "bash,write,edit"). Disabled tools are
|
|
304
|
+
never exposed to the model and are rejected if
|
|
305
|
+
invoked via batch.
|
|
306
|
+
Env: LINK_ASSISTANT_AGENT_DISABLE_TOOLS
|
|
307
|
+
|
|
296
308
|
Stdin Mode Options:
|
|
297
309
|
-p, --prompt Direct prompt (bypasses stdin reading)
|
|
298
310
|
--disable-stdin Disable stdin streaming (requires --prompt)
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { Session } from '../session/index.ts';
|
|
|
10
10
|
import { SessionPrompt } from '../session/prompt.ts';
|
|
11
11
|
import { createEventHandler, serializeOutput } from '../json-standard/index.ts';
|
|
12
12
|
import { createContinuousStdinReader } from './input-queue.js';
|
|
13
|
+
import { Permission } from '../permission/index.ts';
|
|
13
14
|
import { Log } from '../util/log.ts';
|
|
14
15
|
import { config } from '../config/config.ts';
|
|
15
16
|
import { createVerboseFetch } from '../util/verbose-fetch.ts';
|
|
@@ -323,6 +324,19 @@ export async function runContinuousServerMode(
|
|
|
323
324
|
return;
|
|
324
325
|
}
|
|
325
326
|
|
|
327
|
+
// Permission approval reply (issue #271). Resolve a pending tool
|
|
328
|
+
// permission request without interrupting the in-flight turn — this must
|
|
329
|
+
// bypass the `isProcessing` queue, since the turn is blocked waiting for it.
|
|
330
|
+
if (message.kind === 'permission_response') {
|
|
331
|
+
Permission.respond({
|
|
332
|
+
sessionID,
|
|
333
|
+
permissionID: message.permissionID,
|
|
334
|
+
response: message.response,
|
|
335
|
+
});
|
|
336
|
+
outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
326
340
|
if (isProcessing) {
|
|
327
341
|
pendingMessages.push(message);
|
|
328
342
|
return;
|
|
@@ -577,6 +591,19 @@ export async function runContinuousDirectMode(
|
|
|
577
591
|
return;
|
|
578
592
|
}
|
|
579
593
|
|
|
594
|
+
// Permission approval reply (issue #271). Resolve a pending tool
|
|
595
|
+
// permission request without interrupting the in-flight turn — this must
|
|
596
|
+
// bypass the `isProcessing` queue, since the turn is blocked waiting for it.
|
|
597
|
+
if (message.kind === 'permission_response') {
|
|
598
|
+
Permission.respond({
|
|
599
|
+
sessionID,
|
|
600
|
+
permissionID: message.permissionID,
|
|
601
|
+
response: message.response,
|
|
602
|
+
});
|
|
603
|
+
outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
580
607
|
if (isProcessing) {
|
|
581
608
|
pendingMessages.push(message);
|
|
582
609
|
return;
|
package/src/cli/event-handler.js
CHANGED
|
@@ -81,6 +81,27 @@ export function createBusEventSubscription({
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Emit a JSON permission request when a tool needs approval (issue #271).
|
|
85
|
+
// The consumer replies with a `permission_response` frame over stdin which
|
|
86
|
+
// is routed to Permission.respond. No TUI is involved.
|
|
87
|
+
if (event.type === 'permission.updated') {
|
|
88
|
+
const permission = event.properties;
|
|
89
|
+
if (permission.sessionID !== sessionID) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
eventHandler.output({
|
|
93
|
+
type: 'permission_request',
|
|
94
|
+
timestamp: Date.now(),
|
|
95
|
+
sessionID,
|
|
96
|
+
permissionID: permission.id,
|
|
97
|
+
callID: permission.callID,
|
|
98
|
+
tool: permission.type,
|
|
99
|
+
pattern: permission.pattern,
|
|
100
|
+
title: permission.title,
|
|
101
|
+
metadata: permission.metadata,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
84
105
|
// Handle session idle to know when to stop
|
|
85
106
|
if (
|
|
86
107
|
event.type === 'session.idle' &&
|
package/src/cli/input-queue.js
CHANGED
|
@@ -30,18 +30,47 @@ export class InputQueue {
|
|
|
30
30
|
return parseStreamJsonInput(trimmed);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
let parsed;
|
|
33
34
|
try {
|
|
34
|
-
|
|
35
|
-
// If it has a message field, use it directly
|
|
36
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
37
|
-
return parsed;
|
|
38
|
-
}
|
|
39
|
-
// Otherwise wrap it
|
|
40
|
-
return { message: JSON.stringify(parsed) };
|
|
35
|
+
parsed = JSON.parse(trimmed);
|
|
41
36
|
} catch (_e) {
|
|
42
37
|
// Not JSON, treat as plain text message
|
|
43
38
|
return { message: trimmed };
|
|
44
39
|
}
|
|
40
|
+
|
|
41
|
+
// If it has a message field, use it directly
|
|
42
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
43
|
+
// Permission approval reply (issue #271) — normalize to a `kind` frame
|
|
44
|
+
// so it is routed to Permission.respond, mirroring stream-json mode.
|
|
45
|
+
if (parsed.type === 'permission_response') {
|
|
46
|
+
const permissionID = parsed.permissionID ?? parsed.permission_id;
|
|
47
|
+
const response = parsed.response;
|
|
48
|
+
if (typeof permissionID !== 'string' || !permissionID) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'Invalid permission_response: expected string "permissionID"'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
response !== 'once' &&
|
|
55
|
+
response !== 'always' &&
|
|
56
|
+
response !== 'reject'
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'Invalid permission_response: "response" must be "once", "always" or "reject"'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
kind: 'permission_response',
|
|
64
|
+
permissionID,
|
|
65
|
+
response,
|
|
66
|
+
raw: trimmed,
|
|
67
|
+
parsed,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
// Otherwise wrap it
|
|
73
|
+
return { message: JSON.stringify(parsed) };
|
|
45
74
|
}
|
|
46
75
|
|
|
47
76
|
/**
|
|
@@ -283,6 +312,33 @@ export function parseStreamJsonInput(input) {
|
|
|
283
312
|
};
|
|
284
313
|
}
|
|
285
314
|
|
|
315
|
+
// Permission approval reply (issue #271). Resolves a pending
|
|
316
|
+
// `permission_request` emitted by the agent. `response` is one of
|
|
317
|
+
// `once` | `always` | `reject`.
|
|
318
|
+
if (type === 'permission_response') {
|
|
319
|
+
const permissionID = frame.permissionID ?? frame.permission_id;
|
|
320
|
+
const response = frame.response;
|
|
321
|
+
if (typeof permissionID !== 'string' || !permissionID) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
'Invalid permission_response frame: expected string "permissionID"'
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (response !== 'once' && response !== 'always' && response !== 'reject') {
|
|
327
|
+
throw new Error(
|
|
328
|
+
'Invalid permission_response frame: "response" must be "once", "always" or "reject"'
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
kind: 'permission_response',
|
|
333
|
+
permissionID,
|
|
334
|
+
response,
|
|
335
|
+
raw: input,
|
|
336
|
+
parsed: frame,
|
|
337
|
+
format: 'stream-json',
|
|
338
|
+
inputType: type,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
286
342
|
if (type === 'user' || type === 'user_prompt' || type === undefined) {
|
|
287
343
|
const message = extractFrameText(frame);
|
|
288
344
|
if (message === null) {
|
package/src/cli/run-options.js
CHANGED
|
@@ -211,5 +211,28 @@ export function buildRunOptions(yargs, defaultOptions = {}) {
|
|
|
211
211
|
type: 'number',
|
|
212
212
|
description:
|
|
213
213
|
'Override the temperature for model completions. When not set, the default per-model temperature is used.',
|
|
214
|
+
})
|
|
215
|
+
.option('read-only', {
|
|
216
|
+
type: 'boolean',
|
|
217
|
+
description:
|
|
218
|
+
'Enforceable read-only / planning mode. Disables all filesystem-mutating and shell tools (bash, edit, write, multiedit, patch) so the agent can only read, search and plan. Maps to agent-commander --read-only.',
|
|
219
|
+
default: false,
|
|
220
|
+
})
|
|
221
|
+
.option('disable-tools', {
|
|
222
|
+
type: 'string',
|
|
223
|
+
description:
|
|
224
|
+
'Comma-separated list of tool ids to disable (e.g. "bash,write,edit"). Disabled tools are never exposed to the model and are rejected if invoked via batch.',
|
|
225
|
+
})
|
|
226
|
+
.option('permission-mode', {
|
|
227
|
+
type: 'string',
|
|
228
|
+
choices: ['auto', 'plan', 'readonly', 'ask'],
|
|
229
|
+
description:
|
|
230
|
+
'Permission policy for mutating tools. "auto" (default) allows everything; "plan" allows read-only shell and asks before mutations; "readonly" denies all mutations; "ask" asks before every mutating tool. Approvals are driven over JSON (permission_request / permission_response).',
|
|
231
|
+
default: 'auto',
|
|
232
|
+
})
|
|
233
|
+
.option('permission', {
|
|
234
|
+
type: 'string',
|
|
235
|
+
description:
|
|
236
|
+
'OpenCode-compatible permission override as JSON, merged on top of --permission-mode. Example: \'{"edit":"ask","bash":{"git push*":"ask","*":"allow"},"webfetch":"allow"}\'.',
|
|
214
237
|
});
|
|
215
238
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -52,6 +52,10 @@ export interface AgentConfig {
|
|
|
52
52
|
mcpDefaultToolCallTimeout: number;
|
|
53
53
|
mcpMaxToolCallTimeout: number;
|
|
54
54
|
verifyImagesAtReadTool: boolean;
|
|
55
|
+
readOnly: boolean;
|
|
56
|
+
disableTools: string;
|
|
57
|
+
permissionMode: string;
|
|
58
|
+
permission: string;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
// Fallback helpers for when config is not yet initialized (early imports/tests)
|
|
@@ -132,6 +136,10 @@ function defaultConfig(): AgentConfig {
|
|
|
132
136
|
})(),
|
|
133
137
|
verifyImagesAtReadTool:
|
|
134
138
|
getEnvStr('LINK_ASSISTANT_AGENT_VERIFY_IMAGES_AT_READ_TOOL') !== 'false',
|
|
139
|
+
readOnly: truthyEnv('LINK_ASSISTANT_AGENT_READ_ONLY'),
|
|
140
|
+
disableTools: getEnvStr('LINK_ASSISTANT_AGENT_DISABLE_TOOLS') ?? '',
|
|
141
|
+
permissionMode: getEnvStr('LINK_ASSISTANT_AGENT_PERMISSION_MODE') ?? 'auto',
|
|
142
|
+
permission: getEnvStr('LINK_ASSISTANT_AGENT_PERMISSION') ?? '',
|
|
135
143
|
};
|
|
136
144
|
}
|
|
137
145
|
|
|
@@ -240,6 +248,31 @@ function buildAgentConfigOptions({
|
|
|
240
248
|
type: 'boolean',
|
|
241
249
|
description: 'Verify images when using the read tool',
|
|
242
250
|
default: getenv('LINK_ASSISTANT_AGENT_VERIFY_IMAGES_AT_READ_TOOL', true),
|
|
251
|
+
})
|
|
252
|
+
.option('read-only', {
|
|
253
|
+
type: 'boolean',
|
|
254
|
+
description:
|
|
255
|
+
'Enforceable read-only / planning mode. Disables all filesystem-mutating and shell tools (bash, edit, write, multiedit, patch).',
|
|
256
|
+
default: getenv('LINK_ASSISTANT_AGENT_READ_ONLY', false),
|
|
257
|
+
})
|
|
258
|
+
.option('disable-tools', {
|
|
259
|
+
type: 'string',
|
|
260
|
+
description:
|
|
261
|
+
'Comma-separated list of tool ids to disable (e.g. "bash,write,edit"). Disabled tools are never exposed to the model and are rejected if invoked via batch.',
|
|
262
|
+
default: getenv('LINK_ASSISTANT_AGENT_DISABLE_TOOLS', ''),
|
|
263
|
+
})
|
|
264
|
+
.option('permission-mode', {
|
|
265
|
+
type: 'string',
|
|
266
|
+
choices: ['auto', 'plan', 'readonly', 'ask'],
|
|
267
|
+
description:
|
|
268
|
+
'Permission policy for mutating tools. "auto" (default) allows everything; "plan" allows read-only shell and asks before mutations; "readonly" denies all mutations; "ask" asks before every mutating tool. Approvals are driven over JSON (permission_request / permission_response).',
|
|
269
|
+
default: getenv('LINK_ASSISTANT_AGENT_PERMISSION_MODE', 'auto'),
|
|
270
|
+
})
|
|
271
|
+
.option('permission', {
|
|
272
|
+
type: 'string',
|
|
273
|
+
description:
|
|
274
|
+
'OpenCode-compatible permission override as JSON, merged on top of --permission-mode. Example: \'{"edit":"ask","bash":{"git push*":"ask","*":"allow"},"webfetch":"allow"}\'.',
|
|
275
|
+
default: getenv('LINK_ASSISTANT_AGENT_PERMISSION', ''),
|
|
243
276
|
});
|
|
244
277
|
}
|
|
245
278
|
|
|
@@ -292,6 +325,10 @@ export function initConfig(argv?: string[]): AgentConfig {
|
|
|
292
325
|
mcpDefaultToolCallTimeout: parsed.mcpDefaultToolCallTimeout ?? 120000,
|
|
293
326
|
mcpMaxToolCallTimeout: parsed.mcpMaxToolCallTimeout ?? 600000,
|
|
294
327
|
verifyImagesAtReadTool: parsed.verifyImagesAtReadTool ?? true,
|
|
328
|
+
readOnly: parsed.readOnly ?? false,
|
|
329
|
+
disableTools: parsed.disableTools ?? '',
|
|
330
|
+
permissionMode: parsed.permissionMode ?? 'auto',
|
|
331
|
+
permission: parsed.permission ?? '',
|
|
295
332
|
});
|
|
296
333
|
|
|
297
334
|
// Propagate verbose to env var for subprocess resilience (issue #227).
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission system (issue #271).
|
|
3
|
+
*
|
|
4
|
+
* Re-adds an OpenCode-style permission system to @link-assistant/agent that is
|
|
5
|
+
* fully controllable from JSON (input and output), with no TUI.
|
|
6
|
+
*
|
|
7
|
+
* By default the agent runs in **full auto mode** (every action is `allow`), so
|
|
8
|
+
* nothing changes for existing consumers. Opt-in CLI options switch the policy:
|
|
9
|
+
*
|
|
10
|
+
* --permission-mode auto (default) allow everything, never ask
|
|
11
|
+
* --permission-mode plan read-only planning: deny edits, allow read-only
|
|
12
|
+
* shell commands, ask before anything else
|
|
13
|
+
* --permission-mode readonly hard read-only: deny edits and any non
|
|
14
|
+
* read-only shell command (never asks)
|
|
15
|
+
* --permission-mode ask ask before every mutating tool (per-command
|
|
16
|
+
* approval driven over JSON)
|
|
17
|
+
* --permission '<json>' explicit OpenCode-compatible override merged on
|
|
18
|
+
* top of the mode, e.g.
|
|
19
|
+
* '{"bash":{"git push*":"ask","*":"allow"},"edit":"ask"}'
|
|
20
|
+
*
|
|
21
|
+
* When a tool needs approval it publishes a `permission.updated` event (emitted
|
|
22
|
+
* to stdout as a `permission_request` JSON event) and waits. The consumer sends
|
|
23
|
+
* back a `permission_response` JSON frame which resolves `Permission.respond`.
|
|
24
|
+
*
|
|
25
|
+
* This is the native, enforceable counterpart of agent-commander's `--read-only`
|
|
26
|
+
* flag and of its (future) per-command approval UX.
|
|
27
|
+
*/
|
|
28
|
+
import z from 'zod';
|
|
29
|
+
import { Bus } from '../bus';
|
|
30
|
+
import { Log } from '../util/log';
|
|
31
|
+
import { Identifier } from '../id/id';
|
|
32
|
+
import { Instance } from '../project/instance';
|
|
33
|
+
import { Wildcard } from '../util/wildcard';
|
|
34
|
+
import { config } from '../config/config';
|
|
35
|
+
|
|
36
|
+
export namespace Permission {
|
|
37
|
+
const log = Log.create({ service: 'permission' });
|
|
38
|
+
|
|
39
|
+
// ─── Policy types ─────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export const Action = z.enum(['ask', 'allow', 'deny']);
|
|
42
|
+
export type Action = z.infer<typeof Action>;
|
|
43
|
+
|
|
44
|
+
export const Mode = z.enum(['auto', 'plan', 'readonly', 'ask']);
|
|
45
|
+
export type Mode = z.infer<typeof Mode>;
|
|
46
|
+
|
|
47
|
+
/** Resolved permission policy. `bash` is a pattern map (OpenCode-compatible). */
|
|
48
|
+
export interface Policy {
|
|
49
|
+
edit: Action;
|
|
50
|
+
bash: Record<string, Action>;
|
|
51
|
+
webfetch: Action;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read-only shell allowlist. Commands matching one of these patterns are safe
|
|
56
|
+
* to run in plan/readonly mode; everything else falls back to `fallback`
|
|
57
|
+
* (`ask` for plan, `deny` for readonly).
|
|
58
|
+
*/
|
|
59
|
+
function readonlyBash(fallback: Action): Record<string, Action> {
|
|
60
|
+
return {
|
|
61
|
+
'cat*': 'allow',
|
|
62
|
+
'cd*': 'allow',
|
|
63
|
+
'cut*': 'allow',
|
|
64
|
+
'date*': 'allow',
|
|
65
|
+
'df*': 'allow',
|
|
66
|
+
'diff*': 'allow',
|
|
67
|
+
'dirname*': 'allow',
|
|
68
|
+
'du*': 'allow',
|
|
69
|
+
'echo*': 'allow',
|
|
70
|
+
env: 'allow',
|
|
71
|
+
'file *': 'allow',
|
|
72
|
+
'find *': 'allow',
|
|
73
|
+
'git diff*': 'allow',
|
|
74
|
+
'git log*': 'allow',
|
|
75
|
+
'git show*': 'allow',
|
|
76
|
+
'git status*': 'allow',
|
|
77
|
+
'git branch': 'allow',
|
|
78
|
+
'git branch -v': 'allow',
|
|
79
|
+
'grep*': 'allow',
|
|
80
|
+
'head*': 'allow',
|
|
81
|
+
'hostname*': 'allow',
|
|
82
|
+
'less*': 'allow',
|
|
83
|
+
'ls*': 'allow',
|
|
84
|
+
'more*': 'allow',
|
|
85
|
+
'printenv*': 'allow',
|
|
86
|
+
'pwd*': 'allow',
|
|
87
|
+
'readlink*': 'allow',
|
|
88
|
+
'realpath*': 'allow',
|
|
89
|
+
'rg*': 'allow',
|
|
90
|
+
'sort*': 'allow',
|
|
91
|
+
'stat*': 'allow',
|
|
92
|
+
'tail*': 'allow',
|
|
93
|
+
'tree*': 'allow',
|
|
94
|
+
'uname*': 'allow',
|
|
95
|
+
'uniq*': 'allow',
|
|
96
|
+
'wc*': 'allow',
|
|
97
|
+
'whereis*': 'allow',
|
|
98
|
+
'which*': 'allow',
|
|
99
|
+
'whoami*': 'allow',
|
|
100
|
+
// Read-only command families that have destructive variants:
|
|
101
|
+
'find * -delete*': fallback,
|
|
102
|
+
'find * -exec*': fallback,
|
|
103
|
+
'find * -execdir*': fallback,
|
|
104
|
+
'find * -fprint*': fallback,
|
|
105
|
+
'find * -fls*': fallback,
|
|
106
|
+
'find * -fprintf*': fallback,
|
|
107
|
+
'find * -ok*': fallback,
|
|
108
|
+
'sort -o*': fallback,
|
|
109
|
+
'sort --output*': fallback,
|
|
110
|
+
'tree -o*': fallback,
|
|
111
|
+
// Catch-all:
|
|
112
|
+
'*': fallback,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Base policy for a mode, before any explicit `--permission` override. */
|
|
117
|
+
export function modePolicy(mode: Mode): Policy {
|
|
118
|
+
switch (mode) {
|
|
119
|
+
case 'plan':
|
|
120
|
+
return { edit: 'deny', bash: readonlyBash('ask'), webfetch: 'allow' };
|
|
121
|
+
case 'readonly':
|
|
122
|
+
return { edit: 'deny', bash: readonlyBash('deny'), webfetch: 'allow' };
|
|
123
|
+
case 'ask':
|
|
124
|
+
return { edit: 'ask', bash: { '*': 'ask' }, webfetch: 'ask' };
|
|
125
|
+
case 'auto':
|
|
126
|
+
default:
|
|
127
|
+
return { edit: 'allow', bash: { '*': 'allow' }, webfetch: 'allow' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Parse the raw `--permission` JSON string into a partial policy. */
|
|
132
|
+
export function parseOverride(raw: string | undefined): Partial<Policy> {
|
|
133
|
+
if (!raw || !raw.trim()) return {};
|
|
134
|
+
let parsed: any;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(raw);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Invalid --permission JSON: ${
|
|
140
|
+
error instanceof Error ? error.message : String(error)
|
|
141
|
+
}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
145
|
+
throw new Error('Invalid --permission JSON: expected an object');
|
|
146
|
+
}
|
|
147
|
+
const out: Partial<Policy> = {};
|
|
148
|
+
if (parsed.edit !== undefined) out.edit = Action.parse(parsed.edit);
|
|
149
|
+
if (parsed.webfetch !== undefined)
|
|
150
|
+
out.webfetch = Action.parse(parsed.webfetch);
|
|
151
|
+
if (parsed.bash !== undefined) {
|
|
152
|
+
if (typeof parsed.bash === 'string') {
|
|
153
|
+
out.bash = { '*': Action.parse(parsed.bash) };
|
|
154
|
+
} else if (
|
|
155
|
+
typeof parsed.bash === 'object' &&
|
|
156
|
+
!Array.isArray(parsed.bash)
|
|
157
|
+
) {
|
|
158
|
+
const bash: Record<string, Action> = {};
|
|
159
|
+
for (const [pattern, value] of Object.entries(parsed.bash)) {
|
|
160
|
+
bash[pattern] = Action.parse(value);
|
|
161
|
+
}
|
|
162
|
+
out.bash = bash;
|
|
163
|
+
} else {
|
|
164
|
+
throw new Error(
|
|
165
|
+
'Invalid --permission JSON: "bash" must be a string or object'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve the active policy from the global config. Mode defines the base
|
|
174
|
+
* policy and `--permission` JSON is merged on top (override wins; bash maps
|
|
175
|
+
* are merged key-by-key).
|
|
176
|
+
*/
|
|
177
|
+
export function policy(): Policy {
|
|
178
|
+
const mode = Mode.parse(config.permissionMode ?? 'auto');
|
|
179
|
+
const base = modePolicy(mode);
|
|
180
|
+
const override = parseOverride(config.permission);
|
|
181
|
+
return {
|
|
182
|
+
edit: override.edit ?? base.edit,
|
|
183
|
+
webfetch: override.webfetch ?? base.webfetch,
|
|
184
|
+
bash: { ...base.bash, ...(override.bash ?? {}) },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const RESTRICTIVENESS: Record<Action, number> = {
|
|
189
|
+
allow: 0,
|
|
190
|
+
ask: 1,
|
|
191
|
+
deny: 2,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
function moreRestrictive(a: Action, b: Action): Action {
|
|
195
|
+
return RESTRICTIVENESS[b] > RESTRICTIVENESS[a] ? b : a;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function tokenize(segment: string): string[] {
|
|
199
|
+
return segment.trim().split(/\s+/).filter(Boolean);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Evaluate a shell command against a bash pattern map.
|
|
204
|
+
*
|
|
205
|
+
* Faithful-enough port of OpenCode's structured matching, hardened for
|
|
206
|
+
* non-interactive enforcement: a command is only `allow`ed when *every*
|
|
207
|
+
* segment (split on `;`, `&&`, `||`, `|`) matches an allow pattern and the
|
|
208
|
+
* command contains no command substitution or output redirection. Otherwise
|
|
209
|
+
* it falls back to the catch-all action (`*`). The strongest enforcement
|
|
210
|
+
* remains tool removal via `--read-only` / `--disable-tools`.
|
|
211
|
+
*/
|
|
212
|
+
export function evaluateBash(
|
|
213
|
+
command: string,
|
|
214
|
+
patterns: Record<string, Action>
|
|
215
|
+
): { action: Action; askPatterns: string[] } {
|
|
216
|
+
const fallback = patterns['*'] ?? 'allow';
|
|
217
|
+
const askPatterns = new Set<string>();
|
|
218
|
+
|
|
219
|
+
// Command substitution or output redirection can hide arbitrary writes.
|
|
220
|
+
const hasSubstitution = /\$\(|`/.test(command);
|
|
221
|
+
const hasRedirection = />>?/.test(command);
|
|
222
|
+
if (hasSubstitution || hasRedirection) {
|
|
223
|
+
if (fallback === 'ask') askPatterns.add(command);
|
|
224
|
+
return { action: fallback, askPatterns: Array.from(askPatterns) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const segments = command
|
|
228
|
+
.split(/&&|\|\||;|\|/g)
|
|
229
|
+
.map((s) => s.trim())
|
|
230
|
+
.filter(Boolean);
|
|
231
|
+
if (segments.length === 0) {
|
|
232
|
+
return { action: fallback, askPatterns: [] };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let action: Action = 'allow';
|
|
236
|
+
for (const segment of segments) {
|
|
237
|
+
const tokens = tokenize(segment);
|
|
238
|
+
if (tokens.length === 0) continue;
|
|
239
|
+
const matched = Wildcard.allStructured(
|
|
240
|
+
{ head: tokens[0], tail: tokens.slice(1) },
|
|
241
|
+
patterns
|
|
242
|
+
) as Action | undefined;
|
|
243
|
+
const segAction = matched ?? fallback;
|
|
244
|
+
action = moreRestrictive(action, segAction);
|
|
245
|
+
if (segAction === 'ask') {
|
|
246
|
+
const sub = tokens.slice(1).find((arg) => !arg.startsWith('-'));
|
|
247
|
+
askPatterns.add(sub ? `${tokens[0]} ${sub} *` : `${tokens[0]} *`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { action, askPatterns: Array.from(askPatterns) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Ask / respond machinery (ported from OpenCode) ───────────────────────
|
|
254
|
+
|
|
255
|
+
function toKeys(pattern: Info['pattern'], type: string): string[] {
|
|
256
|
+
return pattern === undefined
|
|
257
|
+
? [type]
|
|
258
|
+
: Array.isArray(pattern)
|
|
259
|
+
? pattern
|
|
260
|
+
: [pattern];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function covered(keys: string[], approved: Record<string, boolean>): boolean {
|
|
264
|
+
const pats = Object.keys(approved);
|
|
265
|
+
return keys.every((k) => pats.some((p) => Wildcard.match(k, p)));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const Info = z
|
|
269
|
+
.object({
|
|
270
|
+
id: z.string(),
|
|
271
|
+
type: z.string(),
|
|
272
|
+
pattern: z.union([z.string(), z.array(z.string())]).optional(),
|
|
273
|
+
sessionID: z.string(),
|
|
274
|
+
messageID: z.string(),
|
|
275
|
+
callID: z.string().optional(),
|
|
276
|
+
title: z.string(),
|
|
277
|
+
metadata: z.record(z.string(), z.any()),
|
|
278
|
+
time: z.object({
|
|
279
|
+
created: z.number(),
|
|
280
|
+
}),
|
|
281
|
+
})
|
|
282
|
+
.meta({
|
|
283
|
+
ref: 'Permission',
|
|
284
|
+
});
|
|
285
|
+
export type Info = z.infer<typeof Info>;
|
|
286
|
+
|
|
287
|
+
export const Event = {
|
|
288
|
+
Updated: Bus.event('permission.updated', Info),
|
|
289
|
+
Replied: Bus.event(
|
|
290
|
+
'permission.replied',
|
|
291
|
+
z.object({
|
|
292
|
+
sessionID: z.string(),
|
|
293
|
+
permissionID: z.string(),
|
|
294
|
+
response: z.string(),
|
|
295
|
+
})
|
|
296
|
+
),
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const state = Instance.state(
|
|
300
|
+
() => {
|
|
301
|
+
const pending: {
|
|
302
|
+
[sessionID: string]: {
|
|
303
|
+
[permissionID: string]: {
|
|
304
|
+
info: Info;
|
|
305
|
+
resolve: () => void;
|
|
306
|
+
reject: (e: any) => void;
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
} = {};
|
|
310
|
+
|
|
311
|
+
const approved: {
|
|
312
|
+
[sessionID: string]: {
|
|
313
|
+
[permissionID: string]: boolean;
|
|
314
|
+
};
|
|
315
|
+
} = {};
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
pending,
|
|
319
|
+
approved,
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
async (state) => {
|
|
323
|
+
for (const pending of Object.values(state.pending)) {
|
|
324
|
+
for (const item of Object.values(pending)) {
|
|
325
|
+
item.reject(
|
|
326
|
+
new RejectedError(
|
|
327
|
+
item.info.sessionID,
|
|
328
|
+
item.info.id,
|
|
329
|
+
item.info.callID,
|
|
330
|
+
item.info.metadata
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
export function pending() {
|
|
339
|
+
return state().pending;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export const Response = z.enum(['once', 'always', 'reject']);
|
|
343
|
+
export type Response = z.infer<typeof Response>;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Ask for permission. Resolves immediately when already approved; rejects
|
|
347
|
+
* (throws via the caller) when denied. Otherwise registers a pending request,
|
|
348
|
+
* publishes `permission.updated`, and returns a promise resolved by
|
|
349
|
+
* `respond()`.
|
|
350
|
+
*/
|
|
351
|
+
export async function ask(input: {
|
|
352
|
+
type: Info['type'];
|
|
353
|
+
title: Info['title'];
|
|
354
|
+
pattern?: Info['pattern'];
|
|
355
|
+
callID?: Info['callID'];
|
|
356
|
+
sessionID: Info['sessionID'];
|
|
357
|
+
messageID: Info['messageID'];
|
|
358
|
+
metadata: Info['metadata'];
|
|
359
|
+
}) {
|
|
360
|
+
const { pending, approved } = state();
|
|
361
|
+
log.info(() => ({
|
|
362
|
+
message: 'asking',
|
|
363
|
+
sessionID: input.sessionID,
|
|
364
|
+
pattern: input.pattern,
|
|
365
|
+
}));
|
|
366
|
+
const approvedForSession = approved[input.sessionID] || {};
|
|
367
|
+
const keys = toKeys(input.pattern, input.type);
|
|
368
|
+
if (covered(keys, approvedForSession)) return;
|
|
369
|
+
const info: Info = {
|
|
370
|
+
id: Identifier.ascending('permission'),
|
|
371
|
+
type: input.type,
|
|
372
|
+
pattern: input.pattern,
|
|
373
|
+
sessionID: input.sessionID,
|
|
374
|
+
messageID: input.messageID,
|
|
375
|
+
callID: input.callID,
|
|
376
|
+
title: input.title,
|
|
377
|
+
metadata: input.metadata,
|
|
378
|
+
time: {
|
|
379
|
+
created: Date.now(),
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
pending[input.sessionID] = pending[input.sessionID] || {};
|
|
384
|
+
return new Promise<void>((resolve, reject) => {
|
|
385
|
+
pending[input.sessionID][info.id] = {
|
|
386
|
+
info,
|
|
387
|
+
resolve,
|
|
388
|
+
reject,
|
|
389
|
+
};
|
|
390
|
+
Bus.publish(Event.Updated, info);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function respond(input: {
|
|
395
|
+
sessionID: Info['sessionID'];
|
|
396
|
+
permissionID: Info['id'];
|
|
397
|
+
response: Response;
|
|
398
|
+
}) {
|
|
399
|
+
log.info(() => ({ message: 'response', ...input }));
|
|
400
|
+
const { pending, approved } = state();
|
|
401
|
+
const match = pending[input.sessionID]?.[input.permissionID];
|
|
402
|
+
if (!match) return;
|
|
403
|
+
delete pending[input.sessionID][input.permissionID];
|
|
404
|
+
Bus.publish(Event.Replied, {
|
|
405
|
+
sessionID: input.sessionID,
|
|
406
|
+
permissionID: input.permissionID,
|
|
407
|
+
response: input.response,
|
|
408
|
+
});
|
|
409
|
+
if (input.response === 'reject') {
|
|
410
|
+
match.reject(
|
|
411
|
+
new RejectedError(
|
|
412
|
+
input.sessionID,
|
|
413
|
+
input.permissionID,
|
|
414
|
+
match.info.callID,
|
|
415
|
+
match.info.metadata
|
|
416
|
+
)
|
|
417
|
+
);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
match.resolve();
|
|
421
|
+
if (input.response === 'always') {
|
|
422
|
+
approved[input.sessionID] = approved[input.sessionID] || {};
|
|
423
|
+
const approveKeys = toKeys(match.info.pattern, match.info.type);
|
|
424
|
+
for (const k of approveKeys) {
|
|
425
|
+
approved[input.sessionID][k] = true;
|
|
426
|
+
}
|
|
427
|
+
const items = pending[input.sessionID];
|
|
428
|
+
if (!items) return;
|
|
429
|
+
for (const item of Object.values(items)) {
|
|
430
|
+
const itemKeys = toKeys(item.info.pattern, item.info.type);
|
|
431
|
+
if (covered(itemKeys, approved[input.sessionID])) {
|
|
432
|
+
respond({
|
|
433
|
+
sessionID: item.info.sessionID,
|
|
434
|
+
permissionID: item.info.id,
|
|
435
|
+
response: input.response,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export class RejectedError extends Error {
|
|
443
|
+
constructor(
|
|
444
|
+
public readonly sessionID: string,
|
|
445
|
+
public readonly permissionID: string,
|
|
446
|
+
public readonly toolCallID?: string,
|
|
447
|
+
public readonly metadata?: Record<string, any>,
|
|
448
|
+
public readonly reason?: string
|
|
449
|
+
) {
|
|
450
|
+
super(
|
|
451
|
+
reason !== undefined
|
|
452
|
+
? reason
|
|
453
|
+
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ─── High-level helpers used by tools ─────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Enforce the policy for a simple (non-bash) tool type (`edit`/`webfetch`).
|
|
462
|
+
* Throws RejectedError on deny, asks (and may throw) on ask, returns on allow.
|
|
463
|
+
*/
|
|
464
|
+
export async function check(input: {
|
|
465
|
+
type: 'edit' | 'webfetch';
|
|
466
|
+
title: string;
|
|
467
|
+
sessionID: string;
|
|
468
|
+
messageID: string;
|
|
469
|
+
callID?: string;
|
|
470
|
+
metadata?: Record<string, any>;
|
|
471
|
+
}) {
|
|
472
|
+
const p = policy();
|
|
473
|
+
const action: Action = input.type === 'edit' ? p.edit : p.webfetch;
|
|
474
|
+
if (action === 'allow') return;
|
|
475
|
+
if (action === 'deny') {
|
|
476
|
+
throw new RejectedError(
|
|
477
|
+
input.sessionID,
|
|
478
|
+
input.callID ?? input.messageID,
|
|
479
|
+
input.callID,
|
|
480
|
+
input.metadata,
|
|
481
|
+
`The ${input.type} tool is disabled in the current permission mode (${config.permissionMode ?? 'auto'}).`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
await ask({
|
|
485
|
+
type: input.type,
|
|
486
|
+
title: input.title,
|
|
487
|
+
sessionID: input.sessionID,
|
|
488
|
+
messageID: input.messageID,
|
|
489
|
+
callID: input.callID,
|
|
490
|
+
metadata: input.metadata ?? {},
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Whether bash enforcement is active. In the default `auto` mode every
|
|
496
|
+
* pattern maps to `allow`, so callers can skip the (non-trivial) tree-sitter
|
|
497
|
+
* parse entirely — preserving zero overhead for the full-auto default.
|
|
498
|
+
*/
|
|
499
|
+
export function bashEnforced(): boolean {
|
|
500
|
+
const patterns = policy().bash;
|
|
501
|
+
return Object.values(patterns).some((a) => a !== 'allow');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Resolve the ask-pattern for a single command's token array. */
|
|
505
|
+
function askPattern(command: string[]): string {
|
|
506
|
+
const head = command[0];
|
|
507
|
+
const sub = command.slice(1).find((arg) => !arg.startsWith('-'));
|
|
508
|
+
return sub ? `${head} ${sub} *` : `${head} *`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Enforce the bash policy for the extracted command nodes of a shell line.
|
|
513
|
+
*
|
|
514
|
+
* `commands` is the list of token arrays for every `command` node parsed out
|
|
515
|
+
* of the shell line (e.g. via tree-sitter), so command substitutions and
|
|
516
|
+
* chained commands are each evaluated independently — faithful to OpenCode's
|
|
517
|
+
* bash tool. `cd` is always allowed. A `deny` match throws `RejectedError`; any
|
|
518
|
+
* `ask` matches are batched into a single `Permission.ask`.
|
|
519
|
+
*/
|
|
520
|
+
export async function checkBashTokens(input: {
|
|
521
|
+
commands: string[][];
|
|
522
|
+
command: string;
|
|
523
|
+
sessionID: string;
|
|
524
|
+
messageID: string;
|
|
525
|
+
callID?: string;
|
|
526
|
+
}) {
|
|
527
|
+
const patterns = policy().bash;
|
|
528
|
+
const askPatterns = new Set<string>();
|
|
529
|
+
for (const command of input.commands) {
|
|
530
|
+
if (command.length === 0) continue;
|
|
531
|
+
if (command[0] === 'cd') continue;
|
|
532
|
+
const action = Wildcard.allStructured(
|
|
533
|
+
{ head: command[0], tail: command.slice(1) },
|
|
534
|
+
patterns
|
|
535
|
+
) as Action | undefined;
|
|
536
|
+
if (action === 'deny') {
|
|
537
|
+
throw new RejectedError(
|
|
538
|
+
input.sessionID,
|
|
539
|
+
input.callID ?? input.messageID,
|
|
540
|
+
input.callID,
|
|
541
|
+
{ command: input.command },
|
|
542
|
+
`The command "${command[0]}" is not allowed in the current permission mode (${config.permissionMode ?? 'auto'}). Configuration: ${JSON.stringify(
|
|
543
|
+
patterns
|
|
544
|
+
)}`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (action === 'ask') {
|
|
548
|
+
askPatterns.add(askPattern(command));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (askPatterns.size > 0) {
|
|
552
|
+
const list = Array.from(askPatterns);
|
|
553
|
+
await ask({
|
|
554
|
+
type: 'bash',
|
|
555
|
+
pattern: list,
|
|
556
|
+
sessionID: input.sessionID,
|
|
557
|
+
messageID: input.messageID,
|
|
558
|
+
callID: input.callID,
|
|
559
|
+
title: input.command,
|
|
560
|
+
metadata: { command: input.command, patterns: list },
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
package/src/session/prompt.ts
CHANGED
|
@@ -1128,6 +1128,12 @@ export namespace SessionPrompt {
|
|
|
1128
1128
|
),
|
|
1129
1129
|
mergeDeep(input.tools ?? {})
|
|
1130
1130
|
);
|
|
1131
|
+
// Enforce read-only / planning mode and explicit --disable-tools. These
|
|
1132
|
+
// denials are applied last so they always win over agent and per-message
|
|
1133
|
+
// tool overrides (issue #271).
|
|
1134
|
+
for (const id of ToolRegistry.deniedTools()) {
|
|
1135
|
+
enabledTools[id] = false;
|
|
1136
|
+
}
|
|
1131
1137
|
for (const item of await ToolRegistry.tools(
|
|
1132
1138
|
input.model.providerID,
|
|
1133
1139
|
input.model.modelID
|
package/src/session/system.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Ripgrep } from '../file/ripgrep';
|
|
|
2
2
|
import { Global } from '../global';
|
|
3
3
|
import { Filesystem } from '../util/filesystem';
|
|
4
4
|
import { Config } from '../config/file-config';
|
|
5
|
+
import { config as runtimeConfig } from '../config/config';
|
|
5
6
|
|
|
6
7
|
import { Instance } from '../project/instance';
|
|
7
8
|
import path from 'path';
|
|
@@ -42,6 +43,18 @@ export namespace SystemPrompt {
|
|
|
42
43
|
|
|
43
44
|
export async function environment() {
|
|
44
45
|
const project = Instance.project;
|
|
46
|
+
const readOnlyNote = runtimeConfig.readOnly
|
|
47
|
+
? [
|
|
48
|
+
``,
|
|
49
|
+
`<read_only_mode>`,
|
|
50
|
+
` You are running in read-only / planning mode. Tools that modify the`,
|
|
51
|
+
` filesystem or execute shell commands (bash, edit, write, multiedit,`,
|
|
52
|
+
` patch) are disabled and will be rejected if attempted. You may only`,
|
|
53
|
+
` read, search, and plan. Describe the changes you would make instead`,
|
|
54
|
+
` of attempting to apply them.`,
|
|
55
|
+
`</read_only_mode>`,
|
|
56
|
+
].join('\n')
|
|
57
|
+
: '';
|
|
45
58
|
return [
|
|
46
59
|
[
|
|
47
60
|
`Here is some useful information about the environment you are running in:`,
|
|
@@ -51,6 +64,7 @@ export namespace SystemPrompt {
|
|
|
51
64
|
` Platform: ${process.platform}`,
|
|
52
65
|
` Today's date: ${new Date().toDateString()}`,
|
|
53
66
|
`</env>`,
|
|
67
|
+
readOnlyNote,
|
|
54
68
|
`<files>`,
|
|
55
69
|
` ${
|
|
56
70
|
project.vcs === 'git'
|
package/src/tool/bash.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { lazy } from '../util/lazy';
|
|
|
8
8
|
import { Language } from 'web-tree-sitter';
|
|
9
9
|
import { $ } from 'bun';
|
|
10
10
|
import { Filesystem } from '../util/filesystem';
|
|
11
|
+
import { Permission } from '../permission';
|
|
11
12
|
import { fileURLToPath } from 'url';
|
|
12
13
|
|
|
13
14
|
const MAX_OUTPUT_LENGTH = 30_000;
|
|
@@ -71,7 +72,46 @@ export const BashTool = Tool.define('bash', {
|
|
|
71
72
|
}
|
|
72
73
|
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
|
|
73
74
|
|
|
74
|
-
//
|
|
75
|
+
// Permission enforcement (issue #271). In the default `auto` mode every
|
|
76
|
+
// bash pattern resolves to `allow`, so we skip the tree-sitter parse
|
|
77
|
+
// entirely and keep zero overhead. In plan/readonly/ask modes (or with a
|
|
78
|
+
// `--permission` override) we parse the shell line into its individual
|
|
79
|
+
// command nodes and evaluate each against the bash policy: a `deny` match
|
|
80
|
+
// throws, `ask` matches are batched into a single JSON permission request.
|
|
81
|
+
if (Permission.bashEnforced()) {
|
|
82
|
+
const tree = await parser().then((p) => p.parse(params.command));
|
|
83
|
+
if (!tree) {
|
|
84
|
+
throw new Error('Failed to parse command');
|
|
85
|
+
}
|
|
86
|
+
const commands: string[][] = [];
|
|
87
|
+
for (const node of tree.rootNode.descendantsOfType('command')) {
|
|
88
|
+
if (!node) continue;
|
|
89
|
+
const command: string[] = [];
|
|
90
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
91
|
+
const child = node.child(i);
|
|
92
|
+
if (!child) continue;
|
|
93
|
+
if (
|
|
94
|
+
child.type !== 'command_name' &&
|
|
95
|
+
child.type !== 'word' &&
|
|
96
|
+
child.type !== 'string' &&
|
|
97
|
+
child.type !== 'raw_string' &&
|
|
98
|
+
child.type !== 'concatenation'
|
|
99
|
+
) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
command.push(child.text);
|
|
103
|
+
}
|
|
104
|
+
commands.push(command);
|
|
105
|
+
}
|
|
106
|
+
await Permission.checkBashTokens({
|
|
107
|
+
commands,
|
|
108
|
+
command: params.command,
|
|
109
|
+
sessionID: ctx.sessionID,
|
|
110
|
+
messageID: ctx.messageID,
|
|
111
|
+
callID: ctx.callID,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
75
115
|
const proc = spawn(params.command, {
|
|
76
116
|
shell: true,
|
|
77
117
|
cwd: Instance.directory,
|
package/src/tool/batch.ts
CHANGED
|
@@ -54,6 +54,14 @@ export const BatchTool = Tool.define('batch', async () => {
|
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Enforce read-only / planning mode and --disable-tools even when a
|
|
58
|
+
// tool is invoked indirectly through batch (issue #271).
|
|
59
|
+
if (ToolRegistry.deniedTools().has(call.tool)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Tool '${call.tool}' is disabled in read-only / planning mode and cannot be executed.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
const tool = toolMap.get(call.tool);
|
|
58
66
|
if (!tool) {
|
|
59
67
|
const availableToolsList = Array.from(toolMap.keys()).filter(
|
package/src/tool/edit.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { Bus } from '../bus';
|
|
|
13
13
|
import { FileTime } from '../file/time';
|
|
14
14
|
import { Instance } from '../project/instance';
|
|
15
15
|
import { Snapshot } from '../snapshot';
|
|
16
|
+
import { Permission } from '../permission';
|
|
16
17
|
|
|
17
18
|
function normalizeLineEndings(text: string): string {
|
|
18
19
|
return text.replaceAll('\r\n', '\n');
|
|
@@ -42,11 +43,21 @@ export const EditTool = Tool.define('edit', {
|
|
|
42
43
|
throw new Error('oldString and newString must be different');
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
// No restrictions - unrestricted file editing
|
|
46
46
|
const filePath = path.isAbsolute(params.filePath)
|
|
47
47
|
? params.filePath
|
|
48
48
|
: path.join(Instance.directory, params.filePath);
|
|
49
49
|
|
|
50
|
+
// Permission enforcement (issue #271). `auto` mode allows immediately;
|
|
51
|
+
// plan/readonly modes deny; `ask` mode emits a JSON permission request.
|
|
52
|
+
await Permission.check({
|
|
53
|
+
type: 'edit',
|
|
54
|
+
title: filePath,
|
|
55
|
+
sessionID: ctx.sessionID,
|
|
56
|
+
messageID: ctx.messageID,
|
|
57
|
+
callID: ctx.callID,
|
|
58
|
+
metadata: { filePath },
|
|
59
|
+
});
|
|
60
|
+
|
|
50
61
|
let diff = '';
|
|
51
62
|
let contentOld = '';
|
|
52
63
|
let contentNew = '';
|
package/src/tool/patch.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { FileWatcher } from '../file/watcher';
|
|
|
8
8
|
import { Instance } from '../project/instance';
|
|
9
9
|
import { Patch } from '../patch';
|
|
10
10
|
import { Filesystem } from '../util/filesystem';
|
|
11
|
+
import { Permission } from '../permission';
|
|
11
12
|
import { createTwoFilesPatch } from 'diff';
|
|
12
13
|
|
|
13
14
|
const PatchParams = z.object({
|
|
@@ -141,7 +142,24 @@ export const PatchTool = Tool.define('patch', {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
//
|
|
145
|
+
// Permission enforcement (issue #271). Patching mutates the filesystem and
|
|
146
|
+
// is governed by the `edit` policy. `auto` allows; plan/readonly deny;
|
|
147
|
+
// `ask` emits a JSON permission request per affected file.
|
|
148
|
+
for (const change of fileChanges) {
|
|
149
|
+
await Permission.check({
|
|
150
|
+
type: 'edit',
|
|
151
|
+
title: change.movePath ?? change.filePath,
|
|
152
|
+
sessionID: ctx.sessionID,
|
|
153
|
+
messageID: ctx.messageID,
|
|
154
|
+
callID: ctx.callID,
|
|
155
|
+
metadata: {
|
|
156
|
+
filePath: change.filePath,
|
|
157
|
+
movePath: change.movePath,
|
|
158
|
+
changeType: change.type,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
145
163
|
// Apply the changes
|
|
146
164
|
const changedFiles: string[] = [];
|
|
147
165
|
|
package/src/tool/registry.ts
CHANGED
|
@@ -16,8 +16,44 @@ import { Instance } from '../project/instance';
|
|
|
16
16
|
import { Config } from '../config/file-config';
|
|
17
17
|
import { WebSearchTool } from './websearch';
|
|
18
18
|
import { CodeSearchTool } from './codesearch';
|
|
19
|
+
import { config } from '../config/config';
|
|
19
20
|
|
|
20
21
|
export namespace ToolRegistry {
|
|
22
|
+
/**
|
|
23
|
+
* Tools that can mutate the filesystem or execute shell commands.
|
|
24
|
+
* These are disabled when read-only / planning mode is active so that the
|
|
25
|
+
* agent can only read, search and plan. This is the native, enforceable
|
|
26
|
+
* counterpart of agent-commander's `--read-only` flag (issue #271).
|
|
27
|
+
*/
|
|
28
|
+
export const READ_ONLY_DENIED_TOOLS = [
|
|
29
|
+
'bash',
|
|
30
|
+
'edit',
|
|
31
|
+
'write',
|
|
32
|
+
'multiedit',
|
|
33
|
+
'patch',
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the set of tool ids that must be denied for the current run.
|
|
38
|
+
*
|
|
39
|
+
* Combines the read-only/planning preset (`config.readOnly`) with any
|
|
40
|
+
* explicit `--disable-tools a,b,c` list. The result is enforced both when
|
|
41
|
+
* exposing tools to the model and when tools are invoked indirectly via the
|
|
42
|
+
* `batch` tool, so the restriction cannot be bypassed.
|
|
43
|
+
*/
|
|
44
|
+
export function deniedTools(): Set<string> {
|
|
45
|
+
const denied = new Set<string>();
|
|
46
|
+
if (config.readOnly) {
|
|
47
|
+
for (const id of READ_ONLY_DENIED_TOOLS) denied.add(id);
|
|
48
|
+
}
|
|
49
|
+
const list = config.disableTools ?? '';
|
|
50
|
+
for (const raw of list.split(',')) {
|
|
51
|
+
const id = raw.trim().toLowerCase();
|
|
52
|
+
if (id) denied.add(id);
|
|
53
|
+
}
|
|
54
|
+
return denied;
|
|
55
|
+
}
|
|
56
|
+
|
|
21
57
|
export const state = Instance.state(async () => {
|
|
22
58
|
const custom = [] as Tool.Info[];
|
|
23
59
|
return { custom };
|
package/src/tool/webfetch.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Tool } from './tool';
|
|
|
3
3
|
import TurndownService from 'turndown';
|
|
4
4
|
import DESCRIPTION from './webfetch.txt';
|
|
5
5
|
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
6
|
+
import { Permission } from '../permission';
|
|
6
7
|
|
|
7
8
|
const verboseFetch = createVerboseFetch(fetch, { caller: 'webfetch' });
|
|
8
9
|
|
|
@@ -33,7 +34,17 @@ export const WebFetchTool = Tool.define('webfetch', {
|
|
|
33
34
|
throw new Error('URL must start with http:// or https://');
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
//
|
|
37
|
+
// Permission enforcement (issue #271). `auto` allows; plan/readonly allow
|
|
38
|
+
// (webfetch is read-only); `ask` emits a JSON permission request.
|
|
39
|
+
await Permission.check({
|
|
40
|
+
type: 'webfetch',
|
|
41
|
+
title: params.url,
|
|
42
|
+
sessionID: ctx.sessionID,
|
|
43
|
+
messageID: ctx.messageID,
|
|
44
|
+
callID: ctx.callID,
|
|
45
|
+
metadata: { url: params.url },
|
|
46
|
+
});
|
|
47
|
+
|
|
37
48
|
const timeout = Math.min(
|
|
38
49
|
(params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000,
|
|
39
50
|
MAX_TIMEOUT
|
package/src/tool/write.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from 'path';
|
|
|
3
3
|
import { Tool } from './tool';
|
|
4
4
|
import DESCRIPTION from './write.txt';
|
|
5
5
|
import { Instance } from '../project/instance';
|
|
6
|
+
import { Permission } from '../permission';
|
|
6
7
|
|
|
7
8
|
export const WriteTool = Tool.define('write', {
|
|
8
9
|
description: DESCRIPTION,
|
|
@@ -15,11 +16,22 @@ export const WriteTool = Tool.define('write', {
|
|
|
15
16
|
),
|
|
16
17
|
}),
|
|
17
18
|
async execute(params, ctx) {
|
|
18
|
-
// No restrictions - unrestricted file system access
|
|
19
19
|
const filepath = path.isAbsolute(params.filePath)
|
|
20
20
|
? params.filePath
|
|
21
21
|
: path.join(Instance.directory, params.filePath);
|
|
22
22
|
|
|
23
|
+
// Permission enforcement (issue #271). Writing is governed by the same
|
|
24
|
+
// `edit` policy as the edit tool: `auto` allows, plan/readonly deny,
|
|
25
|
+
// `ask` emits a JSON permission request.
|
|
26
|
+
await Permission.check({
|
|
27
|
+
type: 'edit',
|
|
28
|
+
title: filepath,
|
|
29
|
+
sessionID: ctx.sessionID,
|
|
30
|
+
messageID: ctx.messageID,
|
|
31
|
+
callID: ctx.callID,
|
|
32
|
+
metadata: { filePath: filepath },
|
|
33
|
+
});
|
|
34
|
+
|
|
23
35
|
const file = Bun.file(filepath);
|
|
24
36
|
const exists = await file.exists();
|
|
25
37
|
|