typeclaw 0.30.1 → 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -49,6 +49,7 @@ import {
49
49
  } from './plugin-tools'
50
50
  import { createReloadTool } from './reload-tool'
51
51
  import type { RestartHandoffOrigin } from './restart-handoff'
52
+ import type { SubagentBashPolicy } from './reviewer-bash-policy'
52
53
  import { loadSelf } from './self'
53
54
  import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
54
55
  import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
@@ -147,6 +148,11 @@ export type CreateSessionOptions = {
147
148
  // wider plugin registry's tools are NOT injected. Used by plugin subagent
148
149
  // session creation so subagents see exactly what they declared.
149
150
  pluginSubagent?: PluginSubagentSelection
151
+ // Per-subagent bash capability restriction. Threaded to the bash-tool wrapper
152
+ // and enforced before the role-derived sandbox, so a read-only subagent's
153
+ // bash stays read-only regardless of the spawning role. See
154
+ // `src/agent/reviewer-bash-policy.ts`.
155
+ bashPolicy?: SubagentBashPolicy
150
156
  // Enables the `restart` tool. Set when the agent is running inside a
151
157
  // typeclaw-managed container. Read from TYPECLAW_CONTAINER_NAME at the call site.
152
158
  containerName?: string
@@ -411,6 +417,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
411
417
  getOrigin,
412
418
  getAbort,
413
419
  ...(options.permissions ? { permissions: options.permissions } : {}),
420
+ ...(options.bashPolicy !== undefined ? { bashPolicy: options.bashPolicy } : {}),
414
421
  })
415
422
  : []
416
423
  const wrappedCustomSystemTools = wrapSystemTools(customSystemTools, options.plugins, getOrigin, getAbort)
@@ -49,6 +49,7 @@ import {
49
49
 
50
50
  import { createLoopGuard, type LoopGuard, type LoopGuardDecision } from './loop-guard'
51
51
  import { checkImageReadRedirect } from './multimodal/read-redirect'
52
+ import { enforceSubagentBashPolicy, type SubagentBashPolicy } from './reviewer-bash-policy'
52
53
  import type { SessionOrigin } from './session-origin'
53
54
  import { SUBAGENT_OUTPUT_TOOL_NAME, type SubagentOutputToolDetails } from './tools/subagent-output'
54
55
  import { webFetchTool } from './tools/webfetch'
@@ -193,6 +194,11 @@ export type WrapSystemToolOptions = {
193
194
  // runs bash unchanged — preserving today's behavior for trusted+ and for
194
195
  // sessions wired without a permission service (e.g. tests).
195
196
  permissions?: PermissionService
197
+ // Per-subagent bash capability policy, enforced as a hard pre-check BEFORE
198
+ // the role-derived sandbox (which returns early for trusted/owner). Lets a
199
+ // read-only subagent keep its bash read-only no matter who spawned it. See
200
+ // `src/agent/reviewer-bash-policy.ts`.
201
+ bashPolicy?: SubagentBashPolicy
196
202
  }
197
203
 
198
204
  // Zod 4 emits a top-level `"$schema": "https://json-schema.org/draft/2020-12/schema"`
@@ -461,6 +467,16 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
461
467
  }
462
468
  stripGuardAcknowledgements(mutableArgs)
463
469
 
470
+ // Per-subagent capability fence: runs BEFORE the role-derived sandbox so
471
+ // a read-only subagent's bash stays read-only even for a trusted/owner
472
+ // caller (for whom applyBashSandbox returns early with no masks). Throws
473
+ // SubagentBashPolicyError on a disallowed command, surfaced to the model
474
+ // as a tool error.
475
+ if (tool.name === 'bash' && opts.bashPolicy !== undefined) {
476
+ const command = mutableArgs.command
477
+ if (typeof command === 'string') enforceSubagentBashPolicy(opts.bashPolicy, command)
478
+ }
479
+
464
480
  if (tool.name === 'bash' && opts.permissions !== undefined) {
465
481
  await applyBashSandbox(mutableArgs, opts.permissions, liveOrigin, opts.agentDir, opts.sessionId, bashEnvOverlay)
466
482
  }
@@ -0,0 +1,572 @@
1
+ // Per-subagent bash capability policy. This is NOT the bwrap filesystem
2
+ // sandbox (src/sandbox/) — that is role-derived and returns early for
3
+ // trusted/owner callers. This is a subagent-capability boundary that must hold
4
+ // regardless of who spawned the subagent, so it is enforced as a standalone
5
+ // pre-check at the bash-wrap site before applyBashSandbox runs.
6
+ //
7
+ // Design (issue #452): the `reviewer` subagent is read-only by contract, but
8
+ // its legitimate workflows use pipes (`gh api … | base64 -d | nl -ba`), `&&`
9
+ // chains, and writes to a throwaway `/tmp` scratch checkout. A prefix
10
+ // allowlist plus a metacharacter ban (the SandboxCommandFilter primitive)
11
+ // cannot express "a pipeline of read-only commands", so this policy instead:
12
+ // 1. fails closed on shell constructs that defeat static analysis
13
+ // (command/process substitution, heredocs, `eval`/`sh -c` wrappers,
14
+ // redirects to non-/tmp paths, unbalanced quotes);
15
+ // 2. splits the remaining command on top-level `|` `&&` `||` `;` with a
16
+ // quote/escape-aware scanner;
17
+ // 3. classifies each segment's leading verb against a read-only allowlist and
18
+ // a mutating-subcommand denylist, with path-sensitive handling for the few
19
+ // verbs (git checkout/clone, file writers) that are safe only under /tmp.
20
+ // It is defense-in-depth layered on top of the global exfil guards, not the
21
+ // sole fence — so "deny what we cannot prove safe" is the correct bias.
22
+
23
+ export type SubagentBashPolicy = { kind: 'readonly-reviewer' }
24
+
25
+ export class SubagentBashPolicyError extends Error {
26
+ constructor(message: string) {
27
+ super(message)
28
+ this.name = 'SubagentBashPolicyError'
29
+ }
30
+ }
31
+
32
+ // Constructs that let a benign-looking string smuggle an arbitrary command past
33
+ // segment/verb analysis. `$(`/backtick = command substitution; `<(`/`>(` =
34
+ // process substitution; `<<` = heredoc; `${` is allowed (plain var expansion is
35
+ // harmless for our denylist) but `$((` arithmetic and `$(` are not. We reject
36
+ // the whole command if any appear — the reviewer's documented workflows need
37
+ // none of them.
38
+ const FAIL_CLOSED_CONSTRUCTS: { pattern: RegExp; reason: string }[] = [
39
+ { pattern: /\$\(/, reason: 'command substitution `$(…)`' },
40
+ { pattern: /`/, reason: 'backtick command substitution' },
41
+ { pattern: /<\(/, reason: 'process substitution `<(…)`' },
42
+ { pattern: />\(/, reason: 'process substitution `>(…)`' },
43
+ { pattern: /<</, reason: 'heredoc `<<`' },
44
+ ]
45
+
46
+ // Wrapper verbs that re-enter a shell or hand execution to another command,
47
+ // defeating verb analysis (`bash -c "git push"`, `xargs rm`, `find … -exec`).
48
+ // Denied outright as a leading verb.
49
+ const FORBIDDEN_WRAPPER_VERBS = new Set([
50
+ 'eval',
51
+ 'exec',
52
+ 'source',
53
+ '.',
54
+ 'sh',
55
+ 'bash',
56
+ 'zsh',
57
+ 'dash',
58
+ 'env',
59
+ 'command',
60
+ 'xargs',
61
+ 'find',
62
+ 'parallel',
63
+ 'time',
64
+ 'nohup',
65
+ 'sudo',
66
+ 'doas',
67
+ 'ssh',
68
+ ])
69
+
70
+ // Leading verbs that are read-only and need no further inspection.
71
+ const READONLY_VERBS = new Set([
72
+ 'cat',
73
+ 'head',
74
+ 'tail',
75
+ 'wc',
76
+ 'sort',
77
+ 'uniq',
78
+ 'cut',
79
+ 'tr',
80
+ 'nl',
81
+ 'base64',
82
+ 'jq',
83
+ 'yq',
84
+ 'grep',
85
+ 'rg',
86
+ 'egrep',
87
+ 'fgrep',
88
+ 'ls',
89
+ 'pwd',
90
+ 'echo',
91
+ 'printf',
92
+ 'true',
93
+ 'false',
94
+ 'test',
95
+ 'dirname',
96
+ 'basename',
97
+ 'realpath',
98
+ 'date',
99
+ 'sed', // read-only as used (no -i); -i is denied below
100
+ 'awk',
101
+ 'diff',
102
+ 'comm',
103
+ 'column',
104
+ 'fold',
105
+ 'rev',
106
+ 'tee', // path-checked below
107
+ ])
108
+
109
+ // `git` subcommands that never mutate the working tree or remote.
110
+ const GIT_READONLY_SUBCOMMANDS = new Set([
111
+ 'log',
112
+ 'diff',
113
+ 'show',
114
+ 'blame',
115
+ 'status',
116
+ 'grep',
117
+ 'rev-parse',
118
+ 'rev-list',
119
+ 'ls-files',
120
+ 'ls-tree',
121
+ 'cat-file',
122
+ 'describe',
123
+ 'shortlog',
124
+ 'config', // read form only; --add/--set caught by the write-flag check
125
+ 'remote', // `git remote -v` is read; mutating forms caught below
126
+ 'branch', // `git branch` (list) is read; create/delete caught below
127
+ 'tag', // `git tag` (list) is read; create/delete caught below
128
+ 'name-rev',
129
+ 'merge-base',
130
+ 'symbolic-ref',
131
+ 'for-each-ref',
132
+ 'show-ref',
133
+ 'reflog',
134
+ 'whatchanged',
135
+ ])
136
+
137
+ // `git` subcommands that mutate the working tree, index, or remote. Denied
138
+ // unless the whole git invocation is scoped to a /tmp working dir (scratch
139
+ // clone): `clone`/`fetch`/`checkout` into /tmp are the reviewer's acquisition
140
+ // path; everything else stays denied even under /tmp because it has no
141
+ // legitimate reviewer use.
142
+ const GIT_MUTATING_ALWAYS_DENIED = new Set([
143
+ 'add',
144
+ 'commit',
145
+ 'push',
146
+ 'rebase',
147
+ 'reset',
148
+ 'merge',
149
+ 'cherry-pick',
150
+ 'revert',
151
+ 'am',
152
+ 'apply',
153
+ 'stash',
154
+ 'clean',
155
+ 'rm',
156
+ 'mv',
157
+ 'restore',
158
+ 'switch',
159
+ 'gc',
160
+ 'prune',
161
+ 'update-ref',
162
+ 'update-index',
163
+ 'write-tree',
164
+ 'commit-tree',
165
+ 'hash-object',
166
+ ])
167
+
168
+ // git subcommands permitted only when the effective working dir is /tmp.
169
+ const GIT_TMP_SCOPED = new Set(['clone', 'fetch', 'checkout', 'init', 'sparse-checkout', 'worktree'])
170
+
171
+ // `gh` subcommands/objects that mutate remote state. The reviewer reads PRs and
172
+ // repos; it never merges, reviews, comments, edits, or creates. We allow the
173
+ // read objects explicitly and deny the rest, because `gh` is the highest-value
174
+ // mutation surface (it can approve PRs, which the reviewer must NEVER do — the
175
+ // parent owns posting).
176
+ const GH_READONLY_BY_OBJECT: Record<string, Set<string>> = {
177
+ pr: new Set(['view', 'diff', 'list', 'checks', 'status']),
178
+ issue: new Set(['view', 'list', 'status']),
179
+ repo: new Set(['view', 'list']),
180
+ release: new Set(['view', 'list']),
181
+ run: new Set(['view', 'list']),
182
+ api: new Set(['__any__']), // gh api is method-checked below
183
+ search: new Set(['__any__']),
184
+ browse: new Set(['__any__']),
185
+ }
186
+
187
+ // Filesystem mutators that are safe only when every path operand is under /tmp.
188
+ const FS_WRITERS = new Set(['rm', 'mv', 'cp', 'mkdir', 'touch', 'chmod', 'chown', 'ln', 'rmdir', 'truncate'])
189
+
190
+ const TMP_PREFIXES = ['/tmp/', '/private/tmp/']
191
+
192
+ function isTmpPath(token: string): boolean {
193
+ const unquoted = stripQuotes(token)
194
+ return unquoted === '/tmp' || TMP_PREFIXES.some((p) => unquoted.startsWith(p))
195
+ }
196
+
197
+ function stripQuotes(token: string): string {
198
+ if (token.length >= 2) {
199
+ const first = token[0]
200
+ const last = token[token.length - 1]
201
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
202
+ return token.slice(1, -1)
203
+ }
204
+ }
205
+ return token
206
+ }
207
+
208
+ // Quote/escape-aware tokenizer that ALSO surfaces top-level operators and
209
+ // redirects. Returns the token list plus the set of redirect targets so the
210
+ // caller can fail closed on a redirect to a non-/tmp path. Throws on unbalanced
211
+ // quotes (fail closed — an unterminated quote means we cannot trust the split).
212
+ type Segment = { tokens: string[]; redirectTargets: string[] }
213
+
214
+ function splitIntoSegments(command: string): Segment[] {
215
+ const segments: Segment[] = []
216
+ let tokens: string[] = []
217
+ let redirectTargets: string[] = []
218
+ let current = ''
219
+ let quote: '"' | "'" | null = null
220
+ let expectingRedirectTarget = false
221
+
222
+ const pushToken = () => {
223
+ if (current.length === 0) return
224
+ if (expectingRedirectTarget) {
225
+ redirectTargets.push(current)
226
+ expectingRedirectTarget = false
227
+ } else {
228
+ tokens.push(current)
229
+ }
230
+ current = ''
231
+ }
232
+ const pushSegment = () => {
233
+ pushToken()
234
+ segments.push({ tokens, redirectTargets })
235
+ tokens = []
236
+ redirectTargets = []
237
+ }
238
+
239
+ for (let i = 0; i < command.length; i++) {
240
+ const ch = command[i]!
241
+ if (quote !== null) {
242
+ current += ch
243
+ if (ch === quote) quote = null
244
+ continue
245
+ }
246
+ if (ch === '"' || ch === "'") {
247
+ quote = ch
248
+ current += ch
249
+ continue
250
+ }
251
+ if (ch === '\\') {
252
+ current += ch
253
+ if (i + 1 < command.length) {
254
+ current += command[i + 1]
255
+ i++
256
+ }
257
+ continue
258
+ }
259
+ if (ch === '|' || ch === '&' || ch === ';' || ch === '\n' || ch === '\r') {
260
+ const next = command[i + 1]
261
+ // `|`, `||`, `&&`, `;`, and a NEWLINE all start a new top-level segment.
262
+ // bash treats an unquoted newline as a command separator exactly like `;`,
263
+ // so failing to split on it would let `git status\ngit push` parse as one
264
+ // allowed `git status` segment while bash runs `git push` separately. A
265
+ // lone `&` (background) is treated the same — we don't run backgrounded jobs.
266
+ if ((ch === '|' && next === '|') || (ch === '&' && next === '&')) i++
267
+ pushSegment()
268
+ continue
269
+ }
270
+ if (ch === '>' || ch === '<') {
271
+ // Redirect operator. The following word is a path target we must
272
+ // path-check. `2>`, `&>` handled by the trailing-fd char already being in
273
+ // `current` — flush it as a token first.
274
+ pushToken()
275
+ // consume an optional second char (>>, 2>, &>)
276
+ if (command[i + 1] === '>') i++
277
+ expectingRedirectTarget = true
278
+ continue
279
+ }
280
+ if (ch === ' ' || ch === '\t') {
281
+ pushToken()
282
+ continue
283
+ }
284
+ current += ch
285
+ }
286
+ if (quote !== null) {
287
+ throw new SubagentBashPolicyError('command has an unbalanced quote; refusing to run what cannot be parsed safely.')
288
+ }
289
+ pushSegment()
290
+ return segments.filter((s) => s.tokens.length > 0 || s.redirectTargets.length > 0)
291
+ }
292
+
293
+ function hasWriteFlag(tokens: string[]): boolean {
294
+ return tokens.some((t) => t === '-i' || t === '--in-place' || t.startsWith('-i') || t === '--set' || t === '--add')
295
+ }
296
+
297
+ function classifyGit(tokens: string[]): void {
298
+ // Resolve `git -C <dir>` and global flags to find the subcommand and the
299
+ // effective working directory.
300
+ let workdir: string | null = null
301
+ let idx = 1
302
+ while (idx < tokens.length) {
303
+ const t = tokens[idx]!
304
+ if (t === '-C') {
305
+ workdir = tokens[idx + 1] ?? null
306
+ idx += 2
307
+ continue
308
+ }
309
+ if (t === '-c') {
310
+ // `git -c key=val` config override — skip the pair. A core.hooksPath
311
+ // override is a mutation vector, so deny it outright.
312
+ const kv = tokens[idx + 1] ?? ''
313
+ if (/hookspath|core\.editor|alias\./i.test(stripQuotes(kv))) {
314
+ throw new SubagentBashPolicyError('git -c override of hooks/editor/alias is not permitted for the reviewer.')
315
+ }
316
+ idx += 2
317
+ continue
318
+ }
319
+ if (t.startsWith('-')) {
320
+ idx++
321
+ continue
322
+ }
323
+ break
324
+ }
325
+ const sub = tokens[idx]
326
+ if (sub === undefined) return // bare `git` — harmless
327
+ const subcommand = stripQuotes(sub)
328
+
329
+ if (GIT_MUTATING_ALWAYS_DENIED.has(subcommand)) {
330
+ throw new SubagentBashPolicyError(
331
+ `git ${subcommand} mutates repository state, which the read-only reviewer may not do.`,
332
+ )
333
+ }
334
+ if (GIT_TMP_SCOPED.has(subcommand)) {
335
+ assertGitTmpScoped(subcommand, workdir, tokens.slice(idx + 1))
336
+ return
337
+ }
338
+ if (GIT_READONLY_SUBCOMMANDS.has(subcommand)) {
339
+ if (
340
+ (subcommand === 'config' || subcommand === 'remote' || subcommand === 'branch' || subcommand === 'tag') &&
341
+ tokens.slice(idx + 1).some((t) => isGitWriteForm(subcommand, stripQuotes(t)))
342
+ ) {
343
+ throw new SubagentBashPolicyError(`git ${subcommand} is being used in a mutating form, which is not permitted.`)
344
+ }
345
+ return
346
+ }
347
+ throw new SubagentBashPolicyError(`git ${subcommand} is not on the reviewer's read-only allowlist.`)
348
+ }
349
+
350
+ // A /tmp-scoped git subcommand is safe only when the path it WRITES is under
351
+ // /tmp — not merely when some operand mentions /tmp. The earlier `.some(isTmpPath)`
352
+ // let `git clone /tmp/src /agent/evil` through because the source token matched
353
+ // while git wrote the destination at /agent/evil. Validate the actual write
354
+ // target per subcommand: clone writes its destination operand (or, when omitted,
355
+ // a directory derived from the repo under cwd — which we cannot prove is /tmp,
356
+ // so we require an explicit /tmp destination); -C-scoped operations write the
357
+ // -C workdir; a bare fetch/checkout without -C writes the ambient repo, which
358
+ // is not /tmp.
359
+ function assertGitTmpScoped(subcommand: string, workdir: string | null, rest: string[]): void {
360
+ const deny = (detail: string): never => {
361
+ throw new SubagentBashPolicyError(`git ${subcommand} is permitted only against a /tmp scratch checkout; ${detail}.`)
362
+ }
363
+ if (workdir !== null) {
364
+ if (!isTmpPath(workdir)) deny('the -C working directory is not under /tmp')
365
+ return
366
+ }
367
+ if (subcommand === 'clone') {
368
+ // `git clone [flags] <repo> [<dir>]`: the write target is the explicit
369
+ // <dir> operand when present, else a repo-derived dir under cwd (unprovable
370
+ // as /tmp). Extracting operands requires skipping value-taking flags
371
+ // (`--depth 1`, `-b main`, `--branch x`, …) whose VALUE is a bare word that
372
+ // would otherwise be miscounted as the repo or destination.
373
+ const operands = cloneOperands(rest)
374
+ const dest = operands[1]
375
+ if (dest === undefined) deny('clone needs an explicit /tmp destination directory')
376
+ if (!isTmpPath(dest!)) deny('the clone destination is not under /tmp')
377
+ return
378
+ }
379
+ // fetch/checkout/init/sparse-checkout/worktree without -C operate on the
380
+ // ambient repo (the agent checkout), which is never /tmp. Require -C /tmp.
381
+ deny(`${subcommand} without -C operates on the ambient repo; scope it with -C /tmp/review-*`)
382
+ }
383
+
384
+ // `git clone` flags that consume the NEXT token as their value (separated form,
385
+ // e.g. `--depth 1`). Their value is a bare word, so it must be skipped when
386
+ // counting positional operands (<repo> [<dir>]). Attached forms (`--depth=1`,
387
+ // `-b=x`) carry their own value and need no skip. Unknown long flags are treated
388
+ // as boolean (no skip); if a future value-taking flag is missed, the worst case
389
+ // is a stricter deny (a real operand shifts), never a looser allow.
390
+ const GIT_CLONE_VALUE_FLAGS = new Set([
391
+ '--depth',
392
+ '-b',
393
+ '--branch',
394
+ '-o',
395
+ '--origin',
396
+ '-u',
397
+ '--upload-pack',
398
+ '--reference',
399
+ '--reference-if-able',
400
+ '--separate-git-dir',
401
+ '-c',
402
+ '--config',
403
+ '--shallow-since',
404
+ '--shallow-exclude',
405
+ '-j',
406
+ '--jobs',
407
+ '--filter',
408
+ '--template',
409
+ ])
410
+
411
+ function cloneOperands(rest: string[]): string[] {
412
+ const operands: string[] = []
413
+ for (let i = 0; i < rest.length; i++) {
414
+ const t = rest[i]!
415
+ if (t.startsWith('-')) {
416
+ if (GIT_CLONE_VALUE_FLAGS.has(t)) i++
417
+ continue
418
+ }
419
+ operands.push(t)
420
+ }
421
+ return operands
422
+ }
423
+
424
+ function isGitWriteForm(sub: string, arg: string): boolean {
425
+ if (sub === 'config') return arg === '--add' || arg === '--unset' || arg === '--replace-all' || arg === '--set'
426
+ if (sub === 'remote')
427
+ return arg === 'add' || arg === 'remove' || arg === 'rm' || arg === 'set-url' || arg === 'rename'
428
+ if (sub === 'branch') return arg === '-d' || arg === '-D' || arg === '--delete' || arg === '-m' || arg === '-M'
429
+ if (sub === 'tag') return arg === '-d' || arg === '--delete' || arg === '-a' || arg === '-s'
430
+ return false
431
+ }
432
+
433
+ function classifyGh(tokens: string[]): void {
434
+ // Find the object (pr/issue/repo/api/…) skipping global flags.
435
+ let idx = 1
436
+ while (idx < tokens.length && tokens[idx]!.startsWith('-')) idx++
437
+ const objRaw = tokens[idx]
438
+ if (objRaw === undefined) return
439
+ const obj = stripQuotes(objRaw)
440
+ const allowed = GH_READONLY_BY_OBJECT[obj]
441
+ if (allowed === undefined) {
442
+ throw new SubagentBashPolicyError(
443
+ `gh ${obj} is not on the reviewer's read-only allowlist (it may mutate remote state).`,
444
+ )
445
+ }
446
+ if (obj === 'api') {
447
+ assertGhApiReadOnly(tokens.slice(idx + 1))
448
+ return
449
+ }
450
+ if (allowed.has('__any__')) return
451
+ // Find the verb after the object.
452
+ let vIdx = idx + 1
453
+ while (vIdx < tokens.length && tokens[vIdx]!.startsWith('-')) vIdx++
454
+ const verbRaw = tokens[vIdx]
455
+ if (verbRaw === undefined) return // bare `gh pr` — harmless listing-ish
456
+ const verb = stripQuotes(verbRaw)
457
+ if (!allowed.has(verb)) {
458
+ throw new SubagentBashPolicyError(`gh ${obj} ${verb} is not a read-only operation; the reviewer may not run it.`)
459
+ }
460
+ }
461
+
462
+ // `gh api` does NOT always default to GET. Per `gh api --help`: "adding request
463
+ // parameters will automatically switch the request method to POST". So any of
464
+ // `-f/--field`, `-F/--raw-field`, or `--input` flips the call to POST unless an
465
+ // explicit `--method GET/HEAD` overrides it. We mirror that inference, and we
466
+ // deny the `graphql` endpoint outright unless it is provably a query (a `mutation`
467
+ // operation is a write; even a query we cannot statically prove safe is denied
468
+ // for the reviewer because graphql can mutate through a GET-shaped call).
469
+ // Separated forms (flag and value are two tokens, e.g. `--field body=x`).
470
+ const GH_API_BODY_FLAGS = new Set(['-f', '--field', '-F', '--raw-field', '--input', '-d', '--data'])
471
+ // Attached long forms (`--field=body=x`, `--input=/tmp/x`). Each must be matched
472
+ // with its trailing `=` so `--field` proper still routes through the separated
473
+ // set above, and so an unrelated flag that merely starts with the same letters
474
+ // is not misread. Attached SHORT forms (`-fbody=x`, `-Fx`) are caught by the
475
+ // `-f`/`-F` prefix check at the call site.
476
+ const GH_API_BODY_FLAG_PREFIXES = ['--field=', '--raw-field=', '--input=', '--data=']
477
+
478
+ function isGhApiBodyParam(token: string): boolean {
479
+ if (GH_API_BODY_FLAGS.has(token)) return true
480
+ if (token.startsWith('-f') || token.startsWith('-F')) return true
481
+ return GH_API_BODY_FLAG_PREFIXES.some((p) => token.startsWith(p))
482
+ }
483
+
484
+ function assertGhApiReadOnly(rest: string[]): void {
485
+ let explicitMethod: string | null = null
486
+ let hasBodyParam = false
487
+ let isGraphql = false
488
+ for (let i = 0; i < rest.length; i++) {
489
+ const t = rest[i]!
490
+ if (t === '-X' || t === '--method') {
491
+ explicitMethod = stripQuotes(rest[i + 1] ?? '').toUpperCase()
492
+ continue
493
+ }
494
+ if (t.startsWith('-X')) {
495
+ explicitMethod = stripQuotes(t.slice(2)).toUpperCase()
496
+ continue
497
+ }
498
+ if (t.startsWith('--method=')) {
499
+ explicitMethod = stripQuotes(t.slice('--method='.length)).toUpperCase()
500
+ continue
501
+ }
502
+ if (isGhApiBodyParam(t)) hasBodyParam = true
503
+ if (stripQuotes(t) === 'graphql') isGraphql = true
504
+ }
505
+ if (isGraphql) {
506
+ throw new SubagentBashPolicyError(
507
+ 'gh api graphql can mutate (a `mutation` operation is a write, and a GET-shaped call can still mutate); the reviewer may not use the graphql endpoint.',
508
+ )
509
+ }
510
+ const method = explicitMethod ?? (hasBodyParam ? 'POST' : 'GET')
511
+ if (method !== 'GET' && method !== 'HEAD') {
512
+ throw new SubagentBashPolicyError(
513
+ `gh api resolves to ${method} (explicit or inferred from request parameters), which mutates remote state; the reviewer may only GET/HEAD.`,
514
+ )
515
+ }
516
+ }
517
+
518
+ function classifyFsWriter(verb: string, tokens: string[], redirectTargets: string[]): void {
519
+ const operands = tokens.slice(1).filter((t) => !t.startsWith('-'))
520
+ const allUnderTmp = operands.length > 0 && operands.every(isTmpPath) && redirectTargets.every(isTmpPath)
521
+ if (!allUnderTmp) {
522
+ throw new SubagentBashPolicyError(
523
+ `${verb} may only write under /tmp for the reviewer; a non-/tmp path operand is not permitted.`,
524
+ )
525
+ }
526
+ }
527
+
528
+ function classifySegment(segment: Segment): void {
529
+ const { tokens, redirectTargets } = segment
530
+ // A redirect to any non-/tmp path is a write to the persistent tree.
531
+ for (const target of redirectTargets) {
532
+ if (!isTmpPath(target)) {
533
+ throw new SubagentBashPolicyError(
534
+ `redirect to ${stripQuotes(target)} writes outside /tmp, which the read-only reviewer may not do.`,
535
+ )
536
+ }
537
+ }
538
+ if (tokens.length === 0) return
539
+ const verb = stripQuotes(tokens[0]!)
540
+
541
+ if (FORBIDDEN_WRAPPER_VERBS.has(verb)) {
542
+ throw new SubagentBashPolicyError(`\`${verb}\` can re-enter a shell or hand off execution; it is not permitted.`)
543
+ }
544
+ if (verb === 'git') return classifyGit(tokens)
545
+ if (verb === 'gh') return classifyGh(tokens)
546
+ if (FS_WRITERS.has(verb)) return classifyFsWriter(verb, tokens, redirectTargets)
547
+ if (verb === 'sed' && hasWriteFlag(tokens)) {
548
+ throw new SubagentBashPolicyError('sed -i edits files in place; the reviewer is read-only.')
549
+ }
550
+ if (verb === 'tee') return classifyFsWriter('tee', tokens, redirectTargets)
551
+ if (READONLY_VERBS.has(verb)) return
552
+ // Package managers and anything unknown: deny. Unknown verbs are the most
553
+ // likely bypass channel, so fail closed.
554
+ throw new SubagentBashPolicyError(
555
+ `\`${verb}\` is not on the reviewer's read-only command allowlist; refusing to run it.`,
556
+ )
557
+ }
558
+
559
+ export function enforceReviewerReadonlyBashPolicy(command: string): void {
560
+ if (typeof command !== 'string' || command.trim().length === 0) return
561
+ for (const { pattern, reason } of FAIL_CLOSED_CONSTRUCTS) {
562
+ if (pattern.test(command)) {
563
+ throw new SubagentBashPolicyError(`command uses ${reason}, which the reviewer policy cannot analyze safely.`)
564
+ }
565
+ }
566
+ const segments = splitIntoSegments(command)
567
+ for (const segment of segments) classifySegment(segment)
568
+ }
569
+
570
+ export function enforceSubagentBashPolicy(policy: SubagentBashPolicy, command: string): void {
571
+ if (policy.kind === 'readonly-reviewer') enforceReviewerReadonlyBashPolicy(command)
572
+ }
@@ -6,6 +6,7 @@ import type { Stream, Unsubscribe } from '@/stream'
6
6
 
7
7
  import { type AgentSession, createSession } from './index'
8
8
  import { subscribeProviderErrors } from './provider-error'
9
+ import type { SubagentBashPolicy } from './reviewer-bash-policy'
9
10
  import type { SessionOrigin } from './session-origin'
10
11
  import {
11
12
  beginSubagentDrainWatch,
@@ -88,6 +89,13 @@ export type SubagentShared<P = unknown> = {
88
89
  // hangs" symptom. Omit for no ceiling (legacy behavior; the spawn waits
89
90
  // as long as the provider takes).
90
91
  timeoutMs?: number
92
+ // Per-subagent bash capability restriction, enforced at the bash-wrap site
93
+ // INDEPENDENT of the caller's role (unlike the role-derived bwrap sandbox,
94
+ // which returns early for trusted/owner). A read-only subagent declares this
95
+ // to fence its `bash` to read-only commands even when spawned by a privileged
96
+ // caller. See `src/agent/reviewer-bash-policy.ts`. Omit for no restriction
97
+ // (the historical contract — prompt-only enforcement).
98
+ bashPolicy?: SubagentBashPolicy
91
99
  }
92
100
 
93
101
  export type Subagent<P = unknown> = SubagentShared<P> & {
@@ -155,6 +163,7 @@ export const defaultCreateSessionForSubagent: CreateSessionForSubagent = (subage
155
163
  customTools: subagent.customTools ?? [],
156
164
  ...(subagent.profile !== undefined ? { profile: subagent.profile } : {}),
157
165
  ...(subagent.toolResultBudget !== undefined ? { toolResultBudget: subagent.toolResultBudget } : {}),
166
+ ...(subagent.bashPolicy !== undefined ? { bashPolicy: subagent.bashPolicy } : {}),
158
167
  })
159
168
 
160
169
  type NormalizedSubagentSession = {