@link-assistant/agent 0.23.0 → 0.24.1
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 +31 -0
- package/package.json +1 -1
- package/src/cli/continuous-mode.js +44 -112
- package/src/cli/event-handler.js +108 -66
- 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/json-standard/index.ts +15 -2
- 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
|
@@ -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(
|