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 +1 -1
- package/src/agent/index.ts +7 -0
- package/src/agent/plugin-tools.ts +16 -0
- package/src/agent/reviewer-bash-policy.ts +572 -0
- package/src/agent/subagents.ts +9 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +132 -15
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +32 -1
- package/src/bundled-plugins/github-cli-auth/index.ts +8 -8
- package/src/bundled-plugins/researcher/write-report.ts +8 -6
- package/src/bundled-plugins/reviewer/reviewer.ts +14 -7
- package/src/bundled-plugins/reviewer/skills/code-review.ts +30 -1
- package/src/channels/router.ts +78 -24
- package/src/run/index.ts +1 -0
- package/src/skills/typeclaw-markdown-pdf/SKILL.md +327 -0
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -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 = {
|