@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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;
@@ -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' &&
@@ -30,18 +30,47 @@ export class InputQueue {
30
30
  return parseStreamJsonInput(trimmed);
31
31
  }
32
32
 
33
+ let parsed;
33
34
  try {
34
- const parsed = JSON.parse(trimmed);
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) {
@@ -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
  }
@@ -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
+ }
@@ -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(
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
- // No restrictions - apply changes directly
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
 
@@ -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 };
@@ -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
- // No restrictions - unrestricted web fetch
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