@rosetears/aili-pi 0.1.9 → 0.1.10

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.
Files changed (57) hide show
  1. package/README.md +6 -6
  2. package/THIRD_PARTY_NOTICES.md +12 -12
  3. package/docs/persistent-agents.md +114 -0
  4. package/manifests/adapter-evidence.json +53 -20
  5. package/manifests/capabilities.json +3 -3
  6. package/manifests/live-verification.json +18 -19
  7. package/manifests/provenance.json +12 -12
  8. package/manifests/roles.json +270 -57
  9. package/manifests/sbom.json +1 -128
  10. package/manifests/skill-compatibility.json +57 -61
  11. package/manifests/subagent-provenance.json +8 -15
  12. package/package.json +3 -3
  13. package/roles/agent-evaluator.md +8 -3
  14. package/roles/ai-regression-scout.md +8 -3
  15. package/roles/browser-qa-runner.md +8 -3
  16. package/roles/code-reviewer.md +8 -3
  17. package/roles/code-scout.md +8 -3
  18. package/roles/convergence-reviewer.md +8 -3
  19. package/roles/doc-researcher.md +8 -3
  20. package/roles/e2e-artifact-runner.md +8 -3
  21. package/roles/general.md +49 -0
  22. package/roles/implementer.md +8 -3
  23. package/roles/opensource-sanitizer.md +8 -3
  24. package/roles/plan-auditor.md +8 -3
  25. package/roles/pr-test-analyzer.md +8 -3
  26. package/roles/security-auditor.md +8 -3
  27. package/roles/silent-failure-reviewer.md +8 -3
  28. package/roles/spec-miner.md +8 -3
  29. package/roles/test-coverage-reviewer.md +8 -3
  30. package/roles/test-engineer.md +8 -3
  31. package/roles/web-performance-auditor.md +8 -3
  32. package/roles/web-researcher.md +8 -3
  33. package/scripts/sync-roles.ts +186 -24
  34. package/src/runtime/doctor.ts +38 -10
  35. package/src/runtime/global-resources.ts +5 -1
  36. package/src/runtime/index.ts +2 -2
  37. package/src/runtime/persistent-agents/hub.ts +429 -0
  38. package/src/runtime/persistent-agents/model-selection.ts +363 -0
  39. package/src/runtime/persistent-agents/output-delivery.ts +356 -0
  40. package/src/runtime/persistent-agents/permission.ts +223 -0
  41. package/src/runtime/persistent-agents/policy.ts +311 -0
  42. package/src/runtime/persistent-agents/production.ts +569 -0
  43. package/src/runtime/persistent-agents/runtime.ts +164 -0
  44. package/src/runtime/persistent-agents/sandbox.ts +68 -0
  45. package/src/runtime/persistent-agents/scheduler.ts +190 -0
  46. package/src/runtime/persistent-agents/session-factory.ts +184 -0
  47. package/src/runtime/persistent-agents/storage.ts +775 -0
  48. package/src/runtime/persistent-agents/task-coordinator.ts +460 -0
  49. package/src/runtime/persistent-agents/task-schema.ts +141 -0
  50. package/src/runtime/persistent-agents/types.ts +134 -0
  51. package/src/runtime/persistent-agents/workspace.ts +335 -0
  52. package/src/runtime/registry.ts +28 -32
  53. package/src/runtime/roles.ts +211 -18
  54. package/src/runtime/rose-context.ts +1 -1
  55. package/templates/APPEND_SYSTEM.md +1 -1
  56. package/upstream/opencode-global-agents.lock.json +1 -1
  57. package/src/runtime/subagents.ts +0 -249
@@ -0,0 +1,356 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, open, readFile, realpath, rename, rm } from "node:fs/promises";
3
+ import { relative, resolve, sep, isAbsolute } from "node:path";
4
+ import type { CoordinatorJournal } from "./storage.js";
5
+ import {
6
+ assertSafeAgentId,
7
+ ensureSidecarLayout,
8
+ replayCoordinator,
9
+ sidecarLayoutForParent,
10
+ validateExactChildSessionPath,
11
+ } from "./storage.js";
12
+ import type { AgentRecord, SidecarLayout } from "./types.js";
13
+ import type { NormalizedTaskSettlement } from "./task-coordinator.js";
14
+ import { assertNoCredentialMaterial } from "./permission.js";
15
+
16
+ export const PARENT_PREVIEW_CHAR_LIMIT = 5_000;
17
+ export const BUILTIN_PARENT_DELETE_GAP = "official Pi 0.81.1 built-in Ctrl+D/archive does not cascade AILI sidecars; use confirmed AILI deletion or reconciliation";
18
+
19
+ function isInside(root: string, candidate: string): boolean {
20
+ const rel = relative(root, candidate);
21
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
22
+ }
23
+
24
+ async function atomicWrite(path: string, content: string): Promise<void> {
25
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
26
+ const handle = await open(temporary, "wx", 0o600);
27
+ try {
28
+ await handle.writeFile(content, "utf8");
29
+ await handle.sync();
30
+ } finally {
31
+ await handle.close();
32
+ }
33
+ await rename(temporary, path);
34
+ }
35
+
36
+ function ownedAgent(state: ReturnType<CoordinatorJournal["getState"]>, agentId: string): AgentRecord {
37
+ const agent = state.agents[agentId] ?? state.releasedAgents[agentId];
38
+ if (!agent) throw new Error(`${agentId}: Agent is not owned by this parent`);
39
+ return agent;
40
+ }
41
+
42
+ export function agentOutputPath(layout: SidecarLayout, agentId: string): string {
43
+ assertSafeAgentId(agentId);
44
+ const path = resolve(layout.agentsDir, `${agentId}.md`);
45
+ if (!isInside(resolve(layout.agentsDir), path)) throw new Error(`${agentId}: output path escapes agents directory`);
46
+ return path;
47
+ }
48
+
49
+ export async function persistFullAgentOutput(layout: SidecarLayout, agentId: string, fullOutput: string): Promise<string> {
50
+ await assertNoCredentialMaterial(fullOutput, "Agent output artifact");
51
+ const outputPath = agentOutputPath(layout, agentId);
52
+ try {
53
+ const stat = await lstat(outputPath);
54
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${agentId}: output target is not a real file`);
55
+ } catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
57
+ }
58
+ await atomicWrite(outputPath, fullOutput);
59
+ return outputPath;
60
+ }
61
+
62
+ export interface BoundedTextResult {
63
+ content: string;
64
+ offset: number;
65
+ limit: number;
66
+ total: number;
67
+ returned: number;
68
+ truncated: boolean;
69
+ source: string;
70
+ diagnostic?: string;
71
+ }
72
+
73
+ function boundedLines(lines: string[], offset: number, limit: number, source: string, diagnostic?: string): BoundedTextResult {
74
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("offset must be a non-negative integer");
75
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("limit must be a positive integer");
76
+ const selected = lines.slice(offset, offset + limit);
77
+ return {
78
+ content: selected.join("\n"),
79
+ offset,
80
+ limit,
81
+ total: lines.length,
82
+ returned: selected.length,
83
+ truncated: offset > 0 || offset + selected.length < lines.length,
84
+ source,
85
+ diagnostic,
86
+ };
87
+ }
88
+
89
+ export function parseAgentReference(reference: string): { kind: "output" | "history"; agentId: string } {
90
+ const match = reference.match(/^(agent|history):\/\/([^/?#]+)$/);
91
+ if (!match) throw new Error(`invalid Agent reference: ${reference}`);
92
+ const agentId = decodeURIComponent(match[2]!);
93
+ assertSafeAgentId(agentId);
94
+ return { kind: match[1] === "agent" ? "output" : "history", agentId };
95
+ }
96
+
97
+ export async function readAgentOutput(layout: SidecarLayout, journal: CoordinatorJournal, agentId: string, offset = 0, limit = 500): Promise<BoundedTextResult> {
98
+ ownedAgent(journal.getState(), agentId);
99
+ const outputPath = agentOutputPath(layout, agentId);
100
+ const content = await readFile(outputPath, "utf8");
101
+ await assertNoCredentialMaterial(content, "Agent output read");
102
+ return boundedLines(content.length === 0 ? [] : content.split("\n"), offset, limit, outputPath);
103
+ }
104
+
105
+ function entryText(entry: Record<string, unknown>): string {
106
+ if (entry.type === "session") return `[session] ${String(entry.id ?? "")}`.trim();
107
+ if (entry.type === "custom_message") return `[custom:${String(entry.customType ?? "unknown")}] ${String(entry.content ?? "")}`.trim();
108
+ if (entry.type === "message" && entry.message && typeof entry.message === "object") {
109
+ const message = entry.message as Record<string, unknown>;
110
+ const role = String(message.role ?? "message");
111
+ const content = message.content;
112
+ if (typeof content === "string") return `[${role}] ${content}`;
113
+ if (Array.isArray(content)) {
114
+ const parts = content.map((part) => {
115
+ if (!part || typeof part !== "object") return "";
116
+ const item = part as Record<string, unknown>;
117
+ if (typeof item.text === "string") return item.text;
118
+ if (typeof item.name === "string") return `[tool:${item.name}]`;
119
+ return `[${String(item.type ?? "content")}]`;
120
+ }).filter(Boolean);
121
+ return `[${role}] ${parts.join(" ")}`;
122
+ }
123
+ return `[${role}]`;
124
+ }
125
+ return `[${String(entry.type ?? "entry")}]`;
126
+ }
127
+
128
+ export async function readAgentHistory(layout: SidecarLayout, journal: CoordinatorJournal, agentId: string, offset = 0, limit = 500): Promise<BoundedTextResult> {
129
+ const agent = ownedAgent(journal.getState(), agentId);
130
+ if (!agent.sessionPath) throw new Error(`${agentId}: no child Session JSONL is registered`);
131
+ const sessionPath = await validateExactChildSessionPath(layout, agent.sessionPath);
132
+ const content = await readFile(sessionPath, "utf8");
133
+ const rawLines = content.split("\n");
134
+ if (rawLines.at(-1) === "") rawLines.pop();
135
+ const rendered: string[] = [];
136
+ let diagnostic: string | undefined;
137
+ for (let index = 0; index < rawLines.length; index += 1) {
138
+ const line = rawLines[index]!;
139
+ try {
140
+ rendered.push(entryText(JSON.parse(line) as Record<string, unknown>));
141
+ } catch (error) {
142
+ if (index === rawLines.length - 1 && !content.endsWith("\n")) {
143
+ diagnostic = `ignored final partial child JSONL line (${Buffer.byteLength(line)} bytes)`;
144
+ break;
145
+ }
146
+ throw new Error(`${agentId}: child history corruption at line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
147
+ }
148
+ }
149
+ await assertNoCredentialMaterial(rendered, "Agent history read");
150
+ return boundedLines(rendered, offset, limit, sessionPath, diagnostic);
151
+ }
152
+
153
+ export interface ParentResultMessage {
154
+ customType: "aili.agent-result";
155
+ content: string;
156
+ display: true;
157
+ details: {
158
+ deliveryId: string;
159
+ agentId: string;
160
+ jobId: string;
161
+ turnId: string;
162
+ status: string;
163
+ outputRef: string;
164
+ historyRef: string;
165
+ previewTruncated: boolean;
166
+ };
167
+ }
168
+
169
+ export interface ParentDeliveryAdapter {
170
+ scanDeliveryIds(): Promise<Set<string>>;
171
+ send(message: ParentResultMessage): Promise<"sent" | "unavailable">;
172
+ }
173
+
174
+ export function scanDeliveryIdsFromParentEntries(entries: unknown[]): Set<string> {
175
+ const found = new Set<string>();
176
+ for (const raw of entries) {
177
+ if (!raw || typeof raw !== "object") continue;
178
+ const entry = raw as Record<string, unknown>;
179
+ const candidates = [entry, entry.message].filter((candidate): candidate is Record<string, unknown> => Boolean(candidate && typeof candidate === "object"));
180
+ for (const candidate of candidates) {
181
+ const customType = candidate.customType ?? entry.customType;
182
+ const details = (candidate.details ?? entry.details) as Record<string, unknown> | undefined;
183
+ if (customType === "aili.agent-result" && typeof details?.deliveryId === "string") found.add(details.deliveryId);
184
+ }
185
+ }
186
+ return found;
187
+ }
188
+
189
+ function preview(fullOutput: string): { content: string; truncated: boolean } {
190
+ if (fullOutput.length <= PARENT_PREVIEW_CHAR_LIMIT) return { content: fullOutput, truncated: false };
191
+ return { content: fullOutput.slice(-PARENT_PREVIEW_CHAR_LIMIT), truncated: true };
192
+ }
193
+
194
+ export class AsyncDeliveryService {
195
+ private readonly deliveryTails = new Map<string, Promise<void>>();
196
+
197
+ constructor(
198
+ private readonly layout: SidecarLayout,
199
+ private readonly journal: CoordinatorJournal,
200
+ private readonly parent: ParentDeliveryAdapter,
201
+ ) {}
202
+
203
+ async complete(settlement: NormalizedTaskSettlement, fullOutput: string): Promise<{ deliveryId?: string; status: "skipped-sync" | "pending" | "delivered"; deduplicated?: boolean }> {
204
+ if (!settlement.deliveryRequired || !settlement.async) return { status: "skipped-sync" };
205
+ const deliveryId = `delivery-${settlement.jobId}`;
206
+ return await this.withDeliveryLock(deliveryId, async () => await this.completeLocked(settlement, fullOutput, deliveryId));
207
+ }
208
+
209
+ private async completeLocked(settlement: NormalizedTaskSettlement, fullOutput: string, deliveryId: string): Promise<{ deliveryId: string; status: "pending" | "delivered"; deduplicated?: boolean }> {
210
+ const state = this.journal.getState();
211
+ const agent = ownedAgent(state, settlement.agentId);
212
+ const existing = state.deliveries[deliveryId];
213
+ if (existing) {
214
+ if (existing.agentId !== settlement.agentId || existing.jobId !== settlement.jobId) throw new Error(`${deliveryId}: conflicting retry ownership`);
215
+ if (existing.status === "delivered") return { deliveryId, status: "delivered", deduplicated: true };
216
+ return await this.deliver(deliveryId);
217
+ }
218
+ if (!agent.sessionPath) throw new Error(`${settlement.agentId}: child session path must exist before async delivery`);
219
+ await persistFullAgentOutput(this.layout, settlement.agentId, fullOutput);
220
+ await validateExactChildSessionPath(this.layout, agent.sessionPath);
221
+ const outputPreview = preview(fullOutput);
222
+ await this.journal.append({
223
+ kind: "delivery.put",
224
+ agentId: settlement.agentId,
225
+ jobId: settlement.jobId,
226
+ turnId: settlement.turnId,
227
+ deliveryId,
228
+ payload: {
229
+ status: "pending",
230
+ deliveryId,
231
+ agentId: settlement.agentId,
232
+ jobId: settlement.jobId,
233
+ turnId: settlement.turnId,
234
+ resultStatus: settlement.status,
235
+ outputRef: settlement.outputRef,
236
+ historyRef: settlement.historyRef,
237
+ preview: outputPreview.content,
238
+ previewTruncated: outputPreview.truncated,
239
+ },
240
+ });
241
+ return await this.deliver(deliveryId);
242
+ }
243
+
244
+ async recoverPending(): Promise<Array<{ deliveryId: string; status: "pending" | "delivered"; deduplicated?: boolean }>> {
245
+ const ids = Object.entries(this.journal.getState().deliveries)
246
+ .filter(([, delivery]) => delivery.status === "pending")
247
+ .map(([id]) => id);
248
+ const results = [];
249
+ for (const id of ids) results.push(await this.withDeliveryLock(id, async () => await this.deliver(id)));
250
+ return results;
251
+ }
252
+
253
+ private async withDeliveryLock<T>(deliveryId: string, operation: () => Promise<T>): Promise<T> {
254
+ const previous = this.deliveryTails.get(deliveryId) ?? Promise.resolve();
255
+ const current = previous.then(operation);
256
+ const tail = current.then(() => undefined, () => undefined);
257
+ this.deliveryTails.set(deliveryId, tail);
258
+ try {
259
+ return await current;
260
+ } finally {
261
+ if (this.deliveryTails.get(deliveryId) === tail) this.deliveryTails.delete(deliveryId);
262
+ }
263
+ }
264
+
265
+ private async deliver(deliveryId: string): Promise<{ deliveryId: string; status: "pending" | "delivered"; deduplicated?: boolean }> {
266
+ const delivery = this.journal.getState().deliveries[deliveryId];
267
+ if (!delivery) throw new Error(`${deliveryId}: unknown delivery`);
268
+ if (delivery.status === "delivered") return { deliveryId, status: "delivered", deduplicated: true };
269
+ const existing = await this.parent.scanDeliveryIds();
270
+ if (existing.has(deliveryId)) {
271
+ await this.ack(deliveryId, delivery);
272
+ return { deliveryId, status: "delivered", deduplicated: true };
273
+ }
274
+ const truncated = delivery.previewTruncated === true;
275
+ const content = [
276
+ `Agent ${delivery.agentId} job ${delivery.jobId} ${delivery.resultStatus}.`,
277
+ truncated ? `[preview truncated to ${PARENT_PREVIEW_CHAR_LIMIT} characters; full output: ${delivery.outputRef}]` : `Full output: ${delivery.outputRef}`,
278
+ String(delivery.preview ?? ""),
279
+ `History: ${delivery.historyRef}`,
280
+ ].join("\n");
281
+ let sent: "sent" | "unavailable";
282
+ try {
283
+ sent = await this.parent.send({
284
+ customType: "aili.agent-result",
285
+ content,
286
+ display: true,
287
+ details: {
288
+ deliveryId,
289
+ agentId: String(delivery.agentId),
290
+ jobId: String(delivery.jobId),
291
+ turnId: String(delivery.turnId),
292
+ status: String(delivery.resultStatus),
293
+ outputRef: String(delivery.outputRef),
294
+ historyRef: String(delivery.historyRef),
295
+ previewTruncated: truncated,
296
+ },
297
+ });
298
+ } catch {
299
+ return { deliveryId, status: "pending" };
300
+ }
301
+ if (sent === "unavailable") return { deliveryId, status: "pending" };
302
+ await this.ack(deliveryId, delivery);
303
+ return { deliveryId, status: "delivered" };
304
+ }
305
+
306
+ private async ack(deliveryId: string, delivery: Record<string, unknown>): Promise<void> {
307
+ await this.journal.append({
308
+ kind: "delivery.put",
309
+ agentId: String(delivery.agentId),
310
+ jobId: String(delivery.jobId),
311
+ turnId: String(delivery.turnId),
312
+ deliveryId,
313
+ payload: { ...delivery, status: "delivered", deliveredAt: new Date().toISOString() },
314
+ });
315
+ }
316
+ }
317
+
318
+ export async function initializeEmptyForkSidecar(parentSessionPath: string, parentId: string): Promise<{ layout: SidecarLayout; diagnostic: string }> {
319
+ const layout = await ensureSidecarLayout(parentSessionPath);
320
+ const replay = await replayCoordinator(layout, parentId);
321
+ if (replay.state.lastSequence !== 0 || Object.keys(replay.state.agents).length > 0 || Object.keys(replay.state.releasedAgents).length > 0) {
322
+ throw new Error("fork sidecar is not empty; existing child artifacts are never copied, adopted, or reset");
323
+ }
324
+ return { layout, diagnostic: "fork registry initialized empty; no child artifacts copied" };
325
+ }
326
+
327
+ export async function inspectParentSidecar(parentSessionPath: string): Promise<{ status: "attached" | "orphaned" | "missing"; root: string; diagnostic: string }> {
328
+ const layout = sidecarLayoutForParent(parentSessionPath);
329
+ let parentExists = true;
330
+ let rootExists = true;
331
+ try { await lstat(parentSessionPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") parentExists = false; else throw error; }
332
+ try { await lstat(layout.root); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") rootExists = false; else throw error; }
333
+ if (!rootExists) return { status: "missing", root: layout.root, diagnostic: BUILTIN_PARENT_DELETE_GAP };
334
+ return {
335
+ status: parentExists ? "attached" : "orphaned",
336
+ root: layout.root,
337
+ diagnostic: parentExists ? BUILTIN_PARENT_DELETE_GAP : `orphaned sidecar preserved; ${BUILTIN_PARENT_DELETE_GAP}`,
338
+ };
339
+ }
340
+
341
+ export async function confirmedDeleteParentAndSidecar(options: {
342
+ parentSessionPath: string;
343
+ parentId: string;
344
+ confirmation: string;
345
+ }): Promise<{ deletedParent: boolean; deletedSidecar: boolean }> {
346
+ if (options.confirmation !== `DELETE ${options.parentId}`) throw new Error("exact parent deletion confirmation is required");
347
+ const layout = sidecarLayoutForParent(options.parentSessionPath);
348
+ const rootStat = await lstat(layout.root);
349
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error("owned sidecar root must be a real directory");
350
+ const canonicalRoot = await realpath(layout.root);
351
+ if (canonicalRoot !== resolve(layout.root)) throw new Error("owned sidecar canonical root mismatch");
352
+ await replayCoordinator(layout, options.parentId);
353
+ await rm(layout.root, { recursive: true, force: false });
354
+ await rm(options.parentSessionPath, { force: false });
355
+ return { deletedParent: true, deletedSidecar: true };
356
+ }
@@ -0,0 +1,223 @@
1
+ import { isOutside } from "pi-permission-modes/src/paths.ts";
2
+ import { analyzeBash } from "pi-permission-modes/src/bash-parse.ts";
3
+ import type { Action, ModeDef, Surface } from "pi-permission-modes/src/schema.ts";
4
+ import { decide, decideBashCommand, mostRestrictive } from "../../vendor/pi-permission-modes/resolve.js";
5
+ import { bashMentionsCredentialPath, isProtectedChildPath } from "../credential-guard.js";
6
+
7
+ const FILE_SURFACE: Record<string, Surface> = {
8
+ read: "read",
9
+ write: "write",
10
+ edit: "edit",
11
+ grep: "grep",
12
+ find: "find",
13
+ ls: "ls",
14
+ };
15
+ const SECRET_KEY = /^(?:authorization|auth|credential|credentials|token|accessToken|refreshToken|secret|password|passwd|apiKey|api_key|privateKey|private_key)$/i;
16
+ const SECRET_ASSIGNMENT = /\b(?:authorization:\s*bearer|bearer)\s+\S+|\b(?:token|secret|password|passwd|api[_-]?key|private[_-]?key)\s*[=:]\s*\S+/i;
17
+ const PRIVATE_KEY_BLOCK = /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/;
18
+
19
+ export interface CredentialFinding {
20
+ path: string;
21
+ reason: string;
22
+ }
23
+
24
+ export async function findCredentialMaterial(value: unknown, cwd = process.cwd(), path = "input", seen = new Set<object>()): Promise<CredentialFinding | undefined> {
25
+ if (typeof value === "string") {
26
+ if (PRIVATE_KEY_BLOCK.test(value)) return { path, reason: "private-key material" };
27
+ if (SECRET_ASSIGNMENT.test(value)) return { path, reason: "credential-like assignment" };
28
+ if (await isProtectedChildPath(cwd, value)) return { path, reason: "protected credential/auth path" };
29
+ return undefined;
30
+ }
31
+ if (!value || typeof value !== "object") return undefined;
32
+ if (seen.has(value)) return undefined;
33
+ seen.add(value);
34
+ if (Array.isArray(value)) {
35
+ for (let index = 0; index < value.length; index += 1) {
36
+ const finding = await findCredentialMaterial(value[index], cwd, `${path}[${index}]`, seen);
37
+ if (finding) return finding;
38
+ }
39
+ return undefined;
40
+ }
41
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
42
+ if (SECRET_KEY.test(key) && item !== undefined && item !== null && item !== "") return { path: `${path}.${key}`, reason: "credential-bearing field" };
43
+ const finding = await findCredentialMaterial(item, cwd, `${path}.${key}`, seen);
44
+ if (finding) return finding;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export async function assertNoCredentialMaterial(value: unknown, context: string, cwd = process.cwd()): Promise<void> {
50
+ const finding = await findCredentialMaterial(value, cwd);
51
+ if (finding) throw new Error(`${context} denied credential/auth/private-key material at ${finding.path} (${finding.reason})`);
52
+ }
53
+
54
+ export function redactCredentialText(value: string): string {
55
+ return value
56
+ .replace(/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g, "<redacted-private-key>")
57
+ .replace(/\b(authorization:\s*bearer|bearer)\s+\S+/gi, "$1 <redacted>")
58
+ .replace(/\b(token|secret|password|passwd|api[_-]?key|private[_-]?key)\s*([=:])\s*\S+/gi, "$1$2<redacted>");
59
+ }
60
+
61
+ export interface ChildPermissionDecision {
62
+ action: Action;
63
+ toolName: string;
64
+ target: string;
65
+ reason: string;
66
+ requiresSandbox: boolean;
67
+ parserFallback?: boolean;
68
+ }
69
+
70
+ export interface ChildPermissionResolverOptions {
71
+ mode: ModeDef;
72
+ cwd: string;
73
+ sandboxExecutorAvailable: boolean;
74
+ }
75
+
76
+ export class ChildPermissionResolver {
77
+ constructor(private readonly options: ChildPermissionResolverOptions) {}
78
+
79
+ get modeLabel(): string {
80
+ return this.options.mode.label;
81
+ }
82
+
83
+ async decide(toolName: string, input: Record<string, unknown>): Promise<ChildPermissionDecision> {
84
+ const credential = await findCredentialMaterial(input, this.options.cwd);
85
+ if (credential) {
86
+ return {
87
+ action: "deny",
88
+ toolName,
89
+ target: credential.path,
90
+ reason: `credential hard denial: ${credential.reason}`,
91
+ requiresSandbox: false,
92
+ };
93
+ }
94
+ const surface = FILE_SURFACE[toolName];
95
+ if (surface) {
96
+ const target = typeof input.path === "string" ? input.path : "";
97
+ if (!target) return { action: "deny", toolName, target: "(missing path)", reason: "file tool path is missing", requiresSandbox: false };
98
+ const action = decide(this.options.mode, surface, target, { isOutside: isOutside(this.options.cwd, target) });
99
+ return { action, toolName, target, reason: `${this.options.mode.label} ${surface} policy`, requiresSandbox: false };
100
+ }
101
+ if (toolName === "bash") {
102
+ const command = typeof input.command === "string" ? input.command : "";
103
+ if (!command) return { action: "deny", toolName, target: "(empty command)", reason: "bash command is missing", requiresSandbox: false };
104
+ if (bashMentionsCredentialPath(command)) return { action: "deny", toolName, target: "protected path", reason: "credential path in bash", requiresSandbox: false };
105
+ const requiresSandbox = this.options.mode.sandbox.enabled;
106
+ if (requiresSandbox && !this.options.sandboxExecutorAvailable) {
107
+ return { action: "deny", toolName, target: command, reason: "mode requires sandboxed bash but no audited child sandbox executor is available", requiresSandbox: true };
108
+ }
109
+ if (!requiresSandbox) {
110
+ const action = decide(this.options.mode, "bash", command);
111
+ return { action, toolName, target: command, reason: `${this.options.mode.label} bash policy`, requiresSandbox: false };
112
+ }
113
+ const analysis = await analyzeBash(command, this.options.cwd);
114
+ let action: Action;
115
+ if (analysis.commands.length > 0) {
116
+ action = analysis.commands
117
+ .map((commandPart) => decideBashCommand(this.options.mode, commandPart.name, commandPart.args) ?? "allow")
118
+ .reduce<Action>((left, right) => mostRestrictive(left, right) ?? "allow", "allow");
119
+ } else {
120
+ action = decide(this.options.mode, "bash", command);
121
+ }
122
+ if (analysis.outsideReason) action = mostRestrictive(action, "ask") ?? "ask";
123
+ return {
124
+ action,
125
+ toolName,
126
+ target: command,
127
+ reason: analysis.outsideReason ?? `${this.options.mode.label} bash policy`,
128
+ requiresSandbox: true,
129
+ parserFallback: analysis.usedFallback,
130
+ };
131
+ }
132
+ if (toolName === "web_search") {
133
+ const target = typeof input.query === "string" ? input.query : "(empty)";
134
+ return { action: decide(this.options.mode, "web_search", target), toolName, target, reason: `${this.options.mode.label} web_search policy`, requiresSandbox: false };
135
+ }
136
+ return { action: decide(this.options.mode, "tool", toolName), toolName, target: toolName, reason: `${this.options.mode.label} custom tool policy`, requiresSandbox: false };
137
+ }
138
+ }
139
+
140
+ export interface ApprovalPrompt {
141
+ hasUI: boolean;
142
+ ask(packet: ApprovalRequestPacket): Promise<"allow" | "deny" | "dismiss">;
143
+ }
144
+
145
+ export interface ApprovalRequestPacket {
146
+ requestId: string;
147
+ agentId: string;
148
+ jobId: string;
149
+ toolName: string;
150
+ summary: string;
151
+ modeLabel: string;
152
+ }
153
+
154
+ interface PendingApproval {
155
+ settle: (decision: "allow" | "deny") => void;
156
+ jobId: string;
157
+ signal?: AbortSignal;
158
+ abortListener?: () => void;
159
+ }
160
+
161
+ export function brokeredChildPermission(
162
+ resolver: ChildPermissionResolver,
163
+ broker: ParentApprovalBroker,
164
+ context: { agentId: string; jobId: string; signal?: AbortSignal },
165
+ ): {
166
+ decide: (toolName: string, input: Record<string, unknown>) => Promise<Action>;
167
+ requestApproval: (packet: { toolName: string; summary: string }) => Promise<"allow" | "deny">;
168
+ } {
169
+ return {
170
+ decide: async (toolName, input) => (await resolver.decide(toolName, input)).action,
171
+ requestApproval: async (packet) => await broker.request({
172
+ agentId: context.agentId,
173
+ jobId: context.jobId,
174
+ toolName: packet.toolName,
175
+ summary: packet.summary,
176
+ modeLabel: resolver.modeLabel,
177
+ }, context.signal),
178
+ };
179
+ }
180
+
181
+ export class ParentApprovalBroker {
182
+ private pending = new Map<string, PendingApproval>();
183
+ private closed = false;
184
+ private nextId = 0;
185
+
186
+ constructor(private readonly prompt: ApprovalPrompt) {}
187
+
188
+ pendingCount(jobId?: string): number {
189
+ if (!jobId) return this.pending.size;
190
+ return [...this.pending.values()].filter((pending) => pending.jobId === jobId).length;
191
+ }
192
+
193
+ async request(
194
+ packet: Omit<ApprovalRequestPacket, "requestId">,
195
+ signal?: AbortSignal,
196
+ ): Promise<"allow" | "deny"> {
197
+ if (this.closed || !this.prompt.hasUI || signal?.aborted) return "deny";
198
+ const requestId = `approval-${++this.nextId}`;
199
+ const fullPacket: ApprovalRequestPacket = { ...packet, requestId, summary: redactCredentialText(packet.summary).slice(0, 500) };
200
+ let settleGate!: (decision: "allow" | "deny") => void;
201
+ const gate = new Promise<"allow" | "deny">((resolve) => { settleGate = resolve; });
202
+ const pending: PendingApproval = { settle: settleGate, signal, jobId: packet.jobId };
203
+ if (signal) {
204
+ pending.abortListener = () => settleGate("deny");
205
+ signal.addEventListener("abort", pending.abortListener, { once: true });
206
+ }
207
+ this.pending.set(requestId, pending);
208
+ const promptDecision = this.prompt.ask(fullPacket).then(
209
+ (decision) => decision === "allow" ? "allow" as const : "deny" as const,
210
+ () => "deny" as const,
211
+ );
212
+ const decision = await Promise.race([promptDecision, gate]);
213
+ this.pending.delete(requestId);
214
+ if (signal && pending.abortListener) signal.removeEventListener("abort", pending.abortListener);
215
+ return decision;
216
+ }
217
+
218
+ shutdown(): void {
219
+ this.closed = true;
220
+ for (const pending of this.pending.values()) pending.settle("deny");
221
+ this.pending.clear();
222
+ }
223
+ }