rtrt-agent 0.1.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.
@@ -0,0 +1,1270 @@
1
+ // BEGIN rtrt-managed provenance plugin
2
+ import { constants } from "node:fs"
3
+ import { chmod, lstat, mkdir, open, readFile, realpath } from "node:fs/promises"
4
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
5
+ import { createServer } from "node:http"
6
+ import { homedir } from "node:os"
7
+ import path from "node:path"
8
+
9
+ const BROKER_PATH = "/rtrt/permission/v1"
10
+ const BODY_LIMIT = 32_768
11
+ const MAX_PENDING = 8
12
+ const MAX_DEPTH = 12
13
+ const MAX_FIELD = 4_096
14
+ const MAX_REPLAYS = 128
15
+ const MAX_PERMISSION_EVENT_REPLIES = 128
16
+ const MAX_TRACKED_CHILDREN = 128
17
+ const MAX_APPROVAL_SESSIONS = 128
18
+ const MAX_APPROVALS_PER_SESSION = 128
19
+ const MAX_COMMAND = 8_192
20
+ const MANAGED_AGENT_STATE_LIMIT = 65_536
21
+ const MANAGED_AGENT_STATE_DEPTH = 8
22
+ const MANAGED_AGENT_STATE_NODES = 512
23
+ const MAX_MANAGED_AGENTS = 128
24
+ const MANAGED_AGENT_STATE_OWNER = "rtrt-opencode-task-agents"
25
+ const MANAGED_AGENT_STATE_VERSION = 1
26
+ const MANAGER_AGENT = "rtrt-manager"
27
+ const configuredPath = (value) => typeof value === "string" && value && !value.includes("\0")
28
+ const resolveManagedAgentStatePath = ({ OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME, HOME }) => {
29
+ const configDirectory = configuredPath(OPENCODE_CONFIG_DIR)
30
+ ? path.resolve(OPENCODE_CONFIG_DIR)
31
+ : configuredPath(XDG_CONFIG_HOME)
32
+ ? path.resolve(XDG_CONFIG_HOME, "opencode")
33
+ : path.resolve(HOME, ".config", "opencode")
34
+ return path.join(configDirectory, "agents", ".rtrt-managed-state.json")
35
+ }
36
+ const defaultManagedAgentStatePath = () => resolveManagedAgentStatePath({
37
+ ...process.env,
38
+ HOME: configuredPath(process.env.HOME) ? process.env.HOME : homedir(),
39
+ })
40
+
41
+ const tokenizeCommand = (command) => {
42
+ if (typeof command !== "string" || !command || Buffer.byteLength(command) > MAX_COMMAND) return
43
+ if (/[\0\r\n`$~;|&<>*?\[]/.test(command)) return
44
+ const tokens = []
45
+ let token = ""
46
+ let quote = ""
47
+ let active = false
48
+ for (let index = 0; index < command.length; index += 1) {
49
+ const character = command[index]
50
+ if (!quote && /\s/.test(character)) {
51
+ if (active) tokens.push(token)
52
+ token = ""
53
+ active = false
54
+ continue
55
+ }
56
+ if (character === "\\") {
57
+ index += 1
58
+ if (index >= command.length) return
59
+ token += command[index]
60
+ active = true
61
+ continue
62
+ }
63
+ if (character === "'" || character === '"') {
64
+ if (!quote) quote = character
65
+ else if (quote === character) quote = ""
66
+ else token += character
67
+ active = true
68
+ continue
69
+ }
70
+ token += character
71
+ active = true
72
+ }
73
+ if (quote) return
74
+ if (active) tokens.push(token)
75
+ return tokens.length ? tokens : undefined
76
+ }
77
+
78
+ const URL_ARGUMENT = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//
79
+ const BARE_COMMAND = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
80
+ const SAFE_SESSION_FIELD = /^[A-Za-z0-9_-]{1,256}$/
81
+ const SAFE_AGENT_ID = /^[A-Za-z0-9_-]{1,256}$/
82
+
83
+ const boundedJson = (value, remaining = MANAGED_AGENT_STATE_DEPTH, count = { value: 0 }) => {
84
+ count.value += 1
85
+ if (count.value > MANAGED_AGENT_STATE_NODES || remaining < 0) return false
86
+ if (!value || typeof value !== "object") return true
87
+ if (Array.isArray(value)) {
88
+ if (value.length > MAX_MANAGED_AGENTS) return false
89
+ return value.every((item) => boundedJson(item, remaining - 1, count))
90
+ }
91
+ const entries = Object.entries(value)
92
+ if (entries.length > MAX_MANAGED_AGENTS) return false
93
+ return entries.every(([, item]) => boundedJson(item, remaining - 1, count))
94
+ }
95
+
96
+ const loadManagedAgents = async (statePath) => {
97
+ if (typeof statePath !== "string" || !statePath || statePath.includes("\0")) return undefined
98
+ let handle
99
+ try {
100
+ const before = await lstat(statePath)
101
+ if (before.isSymbolicLink() || !before.isFile() || before.size > MANAGED_AGENT_STATE_LIMIT) return undefined
102
+ handle = await open(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
103
+ const opened = await handle.stat()
104
+ if (
105
+ !opened.isFile() ||
106
+ opened.size > MANAGED_AGENT_STATE_LIMIT ||
107
+ opened.dev !== before.dev ||
108
+ opened.ino !== before.ino
109
+ ) return undefined
110
+ const raw = await handle.readFile("utf8")
111
+ if (Buffer.byteLength(raw) > MANAGED_AGENT_STATE_LIMIT) return undefined
112
+ const after = await handle.stat()
113
+ if (after.size !== opened.size || after.dev !== opened.dev || after.ino !== opened.ino) return undefined
114
+ const state = JSON.parse(raw)
115
+ if (!state || typeof state !== "object" || Array.isArray(state) || !boundedJson(state)) return undefined
116
+ if (state.owner !== MANAGED_AGENT_STATE_OWNER || state.version !== MANAGED_AGENT_STATE_VERSION) return undefined
117
+ if (!Array.isArray(state.agents) || state.agents.length === 0 || state.agents.length > MAX_MANAGED_AGENTS) return undefined
118
+ const agents = new Set()
119
+ for (const agent of state.agents) {
120
+ if (typeof agent !== "string" || !SAFE_AGENT_ID.test(agent) || agents.has(agent)) return undefined
121
+ agents.add(agent)
122
+ }
123
+ return agents.has(MANAGER_AGENT) ? agents : undefined
124
+ } catch {
125
+ return undefined
126
+ } finally {
127
+ await handle?.close().catch(() => {})
128
+ }
129
+ }
130
+
131
+ const realisticInside = async (root, operand) => {
132
+ if (!operand || operand.includes("\0") || URL_ARGUMENT.test(operand)) return false
133
+ if (!path.isAbsolute(operand) && operand.split(/[\\/]/).includes("..")) return false
134
+ const context = typeof root === "string" ? { cwd: root, bounds: [root] } : root
135
+ const target = path.resolve(context.cwd, operand)
136
+ if (!context.bounds.some((bound) => isWithin(bound, target))) return false
137
+ let probe = target
138
+ while (true) {
139
+ try {
140
+ const resolved = await realpath(probe)
141
+ return context.bounds.some((bound) => isWithin(bound, resolved))
142
+ } catch (error) {
143
+ if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") return false
144
+ const parent = path.dirname(probe)
145
+ if (parent === probe || !context.bounds.some((bound) => isWithin(bound, parent))) return false
146
+ probe = parent
147
+ }
148
+ }
149
+ }
150
+
151
+ const splitOptions = (args, valueOptions = new Set()) => {
152
+ const operands = []
153
+ for (let index = 0; index < args.length; index += 1) {
154
+ const argument = args[index]
155
+ if (argument === "--") {
156
+ operands.push(...args.slice(index + 1))
157
+ break
158
+ }
159
+ if (!argument.startsWith("-") || argument === "-") {
160
+ operands.push(argument)
161
+ continue
162
+ }
163
+ const [option, attached] = argument.split(/=(.*)/s, 2)
164
+ if (valueOptions.has(option)) {
165
+ const value = attached ?? args[++index]
166
+ if (!value) return
167
+ operands.push(value)
168
+ }
169
+ }
170
+ return operands
171
+ }
172
+
173
+ const allPathsInside = async (root, operands) => {
174
+ if (!operands) return false
175
+ for (const operand of operands) if (!(await realisticInside(root, operand))) return false
176
+ return true
177
+ }
178
+
179
+ const READ_COMMANDS = new Set(["cat", "wc", "stat"])
180
+
181
+ const safeCommand = async (command, root) => {
182
+ const tokens = tokenizeCommand(command)
183
+ if (!tokens || !root || !BARE_COMMAND.test(tokens[0]) || tokens.some((item) => URL_ARGUMENT.test(item))) return false
184
+ const [executable, ...args] = tokens
185
+ for (const argument of args) {
186
+ const value = argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : argument
187
+ if ((path.isAbsolute(value) || value.includes("/") || value.includes("\\") || value.startsWith(".")) && !(await realisticInside(root, value))) return false
188
+ }
189
+ if (executable === "pwd") return args.every((item) => item === "-L" || item === "-P")
190
+ if (executable === "ls") {
191
+ if (args.some((item) => item.startsWith("-") && !/^-[AacdFfghikLlmnopqRrSstuUx1]+$/.test(item))) return false
192
+ return allPathsInside(root, args.filter((item) => !item.startsWith("-")))
193
+ }
194
+ if (READ_COMMANDS.has(executable)) {
195
+ if (args.some((item) => item.startsWith("-") && item !== "--")) return false
196
+ const operands = splitOptions(args)
197
+ return Boolean(operands?.length) && allPathsInside(root, operands)
198
+ }
199
+ if (["head", "tail"].includes(executable)) {
200
+ const operands = []
201
+ for (let index = 0; index < args.length; index += 1) {
202
+ const argument = args[index]
203
+ if (argument === "-q" || argument === "--quiet" || argument === "--silent" || argument === "-v" || argument === "--verbose") continue
204
+ const attached = argument.match(/^(?:-n|--lines|-c|--bytes)=(.*)$/)
205
+ if (attached) {
206
+ if (!/^[-+]?\d+$/.test(attached[1])) return false
207
+ continue
208
+ }
209
+ if (["-n", "--lines", "-c", "--bytes"].includes(argument)) {
210
+ if (!/^[-+]?\d+$/.test(args[++index] ?? "")) return false
211
+ continue
212
+ }
213
+ if (argument.startsWith("-")) return false
214
+ operands.push(argument)
215
+ }
216
+ return operands.length > 0 && allPathsInside(root, operands)
217
+ }
218
+ return false
219
+ }
220
+
221
+ const defaultClientFactory = async (options) => {
222
+ const { createOpencodeClient } = await import("@opencode-ai/sdk/v2")
223
+ return createOpencodeClient(options)
224
+ }
225
+
226
+ const RTRT_AGENT_TOOLS = new Set([
227
+ "rtrt_agent_call",
228
+ "rtrt_agent_route",
229
+ "rtrt_team_dispatch",
230
+ ])
231
+
232
+ const sanitizeSessionID = (sessionID) => {
233
+ const sanitized = String(sessionID ?? "")
234
+ .replace(/[^A-Za-z0-9_-]/g, "_")
235
+ .slice(0, 128)
236
+ return sanitized || "session"
237
+ }
238
+
239
+ const isWithin = (parent, child) => {
240
+ const relative = path.relative(parent, child)
241
+ return (
242
+ relative === "" ||
243
+ (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
244
+ )
245
+ }
246
+
247
+ const hasTraversal = (candidate) =>
248
+ !path.isAbsolute(candidate) && candidate.split(path.sep).includes("..")
249
+
250
+ const canonicalProjectPath = async (rootDirectory, directory, candidate, allowMissing) => {
251
+ if (
252
+ typeof rootDirectory !== "string" ||
253
+ !rootDirectory ||
254
+ rootDirectory.includes("\0") ||
255
+ typeof directory !== "string" ||
256
+ !directory ||
257
+ directory.includes("\0") ||
258
+ typeof candidate !== "string" ||
259
+ !candidate ||
260
+ candidate.includes("\0")
261
+ ) return undefined
262
+ if (hasTraversal(candidate)) return undefined
263
+ try {
264
+ const root = await realpath(path.resolve(rootDirectory))
265
+ const cwd = await realpath(path.resolve(directory))
266
+ if (!isWithin(root, cwd)) return undefined
267
+ const target = path.resolve(cwd, candidate)
268
+ if (!isWithin(root, target)) return undefined
269
+ try {
270
+ const canonical = await realpath(target)
271
+ return isWithin(root, canonical) ? canonical : undefined
272
+ } catch (error) {
273
+ if (!allowMissing || (error?.code !== "ENOENT" && error?.code !== "ENOTDIR")) return undefined
274
+ let probe = path.dirname(target)
275
+ while (isWithin(root, probe)) {
276
+ try {
277
+ const canonicalParent = await realpath(probe)
278
+ return isWithin(root, canonicalParent) ? target : undefined
279
+ } catch (parentError) {
280
+ if (parentError?.code !== "ENOENT" && parentError?.code !== "ENOTDIR") return undefined
281
+ }
282
+ const parent = path.dirname(probe)
283
+ if (parent === probe) return undefined
284
+ probe = parent
285
+ }
286
+ }
287
+ } catch {
288
+ return undefined
289
+ }
290
+ return undefined
291
+ }
292
+
293
+ const readPathFile = async (file, prefix = "") => {
294
+ const lines = (await readFile(file, "utf8")).split(/\r?\n/)
295
+ if (lines.at(-1) === "") lines.pop()
296
+ if (lines.length !== 1 || !lines[0].startsWith(prefix)) return undefined
297
+
298
+ const value = lines[0].slice(prefix.length).trim()
299
+ return value && !value.includes("\0") ? value : undefined
300
+ }
301
+
302
+ const resolveLinkedWorktree = async (worktree, gitFile) => {
303
+ const rawGitDir = await readPathFile(gitFile, "gitdir:")
304
+ if (!rawGitDir) return undefined
305
+
306
+ const gitDirPath = path.resolve(worktree, rawGitDir)
307
+ const gitDirStat = await lstat(gitDirPath)
308
+ if (gitDirStat.isSymbolicLink() || !gitDirStat.isDirectory()) return undefined
309
+ const gitDir = await realpath(gitDirPath)
310
+
311
+ const rawCommonDir = await readPathFile(path.join(gitDir, "commondir"))
312
+ const rawBacklink = await readPathFile(path.join(gitDir, "gitdir"))
313
+ if (!rawCommonDir || !rawBacklink) return undefined
314
+
315
+ const commonDirPath = path.resolve(gitDir, rawCommonDir)
316
+ const commonDirStat = await lstat(commonDirPath)
317
+ if (commonDirStat.isSymbolicLink() || !commonDirStat.isDirectory()) return undefined
318
+ const commonDir = await realpath(commonDirPath)
319
+ if (path.basename(commonDir) !== ".git") return undefined
320
+
321
+ const mainWorktree = path.dirname(commonDir)
322
+ if (mainWorktree === path.parse(mainWorktree).root) return undefined
323
+
324
+ const mainGitStat = await lstat(path.join(mainWorktree, ".git"))
325
+ if (mainGitStat.isSymbolicLink() || !mainGitStat.isDirectory()) return undefined
326
+ if ((await realpath(path.join(mainWorktree, ".git"))) !== commonDir) return undefined
327
+
328
+ const worktreesDir = await realpath(path.join(commonDir, "worktrees"))
329
+ const adminRelative = path.relative(worktreesDir, gitDir)
330
+ if (
331
+ !adminRelative ||
332
+ path.isAbsolute(adminRelative) ||
333
+ adminRelative === ".." ||
334
+ adminRelative.startsWith(`..${path.sep}`) ||
335
+ adminRelative.includes(path.sep)
336
+ ) {
337
+ return undefined
338
+ }
339
+
340
+ const backlink = path.resolve(gitDir, rawBacklink)
341
+ if ((await realpath(backlink)) !== (await realpath(gitFile))) return undefined
342
+ return worktree
343
+ }
344
+
345
+ const resolveProjectWorktree = async (projectWorktree) => {
346
+ if (!projectWorktree) return undefined
347
+
348
+ try {
349
+ const worktree = await realpath(path.resolve(projectWorktree))
350
+ if (worktree === path.parse(worktree).root) return undefined
351
+
352
+ const gitEntry = path.join(worktree, ".git")
353
+ const gitStat = await lstat(gitEntry)
354
+ if (gitStat.isSymbolicLink()) return undefined
355
+ if (gitStat.isDirectory()) return worktree
356
+ if (gitStat.isFile()) return await resolveLinkedWorktree(worktree, gitEntry)
357
+ } catch {
358
+ return undefined
359
+ }
360
+
361
+ return undefined
362
+ }
363
+
364
+ const ensureSessionTempDir = async (parentWorktree, sessionID) => {
365
+ const root = await realpath(parentWorktree)
366
+ const tempRoot = path.join(root, ".rtrt", "tmp", "opencode")
367
+ const tempDir = path.join(tempRoot, sanitizeSessionID(sessionID))
368
+
369
+ if (!isWithin(root, tempDir)) {
370
+ throw new Error("OpenCode session temp directory escapes parent worktree")
371
+ }
372
+
373
+ // Create one level at a time so symlinks cannot redirect writes outside the repository.
374
+ for (const directory of [
375
+ path.join(root, ".rtrt"),
376
+ path.join(root, ".rtrt", "tmp"),
377
+ tempRoot,
378
+ tempDir,
379
+ ]) {
380
+ try {
381
+ await mkdir(directory, { mode: 0o700 })
382
+ } catch (error) {
383
+ if (error?.code !== "EEXIST") throw error
384
+ }
385
+ const stat = await lstat(directory)
386
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
387
+ throw new Error(`OpenCode session temp path is not a secure directory: ${directory}`)
388
+ }
389
+ }
390
+
391
+ const realTempDir = await realpath(tempDir)
392
+ if (!isWithin(root, realTempDir)) {
393
+ throw new Error("OpenCode session temp directory escapes parent worktree")
394
+ }
395
+ await chmod(tempDir, 0o700)
396
+ return tempDir
397
+ }
398
+
399
+ const boundedString = (value, maximum = MAX_FIELD) =>
400
+ typeof value === "string" && value.length > 0 && value.length <= maximum ? value : undefined
401
+
402
+ const WEEKLY_USAGE_CODES = new Set([
403
+ "weekly_usage_limit",
404
+ "weekly-usage-limit",
405
+ "weekly_limit",
406
+ "usage_limit_weekly",
407
+ ])
408
+
409
+ const weeklyUsageCode = (value) => {
410
+ const normalized = boundedString(value, 128)?.trim().toLowerCase()
411
+ return normalized && WEEKLY_USAGE_CODES.has(normalized)
412
+ }
413
+
414
+ const usageLimitPhrase = (value) => {
415
+ const message = boundedString(value)
416
+ if (!message) return undefined
417
+ if (/\bweekly usage (?:limit|cap)\b/i.test(message)) return "weekly_usage_limit"
418
+ if (/\b5[- ]hour usage (?:limit|cap)\b/i.test(message)) return "usage_limit_5h"
419
+ return undefined
420
+ }
421
+
422
+ const providerLimit = (error) => {
423
+ if (!error || typeof error !== "object" || Array.isArray(error)) return undefined
424
+ if (error.statusCode === 429) return { kind: "rate_limit", status: 429 }
425
+ if (error.statusCode === 529) return { kind: "capacity", status: 529 }
426
+ return undefined
427
+ }
428
+
429
+ const sessionErrorProviderLimit = (error) => {
430
+ if (!error || typeof error !== "object" || Array.isArray(error)) return undefined
431
+ if (error.name !== "APIError" && error.name !== "UnknownError") return undefined
432
+
433
+ const data = error.data
434
+ if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
435
+ if (error.name === "APIError") {
436
+ if (data.statusCode === 429) return { kind: "rate_limit", status: 429 }
437
+ if (data.statusCode === 529) return { kind: "capacity", status: 529 }
438
+ if (data.statusCode !== undefined) return undefined
439
+
440
+ const metadata = data.metadata
441
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
442
+ for (const field of ["code", "type", "reason"]) {
443
+ if (weeklyUsageCode(metadata[field])) {
444
+ return { kind: "weekly_usage_limit", status: null }
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ const phrase = usageLimitPhrase(data.message)
451
+ if (phrase) return { kind: phrase, status: null }
452
+ return undefined
453
+ }
454
+
455
+ const statusProviderLimit = (status) => {
456
+ if (!status || typeof status !== "object" || Array.isArray(status) || status.type !== "retry") {
457
+ return undefined
458
+ }
459
+ const action = status.action
460
+ if (action && typeof action === "object" && !Array.isArray(action)) {
461
+ if (action.reason === "account_rate_limit") return {
462
+ kind: usageLimitPhrase(status.message) ?? "account_rate_limit",
463
+ status: null,
464
+ }
465
+ if (action.reason === "free_tier_limit") return { kind: "free_tier_limit", status: null }
466
+ if (action.reason !== undefined) return undefined
467
+ }
468
+ // Compatibility fallback for providers predating OpenCode Go structured actions.
469
+ const phrase = usageLimitPhrase(status.message)
470
+ if (phrase) return { kind: phrase, status: null }
471
+ return undefined
472
+ }
473
+
474
+ const sdkSession = (response) => {
475
+ if (!response || typeof response !== "object" || Array.isArray(response)) return undefined
476
+ let data = response.data
477
+ if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
478
+ if (Object.hasOwn(data, "data")) data = data.data
479
+ return data && typeof data === "object" && !Array.isArray(data) ? data : undefined
480
+ }
481
+
482
+ const sdkPermissions = (response) => {
483
+ if (!response || typeof response !== "object" || Array.isArray(response)) return undefined
484
+ let data = response.data
485
+ if (data && typeof data === "object" && !Array.isArray(data) && Object.hasOwn(data, "data")) data = data.data
486
+ return Array.isArray(data) ? data : undefined
487
+ }
488
+
489
+ const sessionProjectIdentity = (session) => {
490
+ for (const value of [
491
+ session?.projectID,
492
+ session?.projectId,
493
+ session?.project_id,
494
+ session?.project?.id,
495
+ ]) {
496
+ const identity = boundedString(value, 256)
497
+ if (identity) return identity
498
+ }
499
+ return undefined
500
+ }
501
+
502
+ const sessionPathIdentity = (session) => {
503
+ for (const value of [session?.worktree, session?.directory]) {
504
+ const identity = boundedString(value)
505
+ if (identity && !identity.includes("\0")) return path.resolve(identity)
506
+ }
507
+ return undefined
508
+ }
509
+
510
+ const sameAvailableIdentity = (child, parent, currentProject, currentPaths) => {
511
+ const childProject = sessionProjectIdentity(child)
512
+ const parentProject = sessionProjectIdentity(parent)
513
+ if ((childProject || parentProject) && (!childProject || !parentProject || childProject !== parentProject)) return false
514
+ if (childProject && currentProject && childProject !== currentProject) return false
515
+
516
+ const childPath = sessionPathIdentity(child)
517
+ const parentPath = sessionPathIdentity(parent)
518
+ if ((childPath || parentPath) && (!childPath || !parentPath || childPath !== parentPath)) return false
519
+ if (childPath && currentPaths.size > 0 && !currentPaths.has(childPath)) return false
520
+ return true
521
+ }
522
+
523
+ const depthWithin = (value, remaining = MAX_DEPTH) => {
524
+ if (remaining < 0) return false
525
+ if (!value || typeof value !== "object") return true
526
+ if (Array.isArray(value)) return value.every((item) => depthWithin(item, remaining - 1))
527
+ return Object.values(value).every((item) => depthWithin(item, remaining - 1))
528
+ }
529
+
530
+ const safeEqual = (left, right) => {
531
+ const leftDigest = createHash("sha256").update(String(left ?? "")).digest()
532
+ const rightDigest = createHash("sha256").update(String(right ?? "")).digest()
533
+ return timingSafeEqual(leftDigest, rightDigest) && typeof left === "string" && left === right
534
+ }
535
+
536
+ const directPermissionClaude = (command) =>
537
+ typeof command === "string" &&
538
+ /^claude -p(?:\s|$)/.test(command) &&
539
+ /(?:^|\s)--permission-prompt-tool(?:=|\s+)mcp__rtrt__permission_prompt(?:\s|$)/.test(command)
540
+
541
+ const mappedPermission = async (body, projectRoot, pluginDirectory) => {
542
+ const tool = boundedString(body.tool_name, 128)
543
+ if (!tool) return undefined
544
+ const input = body.input
545
+ if (!input || typeof input !== "object" || Array.isArray(input) || !depthWithin(input)) return undefined
546
+
547
+ const pick = (name) => boundedString(input[name])
548
+ const mappedPath = async (resource, action, allowMissing = false) => {
549
+ const classified = await canonicalProjectPath(
550
+ projectRoot,
551
+ pluginDirectory,
552
+ resource,
553
+ allowMissing,
554
+ )
555
+ return classified ? { action, resources: [classified] } : undefined
556
+ }
557
+ switch (tool) {
558
+ case "Bash":
559
+ return pick("command") && { action: "bash", resources: [pick("command")] }
560
+ case "Read":
561
+ return mappedPath(pick("file_path"), "read")
562
+ case "Glob": {
563
+ const pattern = pick("pattern")
564
+ if (!pattern) return undefined
565
+ return mappedPath(Object.hasOwn(input, "path") ? pick("path") : ".", "glob")
566
+ }
567
+ case "Grep": {
568
+ const pattern = pick("pattern")
569
+ return pattern && mappedPath(Object.hasOwn(input, "path") ? pick("path") : ".", "grep")
570
+ }
571
+ case "Edit":
572
+ case "Write":
573
+ case "MultiEdit":
574
+ return mappedPath(pick("file_path"), "edit", true)
575
+ case "NotebookEdit":
576
+ return mappedPath(pick("notebook_path"), "edit", true)
577
+ case "WebFetch":
578
+ return pick("url") && { action: "webfetch", resources: [pick("url")] }
579
+ case "WebSearch":
580
+ return pick("query") && { action: "websearch", resources: [pick("query")] }
581
+ default: {
582
+ const action = tool.toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 64)
583
+ return action && { action, resources: [`claude-tool:${action}`] }
584
+ }
585
+ }
586
+ }
587
+
588
+ const approvalDigest = ({ action, resources }) => createHash("sha256")
589
+ .update(JSON.stringify([
590
+ action.normalize("NFC"),
591
+ resources.map((resource) => resource.normalize("NFC")),
592
+ ]))
593
+ .digest("base64url")
594
+
595
+ const listenLoopback = (server) =>
596
+ new Promise((resolve, reject) => {
597
+ server.once("error", reject)
598
+ server.listen(0, "127.0.0.1", () => {
599
+ server.off("error", reject)
600
+ resolve()
601
+ })
602
+ })
603
+
604
+ const closeServer = (server) =>
605
+ new Promise((resolve) => server.close(() => resolve()))
606
+
607
+ const createPlugin = async (
608
+ { project, directory, serverUrl, client: legacyClient },
609
+ {
610
+ clientFactory = defaultClientFactory,
611
+ bodyLimit = BODY_LIMIT,
612
+ maxPending = MAX_PENDING,
613
+ maxApprovalSessions = MAX_APPROVAL_SESSIONS,
614
+ maxApprovalsPerSession = MAX_APPROVALS_PER_SESSION,
615
+ maxTrackedChildren = MAX_TRACKED_CHILDREN,
616
+ managedAgentStatePath = defaultManagedAgentStatePath(),
617
+ } = {},
618
+ ) => {
619
+ const agents = new Map()
620
+ const invocations = new Map()
621
+ const brokerInvocations = new Map()
622
+ const permissionWaiters = new Map()
623
+ const approvals = new Map()
624
+ const trackedChildren = new Map()
625
+ const cleanedChildren = new Set()
626
+ const lifecycleOperations = new Map()
627
+ const permissionEventReplies = new Set()
628
+ const managedAgents = await loadManagedAgents(managedAgentStatePath)
629
+ const parentWorktree = await resolveProjectWorktree(project?.worktree)
630
+ const parentProject = parentWorktree ? path.basename(parentWorktree) : undefined
631
+ const pluginDirectory = (
632
+ typeof directory === "string" && directory && !directory.includes("\0")
633
+ ? path.resolve(directory)
634
+ : undefined
635
+ )
636
+ const projectRoot = parentWorktree ?? pluginDirectory
637
+ const currentProjectIdentity = sessionProjectIdentity({
638
+ projectID: project?.id ?? project?.projectID ?? project?.projectId ?? project?.project_id,
639
+ })
640
+ const currentPathIdentities = new Set([
641
+ parentWorktree,
642
+ pluginDirectory,
643
+ boundedString(project?.worktree) && !project.worktree.includes("\0")
644
+ ? path.resolve(project.worktree)
645
+ : undefined,
646
+ ].filter(Boolean))
647
+ let approvalRoot
648
+ try {
649
+ const cwd = await realpath(path.resolve(directory ?? parentWorktree))
650
+ const bounds = [...new Set([parentWorktree, cwd].filter(Boolean))]
651
+ if (cwd !== path.parse(cwd).root && bounds.every((bound) => bound !== path.parse(bound).root)) {
652
+ approvalRoot = { cwd, bounds }
653
+ }
654
+ } catch {}
655
+ const v2Client = await clientFactory({
656
+ baseUrl: serverUrl ? serverUrl.toString() : "http://127.0.0.1",
657
+ directory,
658
+ })
659
+ const diagnostic = async (message, extra = {}) => {
660
+ if (typeof v2Client.app?.log !== "function") return
661
+ try {
662
+ await v2Client.app.log({
663
+ service: "rtrt-provenance",
664
+ level: "debug",
665
+ message,
666
+ extra,
667
+ })
668
+ } catch {}
669
+ }
670
+ let pendingCount = 0
671
+ let disposed = false
672
+
673
+ const invocationFor = (callID) => {
674
+ if (!callID) return randomUUID()
675
+ let invocationID = invocations.get(callID)
676
+ if (!invocationID) {
677
+ invocationID = randomUUID()
678
+ invocations.set(callID, invocationID)
679
+ }
680
+ return invocationID
681
+ }
682
+
683
+ const rememberAgent = (sessionID, agent) => {
684
+ if (sessionID && agent) agents.set(sessionID, agent)
685
+ }
686
+
687
+ const confirmManagedSession = async (sessionID) => {
688
+ if (
689
+ !SAFE_SESSION_FIELD.test(sessionID ?? "") ||
690
+ cleanedChildren.has(sessionID) ||
691
+ typeof legacyClient?.session?.get !== "function"
692
+ ) return
693
+ try {
694
+ const session = sdkSession(await legacyClient.session.get({ path: { id: sessionID } }))
695
+ if (boundedString(session?.id, 256) !== sessionID) return
696
+ if (session.agent === MANAGER_AGENT) return { manager: true, session }
697
+ const parentID = boundedString(session.parentID, 256)
698
+ if (!parentID || !SAFE_SESSION_FIELD.test(parentID)) return
699
+ const parent = sdkSession(await legacyClient.session.get({ path: { id: parentID } }))
700
+ if (boundedString(parent?.id, 256) !== parentID) return
701
+ if (managedAgents) {
702
+ if (!managedAgents.has(session.agent) || !sameAvailableIdentity(
703
+ session,
704
+ parent,
705
+ currentProjectIdentity,
706
+ currentPathIdentities,
707
+ )) return
708
+ } else if (parent?.agent !== MANAGER_AGENT) return
709
+ return { manager: false, session, parentID }
710
+ } catch {
711
+ // Ownership is security-sensitive: SDK errors and malformed responses fail closed.
712
+ return undefined
713
+ }
714
+ }
715
+
716
+ const confirmPermissionRequest = async (requestID, sessionID, patterns) => {
717
+ if (typeof v2Client.permission?.list !== "function") return false
718
+ try {
719
+ const permissions = sdkPermissions(await v2Client.permission.list())
720
+ if (!permissions) return false
721
+ return permissions.some((permission) =>
722
+ permission &&
723
+ typeof permission === "object" &&
724
+ !Array.isArray(permission) &&
725
+ permission.id === requestID &&
726
+ permission.sessionID === sessionID &&
727
+ permission.permission === "bash" &&
728
+ Array.isArray(permission.patterns) &&
729
+ permission.patterns.length === patterns.length &&
730
+ permission.patterns.every((pattern, index) => pattern === patterns[index]))
731
+ } catch {
732
+ return false
733
+ }
734
+ }
735
+
736
+ const rejectWaiters = (predicate) => {
737
+ for (const waiter of permissionWaiters.values()) {
738
+ if (!predicate(waiter)) continue
739
+ waiter.resolve("reject")
740
+ }
741
+ }
742
+
743
+ const hasApproval = (sessionID, digest) => approvals.get(sessionID)?.has(digest) === true
744
+
745
+ const cacheApproval = (sessionID, digest) => {
746
+ let sessionApprovals = approvals.get(sessionID)
747
+ if (sessionApprovals?.has(digest)) return true
748
+ if (!sessionApprovals) {
749
+ if (approvals.size >= maxApprovalSessions) return false
750
+ sessionApprovals = new Set()
751
+ }
752
+ if (sessionApprovals.size >= maxApprovalsPerSession) return false
753
+ sessionApprovals.add(digest)
754
+ approvals.set(sessionID, sessionApprovals)
755
+ return true
756
+ }
757
+
758
+ const invalidate = (sessionID, callID) => {
759
+ const key = `${sessionID ?? ""}\0${callID ?? ""}`
760
+ const record = brokerInvocations.get(key)
761
+ if (!record) return
762
+ brokerInvocations.delete(key)
763
+ record.valid = false
764
+ rejectWaiters((waiter) => waiter.record === record)
765
+ }
766
+
767
+ const respond = (response, requestID, decision = "reject", status = 200) => {
768
+ if (response.writableEnded || response.destroyed) return
769
+ response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" })
770
+ response.end(JSON.stringify({ version: 1, request_id: requestID ?? "", decision }))
771
+ }
772
+
773
+ const readBody = (request) =>
774
+ new Promise((resolve, reject) => {
775
+ let size = 0
776
+ let oversized = false
777
+ const chunks = []
778
+ const cleanup = () => {
779
+ request.off("data", onData)
780
+ request.off("end", onEnd)
781
+ request.off("error", onError)
782
+ }
783
+ const onData = (chunk) => {
784
+ size += chunk.length
785
+ if (size > bodyLimit) {
786
+ oversized = true
787
+ return
788
+ }
789
+ chunks.push(chunk)
790
+ }
791
+ const onEnd = () => {
792
+ cleanup()
793
+ if (oversized) reject(Object.assign(new Error("oversize"), { status: 413 }))
794
+ else resolve(Buffer.concat(chunks).toString("utf8"))
795
+ }
796
+ const onError = (error) => {
797
+ cleanup()
798
+ reject(error)
799
+ }
800
+ request.on("data", onData)
801
+ request.once("end", onEnd)
802
+ request.once("error", onError)
803
+ })
804
+
805
+ const awaitReply = (record, sessionID, requestID, digest, request, response) =>
806
+ new Promise((resolve) => {
807
+ const key = `${sessionID}\0${requestID}`
808
+ const finish = (decision) => {
809
+ if (!permissionWaiters.has(key)) return
810
+ permissionWaiters.delete(key)
811
+ request.off("aborted", disconnect)
812
+ response.off("close", disconnect)
813
+ resolve(decision)
814
+ }
815
+ const disconnect = () => {
816
+ if (response.writableFinished) return
817
+ invalidate(record.sessionID, record.callID)
818
+ finish("reject")
819
+ }
820
+ permissionWaiters.set(key, { record, resolve: finish, sessionID, requestID, digest })
821
+ request.once("aborted", disconnect)
822
+ response.once("close", disconnect)
823
+ if (request.aborted || response.destroyed) disconnect()
824
+ })
825
+
826
+ const handlePermission = async (request, response) => {
827
+ let requestID = ""
828
+ let counted = false
829
+ try {
830
+ if (disposed || request.method !== "POST" || request.url !== BROKER_PATH) {
831
+ respond(response, requestID, "reject", 404)
832
+ return
833
+ }
834
+ if (pendingCount >= maxPending) {
835
+ respond(response, requestID, "reject", 429)
836
+ return
837
+ }
838
+ const contentLength = request.headers["content-length"]
839
+ if (typeof contentLength !== "string" || !/^\d+$/.test(contentLength)) {
840
+ respond(response, requestID, "reject", 411)
841
+ return
842
+ }
843
+ const declared = Number(contentLength)
844
+ if (!Number.isSafeInteger(declared) || declared > bodyLimit) {
845
+ respond(response, requestID, "reject", 413)
846
+ return
847
+ }
848
+ pendingCount += 1
849
+ counted = true
850
+ let body
851
+ try {
852
+ body = JSON.parse(await readBody(request))
853
+ } catch (error) {
854
+ if (!response.destroyed) respond(response, requestID, "reject", error?.status ?? 400)
855
+ return
856
+ }
857
+ requestID = boundedString(body?.request_id, 256) ?? ""
858
+ const allowedFields = new Set([
859
+ "version",
860
+ "request_id",
861
+ "broker_nonce",
862
+ "invocation_id",
863
+ "parent_session_id",
864
+ "parent_call_id",
865
+ "child_session_id",
866
+ "tool_use_id",
867
+ "tool_name",
868
+ "input",
869
+ ])
870
+ const required = [
871
+ requestID,
872
+ boundedString(body?.broker_nonce, 256),
873
+ boundedString(body?.invocation_id, 256),
874
+ boundedString(body?.parent_session_id, 256),
875
+ boundedString(body?.parent_call_id, 256),
876
+ boundedString(body?.tool_name, 128),
877
+ ]
878
+ if (
879
+ body?.version !== 1 ||
880
+ !body ||
881
+ typeof body !== "object" ||
882
+ Array.isArray(body) ||
883
+ Object.keys(body).some((field) => !allowedFields.has(field)) ||
884
+ required.some((value) => !value) ||
885
+ !body.input ||
886
+ typeof body.input !== "object" ||
887
+ Array.isArray(body.input) ||
888
+ !depthWithin(body) ||
889
+ (body.child_session_id !== undefined && !boundedString(body.child_session_id, 256)) ||
890
+ (body.tool_use_id !== undefined && !boundedString(body.tool_use_id, 256))
891
+ ) {
892
+ respond(response, requestID, "reject", 400)
893
+ return
894
+ }
895
+
896
+ const key = `${body.parent_session_id}\0${body.parent_call_id}`
897
+ const record = brokerInvocations.get(key)
898
+ const authorization = request.headers.authorization ?? ""
899
+ const suppliedToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""
900
+ const suppliedNonce = request.headers["x-rtrt-broker-nonce"]
901
+ if (
902
+ !record ||
903
+ !record.valid ||
904
+ !safeEqual(suppliedToken, record.token) ||
905
+ !safeEqual(suppliedNonce, record.nonce) ||
906
+ !safeEqual(body.broker_nonce, record.nonce) ||
907
+ !safeEqual(body.invocation_id, record.invocationID) ||
908
+ !safeEqual(body.parent_session_id, record.sessionID) ||
909
+ !safeEqual(body.parent_call_id, record.callID)
910
+ ) {
911
+ respond(response, requestID, "reject", 401)
912
+ return
913
+ }
914
+
915
+ const digest = createHash("sha256").update(JSON.stringify(body)).digest("base64url")
916
+ const previous = record.replays.get(requestID)
917
+ if (previous) {
918
+ const identical = previous.digest === digest && previous.done
919
+ respond(response, requestID, identical ? previous.decision : "reject", identical ? 200 : 409)
920
+ return
921
+ }
922
+ if (record.replays.size >= MAX_REPLAYS) {
923
+ respond(response, requestID, "reject", 429)
924
+ return
925
+ }
926
+ record.replays.set(requestID, { digest, done: false, decision: "reject" })
927
+
928
+ const mapped = await mappedPermission(body, projectRoot, pluginDirectory)
929
+ if (!mapped) {
930
+ record.replays.set(requestID, { digest, done: true, decision: "reject" })
931
+ respond(response, requestID)
932
+ return
933
+ }
934
+ const mappedDigest = approvalDigest(mapped)
935
+ if (hasApproval(record.sessionID, mappedDigest)) {
936
+ record.replays.set(requestID, { digest, done: true, decision: "always" })
937
+ respond(response, requestID, "always")
938
+ return
939
+ }
940
+ const permissionID = randomUUID()
941
+ let result
942
+ try {
943
+ result = await v2Client.session.permission.create({
944
+ sessionID: record.sessionID,
945
+ id: permissionID,
946
+ action: mapped.action,
947
+ resources: mapped.resources,
948
+ save: [],
949
+ metadata: { source: "claude-cli", tool: body.tool_name },
950
+ ...(record.agent ? { agent: record.agent } : {}),
951
+ })
952
+ } catch {
953
+ result = { effect: "deny" }
954
+ }
955
+ const effect = result?.data?.data?.effect
956
+ let decision = "reject"
957
+ if (effect === "allow") decision = "once"
958
+ else if (effect === "ask") {
959
+ decision = await awaitReply(
960
+ record,
961
+ record.sessionID,
962
+ permissionID,
963
+ mappedDigest,
964
+ request,
965
+ response,
966
+ )
967
+ }
968
+ record.replays.set(requestID, { digest, done: true, decision })
969
+ respond(response, requestID, decision)
970
+ } catch {
971
+ respond(response, requestID, "reject", 500)
972
+ } finally {
973
+ if (counted) pendingCount -= 1
974
+ }
975
+ }
976
+
977
+ const server = createServer((request, response) => void handlePermission(request, response))
978
+ await listenLoopback(server)
979
+ server.unref()
980
+ const address = server.address()
981
+ const brokerUrl = `http://127.0.0.1:${address.port}${BROKER_PATH}`
982
+
983
+ const abortTracked = async (sessionID, tracked, limit) => {
984
+ if (
985
+ disposed ||
986
+ tracked.aborted ||
987
+ tracked.abortAttempted ||
988
+ cleanedChildren.has(sessionID)
989
+ ) return
990
+ tracked.abortAttempted = true
991
+ tracked.kind = limit.kind
992
+ tracked.status = limit.status
993
+ try {
994
+ if (typeof legacyClient?.session?.abort !== "function") {
995
+ await diagnostic("provider limit abort failed", { kind: limit.kind, reason: "unavailable" })
996
+ return
997
+ }
998
+ const result = await legacyClient.session.abort({ path: { id: sessionID } })
999
+ if (result?.error || result?.data !== true) {
1000
+ await diagnostic("provider limit abort rejected", { kind: limit.kind })
1001
+ return
1002
+ }
1003
+ tracked.aborted = true
1004
+ await diagnostic("provider limit abort accepted", { kind: limit.kind })
1005
+ } catch {
1006
+ await diagnostic("provider limit abort failed", { kind: limit.kind })
1007
+ }
1008
+ }
1009
+
1010
+ const reserveChildCandidate = (sessionID, details = {}) => {
1011
+ if (
1012
+ disposed ||
1013
+ !SAFE_SESSION_FIELD.test(sessionID ?? "") ||
1014
+ cleanedChildren.has(sessionID)
1015
+ ) return undefined
1016
+ let tracked = trackedChildren.get(sessionID)
1017
+ if (!tracked) {
1018
+ if (trackedChildren.size >= Math.max(1, maxTrackedChildren)) return undefined
1019
+ tracked = { candidate: true, managed: undefined, aborted: false, ...details }
1020
+ trackedChildren.set(sessionID, tracked)
1021
+ }
1022
+ return tracked
1023
+ }
1024
+
1025
+ const recoverAndAbort = async (sessionID, limit) => {
1026
+ if (disposed || cleanedChildren.has(sessionID)) return
1027
+ const tracked = reserveChildCandidate(sessionID)
1028
+ if (!tracked) return
1029
+ if (tracked.managed === undefined) {
1030
+ const ownership = await confirmManagedSession(sessionID)
1031
+ if (disposed || cleanedChildren.has(sessionID)) return
1032
+ if (!ownership || ownership.manager) {
1033
+ tracked.managed = false
1034
+ return
1035
+ }
1036
+ tracked.managed = true
1037
+ tracked.parentID = ownership.parentID
1038
+ tracked.agent = ownership.session.agent
1039
+ }
1040
+ if (tracked.managed) await abortTracked(sessionID, tracked, limit)
1041
+ }
1042
+
1043
+ const serializeLifecycle = (sessionID, operation) => {
1044
+ const previous = lifecycleOperations.get(sessionID) ?? Promise.resolve()
1045
+ const current = previous.catch(() => {}).then(operation)
1046
+ lifecycleOperations.set(sessionID, current)
1047
+ return current.finally(() => {
1048
+ if (lifecycleOperations.get(sessionID) === current) lifecycleOperations.delete(sessionID)
1049
+ })
1050
+ }
1051
+
1052
+ const tombstoneChild = (sessionID, confirmedChild = false) => {
1053
+ if (!sessionID || (!confirmedChild && !trackedChildren.has(sessionID))) return false
1054
+ trackedChildren.delete(sessionID)
1055
+ if (cleanedChildren.size >= Math.max(1, maxTrackedChildren)) {
1056
+ cleanedChildren.delete(cleanedChildren.values().next().value)
1057
+ }
1058
+ cleanedChildren.add(sessionID)
1059
+ return true
1060
+ }
1061
+
1062
+ const dispose = async () => {
1063
+ if (disposed) return
1064
+ disposed = true
1065
+ for (const record of brokerInvocations.values()) record.valid = false
1066
+ brokerInvocations.clear()
1067
+ approvals.clear()
1068
+ trackedChildren.clear()
1069
+ lifecycleOperations.clear()
1070
+ cleanedChildren.clear()
1071
+ permissionEventReplies.clear()
1072
+ rejectWaiters(() => true)
1073
+ await closeServer(server)
1074
+ }
1075
+
1076
+ return {
1077
+ "chat.message": async (input) => {
1078
+ rememberAgent(input.sessionID, input.agent)
1079
+ },
1080
+ "chat.params": async (input) => {
1081
+ rememberAgent(input.sessionID, input.agent)
1082
+ },
1083
+ "permission.ask": async (input, output) => {
1084
+ const permission = input?.type ?? input?.permission
1085
+ if (permission !== "bash" && permission !== "Bash") return
1086
+ if (output?.status !== "ask") return
1087
+ const sessionID = boundedString(input?.sessionID, 256)
1088
+ if (!sessionID || !SAFE_SESSION_FIELD.test(sessionID) || !(await confirmManagedSession(sessionID))) return
1089
+ const patterns = Array.isArray(input?.pattern) ? input.pattern : [input?.pattern]
1090
+ if (!patterns.length || patterns.some((pattern) => typeof pattern !== "string")) return
1091
+ for (const pattern of patterns) if (!(await safeCommand(pattern, approvalRoot))) return
1092
+ output.status = "allow"
1093
+ },
1094
+ "tool.execute.before": async (input, output) => {
1095
+ const invocationID = invocationFor(input.callID)
1096
+ const command = output?.args?.command
1097
+ if ((input.tool === "Bash" || input.tool === "bash") && directPermissionClaude(command)) {
1098
+ const key = `${input.sessionID ?? ""}\0${input.callID ?? ""}`
1099
+ invalidate(input.sessionID, input.callID)
1100
+ brokerInvocations.set(key, {
1101
+ token: randomBytes(32).toString("base64url"),
1102
+ nonce: randomBytes(32).toString("base64url"),
1103
+ invocationID,
1104
+ sessionID: String(input.sessionID ?? ""),
1105
+ callID: String(input.callID ?? ""),
1106
+ agent: agents.get(input.sessionID),
1107
+ replays: new Map(),
1108
+ valid: true,
1109
+ })
1110
+ }
1111
+ if (!RTRT_AGENT_TOOLS.has(input.tool)) return
1112
+
1113
+ output.args.invocation_id = invocationID
1114
+ output.args.parent_project = parentProject
1115
+ output.args.parent_session_id = input.sessionID
1116
+ output.args.parent_call_id = input.callID
1117
+ output.args.caller_agent = agents.get(input.sessionID)
1118
+ output.args.parent_cwd = directory
1119
+ output.args.parent_worktree = parentWorktree
1120
+ },
1121
+ "shell.env": async (input, output) => {
1122
+ if (parentWorktree) {
1123
+ const sessionTempDir = await ensureSessionTempDir(parentWorktree, input.sessionID)
1124
+ output.env.TMPDIR = sessionTempDir
1125
+ output.env.TEMP = sessionTempDir
1126
+ output.env.TMP = sessionTempDir
1127
+ }
1128
+ output.env.RTRT_INVOCATION_ID = invocationFor(input.callID)
1129
+ output.env.RTRT_OPENCODE_PLUGIN_ACTIVE = "1"
1130
+ output.env.RTRT_PARENT_PROJECT = parentProject
1131
+ output.env.RTRT_PARENT_CWD = input.cwd
1132
+ output.env.RTRT_PARENT_WORKTREE = parentWorktree
1133
+ if (input.sessionID) {
1134
+ output.env.RTRT_PARENT_SESSION_ID = input.sessionID
1135
+ const agent = agents.get(input.sessionID)
1136
+ if (agent) output.env.RTRT_PARENT_AGENT = agent
1137
+ }
1138
+ if (input.callID) output.env.RTRT_PARENT_CALL_ID = input.callID
1139
+ const record = brokerInvocations.get(`${input.sessionID ?? ""}\0${input.callID ?? ""}`)
1140
+ if (record?.valid) {
1141
+ output.env.RTRT_PERMISSION_BROKER_URL = brokerUrl
1142
+ output.env.RTRT_PERMISSION_BROKER_TOKEN = record.token
1143
+ output.env.RTRT_PERMISSION_BROKER_NONCE = record.nonce
1144
+ }
1145
+ },
1146
+ "tool.execute.after": async (input) => {
1147
+ invalidate(input.sessionID, input.callID)
1148
+ invocations.delete(input.callID)
1149
+ },
1150
+ event: async (input) => {
1151
+ const event = input?.event ?? input
1152
+ const properties = event?.properties ?? {}
1153
+ if (event?.type === "session.created") {
1154
+ const sessionID = boundedString(properties.info?.id, 256)
1155
+ const parentID = boundedString(properties.info?.parentID, 256)
1156
+ const agent = boundedString(properties.info?.agent, 256)
1157
+ if (
1158
+ sessionID &&
1159
+ parentID &&
1160
+ SAFE_SESSION_FIELD.test(sessionID) &&
1161
+ SAFE_SESSION_FIELD.test(parentID) &&
1162
+ !cleanedChildren.has(sessionID) &&
1163
+ (!trackedChildren.has(sessionID) || trackedChildren.get(sessionID).managed === undefined) &&
1164
+ (trackedChildren.has(sessionID) || trackedChildren.size < maxTrackedChildren)
1165
+ ) {
1166
+ // Reserve synchronously: OpenCode does not await plugin event callbacks.
1167
+ const tracked = reserveChildCandidate(sessionID, { parentID, agent })
1168
+ if (!tracked) return
1169
+ await serializeLifecycle(sessionID, async () => {
1170
+ const ownership = await confirmManagedSession(sessionID)
1171
+ if (disposed || cleanedChildren.has(sessionID)) return
1172
+ tracked.managed = Boolean(
1173
+ ownership &&
1174
+ !ownership.manager &&
1175
+ ownership.parentID === parentID &&
1176
+ (!agent || ownership.session.agent === agent)
1177
+ )
1178
+ if (tracked.managed) tracked.agent = ownership.session.agent
1179
+ })
1180
+ }
1181
+ } else if (event?.type === "permission.asked") {
1182
+ const requestID = boundedString(properties.id, 256)
1183
+ const sessionID = boundedString(properties.sessionID, 256)
1184
+ const patterns = properties.patterns
1185
+ if (
1186
+ properties.permission !== "bash" ||
1187
+ !requestID ||
1188
+ !sessionID ||
1189
+ !SAFE_SESSION_FIELD.test(requestID) ||
1190
+ !SAFE_SESSION_FIELD.test(sessionID) ||
1191
+ !Array.isArray(patterns) ||
1192
+ patterns.length === 0 ||
1193
+ patterns.length > MAX_PERMISSION_EVENT_REPLIES ||
1194
+ patterns.some((pattern) => typeof pattern !== "string") ||
1195
+ !approvalRoot ||
1196
+ permissionEventReplies.size >= MAX_PERMISSION_EVENT_REPLIES
1197
+ ) return
1198
+ const key = `${sessionID}\0${requestID}`
1199
+ if (permissionEventReplies.has(key)) return
1200
+ // Reserve before asynchronous classification so duplicate events cannot race a reply.
1201
+ permissionEventReplies.add(key)
1202
+ const ownership = await confirmManagedSession(sessionID)
1203
+ if (!ownership || !(await confirmPermissionRequest(requestID, sessionID, patterns))) return
1204
+ for (const pattern of patterns) {
1205
+ if (!(await safeCommand(pattern, approvalRoot))) return
1206
+ }
1207
+ if (disposed) return
1208
+ try {
1209
+ await v2Client.permission.reply({ requestID, reply: "once" })
1210
+ } catch {
1211
+ // SDK/network failures fail closed; retained reservation prevents uncertain retries.
1212
+ }
1213
+ } else if (event?.type === "session.error") {
1214
+ const sessionID = boundedString(properties.sessionID, 256)
1215
+ const limit = sessionErrorProviderLimit(properties.error)
1216
+ if (sessionID && limit && reserveChildCandidate(sessionID)) {
1217
+ await serializeLifecycle(sessionID, () => recoverAndAbort(sessionID, limit))
1218
+ }
1219
+ } else if (event?.type === "session.next.retried" || event?.type === "session.status") {
1220
+ const sessionID = boundedString(properties.sessionID ?? properties.session_id, 256)
1221
+ const limit = event.type === "session.next.retried"
1222
+ ? providerLimit(properties.error)
1223
+ : statusProviderLimit(properties.status)
1224
+ if (sessionID && (!limit || reserveChildCandidate(sessionID))) {
1225
+ await serializeLifecycle(
1226
+ sessionID,
1227
+ () => limit ? recoverAndAbort(sessionID, limit) : undefined,
1228
+ )
1229
+ }
1230
+ } else if (event?.type === "permission.v2.replied") {
1231
+ const sessionID = properties.sessionID ?? properties.session_id
1232
+ const requestID = properties.requestID ?? properties.request_id ?? properties.id
1233
+ const key = `${sessionID}\0${requestID}`
1234
+ const waiter = permissionWaiters.get(key)
1235
+ if (waiter) {
1236
+ const reply = properties.reply ?? properties.response ?? properties.decision
1237
+ if (reply === "always" || reply === "allow_always") {
1238
+ waiter.resolve(
1239
+ waiter.record.valid && cacheApproval(waiter.sessionID, waiter.digest)
1240
+ ? "always"
1241
+ : "reject",
1242
+ )
1243
+ } else waiter.resolve(reply === "once" || reply === "allow_once" ? "once" : "reject")
1244
+ }
1245
+ } else if (event?.type === "session.idle" || event?.type === "session.deleted") {
1246
+ const sessionID = properties.sessionID ?? properties.session_id ?? properties.info?.id
1247
+ // Only known child candidates become terminal tombstones. Parent idle is ordinary.
1248
+ const deletedChild = event.type === "session.deleted" &&
1249
+ SAFE_SESSION_FIELD.test(properties.info?.parentID ?? "")
1250
+ tombstoneChild(sessionID, deletedChild)
1251
+ if (sessionID) void serializeLifecycle(sessionID, async () => {})
1252
+ if (event.type !== "session.deleted") return
1253
+ for (const key of permissionEventReplies) {
1254
+ if (key.startsWith(`${sessionID}\0`)) permissionEventReplies.delete(key)
1255
+ }
1256
+ approvals.delete(sessionID)
1257
+ for (const record of [...brokerInvocations.values()]) {
1258
+ if (record.sessionID === sessionID) invalidate(record.sessionID, record.callID)
1259
+ }
1260
+ }
1261
+ },
1262
+ dispose,
1263
+ }
1264
+ }
1265
+
1266
+ export const RtrtProvenance = (input) => createPlugin(input)
1267
+
1268
+ export const __createRtrtProvenanceForTest = (input, options) => createPlugin(input, options)
1269
+ export const __resolveManagedAgentStatePathForTest = resolveManagedAgentStatePath
1270
+ // END rtrt-managed provenance plugin