@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.
@@ -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
+ }
@@ -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
@@ -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
- // No restrictions - unrestricted command execution
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(