pi-better-subagents 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/failures.ts CHANGED
@@ -4,6 +4,17 @@ import { logPathFor, runDir, taskRuntimeTrust } from "./registry.ts";
4
4
  import { activeFailures, actionableFailures, disposeIncidents, failureIdentity, findIncident, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
5
5
  observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
6
6
  import { evidenceText, foldToolEnd, foldToolStart, newIncidentModel, toolOperation, type IncidentModel, type IncidentSink } from "./incident-model.ts";
7
+ import { SandboxDiagnostics, isDiagnosticReport } from "./shared-sandbox-diagnostics.ts";
8
+
9
+
10
+ let diagnosticsWarned = false;
11
+ const workerDiagnostics = new SandboxDiagnostics({ context: "worker",
12
+ version: "unknown", policy: () => undefined, backend: () => undefined,
13
+ onError: () => {
14
+ if (!diagnosticsWarned) process.stderr.write("Sandbox diagnostics collection gap; enforcement unchanged.\n");
15
+ diagnosticsWarned = true;
16
+ },
17
+ });
7
18
 
8
19
  export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered, toolOperation };
9
20
  export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
@@ -230,7 +241,11 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
230
241
  if (line.startsWith("{")) {
231
242
  try {
232
243
  const row = JSON.parse(line);
233
- if (["tool_execution_start", "tool_execution_end", "message_end", "auto_retry_start", "auto_retry_end"].includes(row?.type)) {
244
+ // Separate sampling from the incident model. Even a trusted worker log
245
+ // only supplies agent-reported evidence, never a policy decision.
246
+ if (row?.type === "sandbox_diagnostic_report") {
247
+ if (taskRuntimeTrust(id) === "trusted" && isDiagnosticReport(row)) workerDiagnostics.observeReport(row, id);
248
+ } else if (["tool_execution_start", "tool_execution_end", "message_end", "auto_retry_start", "auto_retry_end"].includes(row?.type)) {
234
249
  if ((row.type.startsWith("tool_execution_") &&
235
250
  (typeof row.toolCallId !== "string" || !row.toolCallId || typeof row.toolName !== "string")) ||
236
251
  (row.type === "tool_execution_end" && typeof row.isError !== "boolean" && typeof row.result?.isError !== "boolean" && typeof row.result?.exitCode !== "number") ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,491 @@
1
+ // Generated from packages/sandbox-diagnostics/index.ts. Do not edit directly.
2
+ import {
3
+ chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync,
4
+ readFileSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync,
5
+ } from "node:fs";
6
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
7
+
8
+ import { join } from "node:path";
9
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+ import { harnessSettingsPath, readHarnessSetting, updateHarnessSetting } from "./shared-harness-settings.ts";
11
+ import { isPermissionResource, type PermissionResource } from "./shared-permission-blocker.ts";
12
+
13
+ export type DiagnosticResource = PermissionResource | "tool-admission" | "sandbox-backend";
14
+ export type DiagnosticContext = "foreground" | "worker" | "background";
15
+ type Basis = "policy-refusal" | "os-permission-error" | "agent-reported";
16
+ export interface DiagnosticsSeams { agentDir?: () => string }
17
+ export interface DiagnosticObservation {
18
+ tool: string; operation: unknown; resource: DiagnosticResource; basis: Basis;
19
+ outcome: "denied" | "succeeded";
20
+ }
21
+ /** Redacted transport only: a worker log is never policy authority. */
22
+ export interface DiagnosticReport {
23
+ schema: 1; type: "sandbox_diagnostic_report"; tool: string; resource: DiagnosticResource;
24
+ version: string; platform: string; backend: string;
25
+ outcome: "denied" | "succeeded";
26
+ operationFingerprint: string; policyFingerprint: string; idFingerprint: string;
27
+ }
28
+ export interface DiagnosticRecord {
29
+ schema: 1; timestamp: number; version: string; platform: string; backend: string;
30
+ tool: string; resource: DiagnosticResource; basis: Basis; outcome: "denied" | "succeeded";
31
+ context: DiagnosticContext; operationFingerprint: string; policyFingerprint: string;
32
+ fingerprint: string; recoveryOf?: string;
33
+ }
34
+ export interface DiagnosticLosses { age: number; count: number; bytes: number; malformed: number; tampered: number }
35
+ export type DiagnosticIssue = "retention-loss" | "malformed-data" | "tampered-data" | "unsafe-storage" | "unavailable";
36
+ export interface DiagnosticsData {
37
+ schema: 1; enabled: boolean; records: DiagnosticRecord[]; losses: DiagnosticLosses; issues: DiagnosticIssue[];
38
+ }
39
+ export interface DiagnosticGroup {
40
+ version: string; backend: string; tool: string; resource: DiagnosticResource; basis: Basis;
41
+ policyFingerprint: string; denied: number; succeeded: number;
42
+ }
43
+ export interface DiagnosticsAnalysis {
44
+ groups: DiagnosticGroup[]; observedOperations: number; recovered: number; outstanding: number;
45
+ }
46
+
47
+ const MAX_BYTES = 1024 * 1024;
48
+ const MAX_ENTRIES = 2000;
49
+ const MAX_AGE = 30 * 24 * 60 * 60 * 1000;
50
+ const TOOLS = new Set([
51
+ "read", "write", "edit", "bash", "grep", "find", "ls", "process_list", "powershell", "remote_bash",
52
+ "subagent", "subagent_spawn", "subagent_spawn_batch", "bg_task", "bg_status", "bg_task_spawn", "bg_task_watch",
53
+ "apply_patch", "failure_disposition", "extension-tool",
54
+ ]);
55
+ const BACKENDS = new Set(["macos-seatbelt", "linux-bubblewrap", "unknown", "unavailable"]);
56
+ const PLATFORMS = new Set(["aix", "android", "darwin", "freebsd", "linux", "openbsd", "sunos", "win32", "unknown"]);
57
+ const CONTEXTS = new Set(["foreground", "worker", "background"]);
58
+ const BASES = new Set(["policy-refusal", "os-permission-error", "agent-reported"]);
59
+ // Do not accept arbitrary prerelease/build labels: they can contain paths or caller IDs.
60
+ const VERSION = /^\d{1,6}(?:\.\d{1,6}){0,2}(?:-(?:alpha|beta|rc)(?:\.\d{1,6})?)?$/;
61
+ const FP = /^[A-Za-z0-9_-]{43}$/;
62
+ const REPORT_FIELDS = ["schema", "type", "tool", "resource", "version", "platform", "backend", "outcome", "operationFingerprint", "policyFingerprint", "idFingerprint"];
63
+ const RECORD_FIELDS = ["schema", "timestamp", "version", "platform", "backend", "tool", "resource", "basis", "outcome", "context", "operationFingerprint", "policyFingerprint", "fingerprint", "recoveryOf"];
64
+ const LOSS_FIELDS = ["age", "count", "bytes", "malformed", "tampered"] as const;
65
+
66
+ class UnsafeStorage extends Error { constructor() { super("Unsafe diagnostics storage."); } }
67
+ const emptyLosses = (): DiagnosticLosses => ({ age: 0, count: 0, bytes: 0, malformed: 0, tampered: 0 });
68
+ const missing = (error: unknown) => (error as NodeJS.ErrnoException)?.code === "ENOENT";
69
+ function plain(value: unknown): value is Record<string, unknown> {
70
+ return !!value && typeof value === "object" && !Array.isArray(value) &&
71
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
72
+ }
73
+ function fields(value: unknown, allowed: readonly string[]): value is Record<string, unknown> {
74
+ return plain(value) && Reflect.ownKeys(value).every(key => typeof key === "string" && allowed.includes(key));
75
+ }
76
+ function resource(value: unknown): value is DiagnosticResource {
77
+ return isPermissionResource(value) || value === "tool-admission" || value === "sandbox-backend";
78
+ }
79
+ function integer(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; }
80
+ function digest(key: Buffer, domain: string, value: string): string {
81
+ return createHmac("sha256", key).update(domain).update("\0").update(value).digest("base64url");
82
+ }
83
+ function authentic(key: Buffer, domain: string, value: string, mac: unknown): boolean {
84
+ return typeof mac === "string" && FP.test(mac) && timingSafeEqual(Buffer.from(mac), Buffer.from(digest(key, domain, value)));
85
+ }
86
+
87
+ // Canonicalize only data, without invoking getters/toJSON or persisting any of it.
88
+ function identity(value: unknown): string {
89
+ const seen = new Set<object>();
90
+ let nodes = 0;
91
+ function visit(v: unknown, depth: number): unknown {
92
+ if (++nodes > 10000 || depth > 32) throw new Error("Diagnostics identity is too large.");
93
+ if (v === undefined) return ["undefined"];
94
+ if (v === null || typeof v === "boolean" || typeof v === "string") return [typeof v, v];
95
+ if (typeof v === "number" && Number.isFinite(v)) return ["number", v];
96
+ if (!Array.isArray(v) && !plain(v)) throw new Error("Unsupported diagnostics identity.");
97
+ if (seen.has(v as object)) throw new Error("Cyclic diagnostics identity.");
98
+ seen.add(v as object);
99
+ const descriptors = Object.getOwnPropertyDescriptors(v);
100
+ if (Reflect.ownKeys(v as object).some(k => typeof k !== "string")) throw new Error("Unsupported diagnostics identity.");
101
+ const keys = Object.keys(descriptors).filter(k => !(Array.isArray(v) && k === "length")).sort();
102
+ const result = [Array.isArray(v) ? "array" : "object", Array.isArray(v) ? v.length : null, keys.map(k => {
103
+ const d = descriptors[k];
104
+ if (!d || !Object.hasOwn(d, "value")) throw new Error("Unsupported diagnostics identity.");
105
+ return [k, visit(d.value, depth + 1)];
106
+ })];
107
+ seen.delete(v as object);
108
+ return result;
109
+ }
110
+ const result = JSON.stringify(visit(value, 0));
111
+ if (Buffer.byteLength(result) > MAX_BYTES) throw new Error("Diagnostics identity is too large.");
112
+ return result;
113
+ }
114
+
115
+ export function diagnosticsEnabled(seams: DiagnosticsSeams = {}): boolean {
116
+ const value = readHarnessSetting("sandboxDiagnostics", seams);
117
+ return fields(value, ["version", "enabled"]) && value.version === 1 && value.enabled === true;
118
+ }
119
+ export function diagnosticPackageVersion(manifest: URL): string {
120
+ try {
121
+ const value: unknown = JSON.parse(readFileSync(manifest, "utf8"));
122
+ return plain(value) && typeof value.version === "string" && VERSION.test(value.version) ? value.version : "unknown";
123
+ } catch { return "unknown"; }
124
+ }
125
+ // Settings are atomically replaced by the parent. Workers must not acquire a writable settings lock.
126
+ function relayEnabled(seams: DiagnosticsSeams): boolean {
127
+ let settings: unknown;
128
+ try { settings = JSON.parse(readFileSync(harnessSettingsPath(seams), "utf8")); }
129
+ catch (error) { if (missing(error)) return false; throw error; }
130
+ if (!plain(settings) || (Object.hasOwn(settings, "piBetterHarness") && !plain(settings.piBetterHarness))) {
131
+ throw new Error("Invalid diagnostics settings.");
132
+ }
133
+ const value = (settings.piBetterHarness as Record<string, unknown> | undefined)?.sandboxDiagnostics;
134
+ return fields(value, ["version", "enabled"]) && value.version === 1 && value.enabled === true;
135
+ }
136
+ export function isDiagnosticReport(value: unknown): value is DiagnosticReport {
137
+ if (!fields(value, REPORT_FIELDS) || Reflect.ownKeys(value).length !== REPORT_FIELDS.length ||
138
+ Object.values(Object.getOwnPropertyDescriptors(value)).some(d => !Object.hasOwn(d, "value"))) return false;
139
+ return value.schema === 1 && value.type === "sandbox_diagnostic_report" && TOOLS.has(value.tool as string) &&
140
+ typeof value.version === "string" && (VERSION.test(value.version) || value.version === "unknown") &&
141
+ PLATFORMS.has(value.platform as string) && BACKENDS.has(value.backend as string) &&
142
+ resource(value.resource) && ["denied", "succeeded"].includes(value.outcome as string) &&
143
+ [value.operationFingerprint, value.policyFingerprint, value.idFingerprint].every(f => typeof f === "string" && FP.test(f));
144
+ }
145
+ export function setDiagnosticsEnabled(enabled: boolean, seams: DiagnosticsSeams = {}): void {
146
+ if (typeof enabled !== "boolean") throw new TypeError("Diagnostics enabled must be boolean.");
147
+ updateHarnessSetting("sandboxDiagnostics", () => ({ version: 1, enabled }), seams);
148
+ }
149
+
150
+ function stat(path: string) {
151
+ try { return lstatSync(path); } catch (error) { if (missing(error)) return undefined; throw error; }
152
+ }
153
+ function safeFile(path: string): void {
154
+ const s = stat(path);
155
+ if (s && (!s.isFile() || s.isSymbolicLink() || s.nlink !== 1)) throw new UnsafeStorage();
156
+ }
157
+ function directory(seams: DiagnosticsSeams, create: boolean): string | undefined {
158
+ let root = (seams.agentDir ?? getAgentDir)();
159
+ if (create && !stat(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
160
+ const s = stat(root);
161
+ if (!s) return undefined;
162
+ if (!s.isDirectory() || s.isSymbolicLink()) throw new UnsafeStorage();
163
+ root = realpathSync(root);
164
+ for (const component of ["diagnostics", "sandbox"]) {
165
+ root = join(root, component);
166
+ if (create && !stat(root)) {
167
+ try { mkdirSync(root, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
168
+ }
169
+ const entry = stat(root);
170
+ if (!entry) return undefined;
171
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new UnsafeStorage();
172
+ if (create) chmodSync(root, 0o700);
173
+ }
174
+ return root;
175
+ }
176
+ function readSafe(path: string, bound: number): string {
177
+ safeFile(path);
178
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
179
+ try {
180
+ const s = fstatSync(fd);
181
+ if (!s.isFile() || s.nlink !== 1) throw new UnsafeStorage();
182
+ if (s.size > bound) throw new Error("Diagnostics file exceeds its bound.");
183
+ return readFileSync(fd, "utf8");
184
+ } finally { closeSync(fd); }
185
+ }
186
+ function atomic(path: string, contents: string): void {
187
+ safeFile(path);
188
+ const pending = `${path}.${randomBytes(16).toString("hex")}.tmp`;
189
+ try {
190
+ writeFileSync(pending, contents, { mode: 0o600, flag: "wx" });
191
+ safeFile(path);
192
+ renameSync(pending, path);
193
+ } finally { rmSync(pending, { force: true }); }
194
+ }
195
+ function locked<T>(dir: string, action: () => T): T {
196
+ const path = join(dir, "events.jsonl");
197
+ const lockPath = `${path}.lock`;
198
+ for (let attempt = 0; attempt < 500; attempt++) {
199
+ // A stopped synchronous writer cannot renew a lease. Never evict by age:
200
+ // an orphan blocks sampling until the human removes it with collectors stopped.
201
+ try { mkdirSync(lockPath, { mode: 0o700 }); }
202
+ catch (error) {
203
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
204
+ const s = stat(lockPath);
205
+ if (s && (!s.isDirectory() || s.isSymbolicLink())) throw new UnsafeStorage();
206
+ if (attempt === 499) throw Object.assign(new Error("Diagnostics storage is locked."), { code: "ELOCKED" });
207
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
208
+ continue;
209
+ }
210
+ try {
211
+ const lock = lstatSync(lockPath);
212
+ if (!lock.isDirectory() || lock.isSymbolicLink()) throw new UnsafeStorage();
213
+ for (const name of ["events.jsonl", "installation.key", "export.json"]) safeFile(join(dir, name));
214
+ return action();
215
+ } finally { rmdirSync(lockPath); }
216
+ }
217
+ throw new Error("Cannot lock diagnostics.");
218
+ }
219
+ function installationKey(dir: string, create: boolean): Buffer {
220
+ const path = join(dir, "installation.key");
221
+ safeFile(path);
222
+ if (!stat(path)) {
223
+ if (!create || stat(join(dir, "events.jsonl"))) throw new Error("Missing diagnostics installation key.");
224
+ atomic(path, `${randomBytes(32).toString("hex")}\n`);
225
+ }
226
+ const raw = readSafe(path, 65);
227
+ if (!/^[a-f0-9]{64}\n$/.test(raw)) throw new Error("Invalid diagnostics installation key.");
228
+ chmodSync(path, 0o600);
229
+ return Buffer.from(raw.trim(), "hex");
230
+ }
231
+ function selectRecord(v: Record<string, unknown>): DiagnosticRecord | undefined {
232
+ if (!fields(v, [...RECORD_FIELDS, "mac"]) || v.schema !== 1 || !integer(v.timestamp) ||
233
+ typeof v.version !== "string" || !(VERSION.test(v.version) || v.version === "unknown") ||
234
+ !PLATFORMS.has(v.platform as string) || !BACKENDS.has(v.backend as string) || !TOOLS.has(v.tool as string) ||
235
+ !resource(v.resource) || !BASES.has(v.basis as string) || !CONTEXTS.has(v.context as string) ||
236
+ !["denied", "succeeded"].includes(v.outcome as string) ||
237
+ ![v.operationFingerprint, v.policyFingerprint, v.fingerprint].every(f => typeof f === "string" && FP.test(f)) ||
238
+ (v.outcome === "denied" ? Object.hasOwn(v, "recoveryOf") : typeof v.recoveryOf !== "string" || !FP.test(v.recoveryOf))) return undefined;
239
+ const record: DiagnosticRecord = {
240
+ schema: 1, timestamp: v.timestamp, version: v.version, platform: v.platform as string,
241
+ backend: v.backend as string, tool: v.tool as string, resource: v.resource,
242
+ basis: v.basis as Basis, outcome: v.outcome as DiagnosticRecord["outcome"], context: v.context as DiagnosticContext,
243
+ operationFingerprint: v.operationFingerprint as string, policyFingerprint: v.policyFingerprint as string, fingerprint: v.fingerprint as string,
244
+ };
245
+ if (record.outcome === "succeeded") record.recoveryOf = v.recoveryOf as string;
246
+ return record;
247
+ }
248
+ function sameOperation(a: DiagnosticRecord, b: DiagnosticRecord): boolean {
249
+ return a.context === b.context && a.tool === b.tool && a.operationFingerprint === b.operationFingerprint;
250
+ }
251
+ interface Journal { records: DiagnosticRecord[]; losses: DiagnosticLosses }
252
+ function load(dir: string, key: Buffer): Journal {
253
+ const path = join(dir, "events.jsonl");
254
+ if (!stat(path)) return { records: [], losses: emptyLosses() };
255
+ chmodSync(path, 0o600);
256
+ const raw = readSafe(path, MAX_BYTES);
257
+ const lines = raw.split("\n");
258
+ if (lines.at(-1) === "") lines.pop();
259
+ const first = lines.shift();
260
+ const losses = emptyLosses();
261
+ let header: Record<string, unknown> | undefined;
262
+ try {
263
+ const v: unknown = JSON.parse(first ?? "");
264
+ const candidateLosses = plain(v) ? v.losses : undefined;
265
+ if (fields(v, ["schema", "kind", "losses", "count", "digest", "mac"]) && v.schema === 1 && v.kind === "journal" &&
266
+ fields(candidateLosses, LOSS_FIELDS) && LOSS_FIELDS.every(k => integer(candidateLosses[k])) && integer(v.count) &&
267
+ typeof v.digest === "string" && FP.test(v.digest)) {
268
+ const l = candidateLosses;
269
+ const selected = { schema: 1, kind: "journal", losses: { age: l.age, count: l.count, bytes: l.bytes, malformed: l.malformed, tampered: l.tampered }, count: v.count, digest: v.digest };
270
+ if (authentic(key, "header", JSON.stringify(selected), v.mac)) {
271
+ header = v;
272
+ for (const k of LOSS_FIELDS) losses[k] = l[k] as number;
273
+ } else losses.tampered++;
274
+ } else losses.malformed++;
275
+ } catch { losses.malformed++; }
276
+ if (header && (header.count !== lines.length || header.digest !== digest(key, "journal", lines.join("\n")))) losses.tampered++;
277
+ const records: DiagnosticRecord[] = [];
278
+ const seen = new Set<string>();
279
+ const denied = new Map<string, DiagnosticRecord>();
280
+ for (const line of lines) {
281
+ let v: unknown;
282
+ try { v = JSON.parse(line); } catch { losses.malformed++; continue; }
283
+ const record = plain(v) ? selectRecord(v) : undefined;
284
+ if (!record) { losses.malformed++; continue; }
285
+ if (!authentic(key, "event", JSON.stringify(record), (v as Record<string, unknown>).mac) || seen.has(record.fingerprint)) {
286
+ losses.tampered++; continue;
287
+ }
288
+ if (record.outcome === "succeeded") {
289
+ const prior = denied.get(record.recoveryOf!);
290
+ if (!prior || !sameOperation(prior, record) || prior.timestamp > record.timestamp) { losses.tampered++; continue; }
291
+ denied.delete(prior.fingerprint);
292
+ } else denied.set(record.fingerprint, record);
293
+ seen.add(record.fingerprint);
294
+ records.push(record);
295
+ }
296
+ return { records, losses };
297
+ }
298
+ function encode(journal: Journal, key: Buffer): string {
299
+ const lines = journal.records.map(record => JSON.stringify({ ...record, mac: digest(key, "event", JSON.stringify(record)) }));
300
+ const header = { schema: 1, kind: "journal", losses: journal.losses, count: lines.length, digest: digest(key, "journal", lines.join("\n")) };
301
+ return `${JSON.stringify({ ...header, mac: digest(key, "header", JSON.stringify(header)) })}\n${lines.length ? `${lines.join("\n")}\n` : ""}`;
302
+ }
303
+ function retain(journal: Journal, key: Buffer, now: number): boolean {
304
+ let changed = false;
305
+ function remove(index: number, reason: "age" | "count" | "bytes") {
306
+ const [removed] = journal.records.splice(index, 1);
307
+ if (!removed) return;
308
+ journal.losses[reason]++;
309
+ // Recovery cannot outlive its evidence; count both discarded records.
310
+ if (removed.outcome === "denied") {
311
+ const recovery = journal.records.findIndex(r => r.recoveryOf === removed.fingerprint);
312
+ if (recovery !== -1) { journal.records.splice(recovery, 1); journal.losses[reason]++; }
313
+ }
314
+ changed = true;
315
+ }
316
+ for (let i = 0; i < journal.records.length;) {
317
+ if (journal.records[i]!.timestamp < now - MAX_AGE) remove(i, "age"); else i++;
318
+ }
319
+ while (journal.records.length > MAX_ENTRIES) remove(0, "count");
320
+ // The full signed header and UTF-8 newlines count towards the byte limit.
321
+ while (Buffer.byteLength(encode(journal, key)) > MAX_BYTES && journal.records.length) remove(0, "bytes");
322
+ return changed;
323
+ }
324
+ function issues(losses: DiagnosticLosses): DiagnosticIssue[] {
325
+ const result: DiagnosticIssue[] = [];
326
+ if (losses.age || losses.count || losses.bytes) result.push("retention-loss");
327
+ if (losses.malformed) result.push("malformed-data");
328
+ if (losses.tampered) result.push("tampered-data");
329
+ return result;
330
+ }
331
+
332
+ export class SandboxDiagnostics {
333
+ constructor(private readonly options: {
334
+ context: DiagnosticContext; version: string; policy: () => unknown; backend: () => string | undefined;
335
+ agentDir?: () => string; onError?: (error: unknown) => void; now?: () => number;
336
+ relay?: (report: DiagnosticReport) => void;
337
+ }) {}
338
+ private relayKey?: Buffer;
339
+ private readonly pending = new Map<string, DiagnosticResource>();
340
+ private report(error: unknown): void { try { this.options.onError?.(error); } catch { /* Diagnostics never change tool outcomes. */ } }
341
+ observe(input: DiagnosticObservation): void {
342
+ this.collect(input);
343
+ }
344
+ /** Caller must gate this on parent-authored task runtime provenance, not a claim in the log. */
345
+ observeReport(value: unknown, runIdentity: string): void {
346
+ try {
347
+ if (!isDiagnosticReport(value) || typeof runIdentity !== "string" || !runIdentity || this.options.context !== "worker" || this.options.relay) return;
348
+ this.collect({ tool: value.tool, resource: value.resource, basis: "agent-reported", outcome: value.outcome,
349
+ operation: { run: runIdentity, fingerprint: value.operationFingerprint } }, { value, runIdentity });
350
+ } catch { this.report(new Error("Diagnostics collection has gaps.")); }
351
+ }
352
+ private emit(input: DiagnosticObservation, tool: string, operation: string): void {
353
+ const key = this.relayKey ??= randomBytes(32);
354
+ const operationFingerprint = digest(key, "operation", operation);
355
+ const prior = this.pending.get(operationFingerprint);
356
+ if (input.outcome === "succeeded" && !prior) return;
357
+ const backend = this.options.backend();
358
+ const report: DiagnosticReport = {
359
+ schema: 1, type: "sandbox_diagnostic_report", tool, resource: prior && input.outcome === "succeeded" ? prior : input.resource,
360
+ version: typeof this.options.version === "string" && VERSION.test(this.options.version) ? this.options.version : "unknown",
361
+ platform: PLATFORMS.has(process.platform) ? process.platform : "unknown",
362
+ backend: typeof backend === "string" && BACKENDS.has(backend) ? backend : "unknown",
363
+ outcome: input.outcome, operationFingerprint, policyFingerprint: digest(key, "policy", identity(this.options.policy())),
364
+ idFingerprint: digest(key, "report-identity", randomBytes(32).toString("hex")),
365
+ };
366
+ this.options.relay!(report);
367
+ if (input.outcome === "succeeded") this.pending.delete(operationFingerprint);
368
+ else {
369
+ this.pending.delete(operationFingerprint);
370
+ this.pending.set(operationFingerprint, input.resource);
371
+ while (this.pending.size > MAX_ENTRIES) this.pending.delete(this.pending.keys().next().value!);
372
+ }
373
+ }
374
+ private collect(input: DiagnosticObservation, imported?: { value: DiagnosticReport; runIdentity: string }): void {
375
+ try {
376
+ if (!(this.options.relay ? relayEnabled(this.options) : diagnosticsEnabled(this.options))) return;
377
+ if (!CONTEXTS.has(this.options.context) || !resource(input.resource) || !BASES.has(input.basis) ||
378
+ !["denied", "succeeded"].includes(input.outcome)) throw new Error("Invalid diagnostic observation.");
379
+ const now = (this.options.now ?? Date.now)();
380
+ if (!integer(now)) throw new Error("Invalid diagnostics clock.");
381
+ const tool = TOOLS.has(input.tool) ? input.tool : "extension-tool";
382
+ const operation = identity({ tool: input.tool, input: input.operation });
383
+ if (this.options.relay) { this.emit(input, tool, operation); return; }
384
+ // Unrelated successes must not initialize diagnostics storage or evaluate policy.
385
+ const dir = directory(this.options, input.outcome === "denied");
386
+ if (!dir) return;
387
+ if (input.outcome === "succeeded" && !stat(join(dir, "events.jsonl"))) return;
388
+ locked(dir, () => {
389
+ const key = installationKey(dir, input.outcome === "denied");
390
+ const journal = load(dir, key);
391
+ const before = JSON.stringify(journal.losses);
392
+ const pruned = retain(journal, key, now);
393
+ const fingerprint = imported ? digest(key, "relay-event-identity", identity([imported.runIdentity, imported.value.idFingerprint])) :
394
+ digest(key, "event-identity", randomBytes(32).toString("hex"));
395
+ // Signed record identities survive parent reloads and full log rescans.
396
+ if (imported && journal.records.some(r => r.fingerprint === fingerprint)) {
397
+ if (pruned || journal.losses.malformed || journal.losses.tampered) atomic(join(dir, "events.jsonl"), encode(journal, key));
398
+ if (issues(journal.losses).length) this.report(new Error("Diagnostics collection has gaps."));
399
+ return;
400
+ }
401
+ const operationFingerprint = digest(key, "operation", operation);
402
+ const recovered = new Set(journal.records.flatMap(r => r.recoveryOf ? [r.recoveryOf] : []));
403
+ const prior = [...journal.records].reverse().find(r => r.outcome === "denied" && r.context === this.options.context &&
404
+ r.tool === tool && r.operationFingerprint === operationFingerprint && r.timestamp <= now);
405
+ if (input.outcome === "succeeded" && (!prior || recovered.has(prior.fingerprint))) {
406
+ if (pruned || issues(journal.losses).some(i => i !== "retention-loss")) atomic(join(dir, "events.jsonl"), encode(journal, key));
407
+ if (issues(journal.losses).length) this.report(new Error("Diagnostics collection has gaps."));
408
+ return;
409
+ }
410
+ const backend = imported?.value.backend ?? this.options.backend();
411
+ const version = imported?.value.version ?? this.options.version;
412
+ const record: DiagnosticRecord = {
413
+ schema: 1, timestamp: now,
414
+ version: typeof version === "string" && VERSION.test(version) ? version : "unknown",
415
+ platform: imported?.value.platform ?? (PLATFORMS.has(process.platform) ? process.platform : "unknown"),
416
+ backend: typeof backend === "string" && BACKENDS.has(backend) ? backend : "unknown",
417
+ tool, resource: input.outcome === "succeeded" && prior ? prior.resource : input.resource,
418
+ basis: input.outcome === "succeeded" && prior ? prior.basis : input.basis,
419
+ outcome: input.outcome, context: this.options.context,
420
+ operationFingerprint, policyFingerprint: imported ? digest(key, "relay-policy", identity([imported.runIdentity, imported.value.policyFingerprint])) :
421
+ digest(key, "policy", identity(this.options.policy())),
422
+ fingerprint,
423
+ };
424
+ if (prior && input.outcome === "succeeded") record.recoveryOf = prior.fingerprint;
425
+ journal.records.push(record);
426
+ retain(journal, key, now);
427
+ atomic(join(dir, "events.jsonl"), encode(journal, key));
428
+ if (before !== JSON.stringify(journal.losses) || journal.losses.malformed || journal.losses.tampered) this.report(new Error("Diagnostics collection has gaps."));
429
+ });
430
+ } catch (error) { this.report(this.options.relay || imported ? new Error("Diagnostics collection has gaps.") : error); }
431
+ }
432
+ }
433
+
434
+ export function readDiagnostics(seams: DiagnosticsSeams = {}): DiagnosticsData {
435
+ const data: DiagnosticsData = { schema: 1, enabled: false, records: [], losses: emptyLosses(), issues: [] };
436
+ try { data.enabled = diagnosticsEnabled(seams); } catch { data.issues.push("unavailable"); }
437
+ try {
438
+ const dir = directory(seams, false);
439
+ if (!dir || !stat(join(dir, "events.jsonl"))) return data;
440
+ locked(dir, () => {
441
+ const key = installationKey(dir, false);
442
+ const journal = load(dir, key);
443
+ if (retain(journal, key, Date.now())) atomic(join(dir, "events.jsonl"), encode(journal, key));
444
+ data.records = journal.records;
445
+ data.losses = journal.losses;
446
+ data.issues.push(...issues(journal.losses));
447
+ });
448
+ } catch (error) { data.issues.push(error instanceof UnsafeStorage ? "unsafe-storage" : "unavailable"); }
449
+ return data;
450
+ }
451
+ export function analyzeDiagnostics(data: DiagnosticsData): DiagnosticsAnalysis {
452
+ const groups = new Map<string, DiagnosticGroup>();
453
+ const operations = new Map<string, { latestDenial: string; recovered: boolean }>();
454
+ for (const r of data.records) {
455
+ const key = JSON.stringify([r.version, r.backend, r.tool, r.resource, r.basis, r.policyFingerprint]);
456
+ let group = groups.get(key);
457
+ if (!group) {
458
+ group = { version: r.version, backend: r.backend, tool: r.tool, resource: r.resource, basis: r.basis, policyFingerprint: r.policyFingerprint, denied: 0, succeeded: 0 };
459
+ groups.set(key, group);
460
+ }
461
+ if (r.outcome === "denied") group.denied++; else group.succeeded++;
462
+ const opKey = JSON.stringify([r.context, r.tool, r.operationFingerprint]);
463
+ if (r.outcome === "denied") operations.set(opKey, { latestDenial: r.fingerprint, recovered: false });
464
+ else {
465
+ const operation = operations.get(opKey);
466
+ if (operation && operation.latestDenial === r.recoveryOf) operation.recovered = true;
467
+ }
468
+ }
469
+ const recovered = [...operations.values()].filter(op => op.recovered).length;
470
+ return { groups: [...groups.values()], observedOperations: operations.size, recovered, outstanding: operations.size - recovered };
471
+ }
472
+ export function formatDiagnosticsSummary(data: DiagnosticsData): string {
473
+ const analysis = analyzeDiagnostics(data);
474
+ const l = data.losses;
475
+ const lines = [
476
+ `Sandbox diagnostics: ${data.enabled ? "enabled" : "disabled"}; ${data.records.length} retained observations.`,
477
+ `Observed operations: ${analysis.observedOperations}; recovered: ${analysis.recovered}; outstanding: ${analysis.outstanding}.`,
478
+ `Journal gaps: ${data.issues.length ? data.issues.join(", ") : "none recorded"}. Losses: age=${l.age}, count=${l.count}, bytes=${l.bytes}, malformed=${l.malformed}, tampered=${l.tampered}.`,
479
+ "Observations only; not a security verdict. Collection errors may not persist; shell output is not classified.",
480
+ ];
481
+ for (const g of analysis.groups.slice(0, 20)) lines.push(`${g.version} ${g.backend} ${g.tool} ${g.resource} ${g.basis} policy=${g.policyFingerprint.slice(0, 12)}: denied=${g.denied}, succeeded=${g.succeeded}`);
482
+ if (analysis.groups.length > 20) lines.push(`${analysis.groups.length - 20} additional groups in the local JSON export.`);
483
+ return lines.join("\n");
484
+ }
485
+ export function exportDiagnostics(seams: DiagnosticsSeams = {}): string {
486
+ const data = readDiagnostics(seams);
487
+ const dir = directory(seams, true)!;
488
+ const path = join(dir, "export.json");
489
+ locked(dir, () => atomic(path, `${JSON.stringify({ schema: 1, enabled: data.enabled, records: data.records, losses: data.losses, issues: data.issues, coverage: { sampling: true, shellOutputClassified: false, collectionErrorsMayNotPersist: true }, analysis: analyzeDiagnostics(data) }, null, 2)}\n`));
490
+ return path;
491
+ }
@@ -6,6 +6,7 @@ import { constants, realpathSync } from "node:fs";
6
6
  import { access, lstat, mkdir, open, readFile, stat, unlink, writeFile } from "node:fs/promises";
7
7
  import { basename, dirname, join } from "node:path";
8
8
  import type { ReadOperations, WriteOperations, EditOperations } from "@earendil-works/pi-coding-agent";
9
+ import type { SandboxDiagnostics, DiagnosticResource } from "./shared-sandbox-diagnostics.ts";
9
10
  import {
10
11
  canonicalizePath, compileWritePolicy, evaluateDeleteAccess, evaluateReadAccess, evaluateWriteAccess, maybeBuildSandboxCommand,
11
12
  type CompiledSandboxWritePolicy, type SandboxSeams, type SandboxWritePolicy, type WriteAccessDecision,
@@ -161,10 +162,29 @@ function mimeType(data: Buffer): string | null {
161
162
  return null;
162
163
  }
163
164
 
164
- export function createTaskFileOperations(controller: TaskFileController): {
165
+ export function createTaskFileOperations(controller: TaskFileController, diagnostics?: Pick<SandboxDiagnostics, "observe">, toolOverride?: string): {
165
166
  read: ReadOperations; write: WriteOperations; edit: EditOperations; remove: TaskRemoveOperations; check: TaskAccessChecks;
166
167
  } {
167
- async function run(operation: Operation, path: string, content?: string): Promise<Buffer | void> {
168
+ function resource(path: string, policy: CompiledSandboxWritePolicy): DiagnosticResource {
169
+ path = canonicalizePath(path);
170
+ const inside = (root: string) => path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`);
171
+ if (policy.credentialPaths?.some(inside)) return "credential-files";
172
+ return inside(policy.writableRoot) ? "project-files" : "outside-project-files";
173
+ }
174
+ const observe = (tool: string, operation: Operation | "remove", path: string, policy: CompiledSandboxWritePolicy,
175
+ basis: "policy-refusal" | "os-permission-error", outcome: "denied" | "succeeded") =>
176
+ { try { diagnostics?.observe({ tool, operation: { operation, path }, resource: resource(path, policy), basis, outcome }); }
177
+ catch { /* Diagnostics must never change the operation's outcome. */ } };
178
+
179
+ async function run(operation: Operation, path: string, content?: string, tool = "read"): Promise<Buffer | void> {
180
+ tool = toolOverride ?? tool;
181
+ const result = await perform(operation, path, content, tool);
182
+ try { diagnostics?.observe({ tool, operation: { operation, path }, resource: "unknown",
183
+ basis: "os-permission-error", outcome: "succeeded" }); } catch { /* Observation is nonfatal. */ }
184
+ return result;
185
+ }
186
+
187
+ async function perform(operation: Operation, path: string, content?: string, tool = "read"): Promise<Buffer | void> {
168
188
  const plan = controller.requireLaunchPlan();
169
189
  if (!plan.confined) {
170
190
  switch (operation) {
@@ -193,7 +213,10 @@ export function createTaskFileOperations(controller: TaskFileController): {
193
213
  const policy = compileWritePolicy(plan.policy);
194
214
  if (operation === "unlink") {
195
215
  const decision = removeDecision(path, policy);
196
- if (!decision.allowed) throw refusal("remove", decision);
216
+ if (!decision.allowed) {
217
+ observe(tool, operation, path, policy, "policy-refusal", "denied");
218
+ throw refusal("remove", decision);
219
+ }
197
220
  }
198
221
  const writing = operation === "write" || operation === "mkdir" || operation === "access-edit" || operation === "unlink";
199
222
  const decision = writing ? evaluateWriteAccess(path, policy) : evaluateReadAccess(path, policy);
@@ -201,14 +224,18 @@ export function createTaskFileOperations(controller: TaskFileController): {
201
224
  if (operation === "mkdir" && decision.reason === "permission-denied") {
202
225
  // SDK write prepares the parent even when it already exists.
203
226
  // Confirm that no-op inside confinement; never grant mkdir.
204
- try { await run("existing-directory", path); return; } catch { /* Preserve the original refusal. */ }
227
+ try { await run("existing-directory", path, undefined, tool); return; } catch { /* Preserve the original refusal. */ }
205
228
  }
206
229
  const detail = "deniedBy" in decision ? ` (protected by ${decision.deniedBy})` : "";
230
+ observe(tool, operation, path, policy, "policy-refusal", "denied");
207
231
  throw new Error(`Task sandbox refused to ${writing ? "write" : "read"} ${decision.path}: ${decision.reason}${detail}.`);
208
232
  }
209
233
  if (operation === "access-edit") {
210
234
  const readable = evaluateReadAccess(path, policy);
211
- if (!readable.allowed) throw new Error(`Task sandbox refused to read ${readable.path}: ${readable.reason}.`);
235
+ if (!readable.allowed) {
236
+ observe(tool, operation, path, policy, "policy-refusal", "denied");
237
+ throw new Error(`Task sandbox refused to read ${readable.path}: ${readable.reason}.`);
238
+ }
212
239
  }
213
240
  if (content !== undefined && Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
214
241
  throw new Error("File exceeds 8 MiB operation limit");
@@ -256,6 +283,9 @@ export function createTaskFileOperations(controller: TaskFileController): {
256
283
  child.stderr.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-2048); });
257
284
  child.stdin.on("error", () => {}); // An early sandbox failure may close stdin.
258
285
  child.on("error", (error) => {
286
+ if (["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) {
287
+ observe(tool, operation, path, policy, "os-permission-error", "denied");
288
+ }
259
289
  if (!finished) { finished = true; clearTimeout(timer); void unlink(profilePath).catch(() => {}); reject(error); }
260
290
  });
261
291
  child.on("close", (code) => {
@@ -270,6 +300,7 @@ export function createTaskFileOperations(controller: TaskFileController): {
270
300
  if (!result.ok) {
271
301
  const error = new Error(result.error ?? `Task file sandbox failed (${code})`) as NodeJS.ErrnoException;
272
302
  error.code = result.code;
303
+ if (["EACCES", "EPERM"].includes(result.code ?? "")) observe(tool, operation, path, policy, "os-permission-error", "denied");
273
304
  return reject(error);
274
305
  }
275
306
  if (code !== 0) return reject(new Error(`Task file sandbox exited ${code}: ${stderr}`));
@@ -284,11 +315,14 @@ export function createTaskFileOperations(controller: TaskFileController): {
284
315
  if (!plan.confined) return;
285
316
  const policy = compileWritePolicy(plan.policy);
286
317
  const decision = verb === "write" ? evaluateWriteAccess(path, policy) : removeDecision(path, policy);
287
- if (!decision.allowed) throw refusal(verb, decision);
318
+ if (!decision.allowed) {
319
+ observe("apply_patch", verb === "remove" ? "unlink" : "write", path, policy, "policy-refusal", "denied");
320
+ throw refusal(verb, decision);
321
+ }
288
322
  };
289
323
 
290
324
  return {
291
- remove: { remove: (path) => run("unlink", path) as Promise<void> },
325
+ remove: { remove: (path) => run("unlink", path, undefined, "apply_patch") as Promise<void> },
292
326
  check: { write: check("write"), remove: check("remove") },
293
327
  read: {
294
328
  readFile: (path) => run("read", path) as Promise<Buffer>,
@@ -296,13 +330,13 @@ export function createTaskFileOperations(controller: TaskFileController): {
296
330
  detectImageMimeType: async (path) => mimeType(await run("mime", path) as Buffer),
297
331
  },
298
332
  write: {
299
- mkdir: (path) => run("mkdir", path) as Promise<void>,
300
- writeFile: (path, content) => run("write", path, content) as Promise<void>,
333
+ mkdir: (path) => run("mkdir", path, undefined, "write") as Promise<void>,
334
+ writeFile: (path, content) => run("write", path, content, "write") as Promise<void>,
301
335
  },
302
336
  edit: {
303
- readFile: (path) => run("read", path) as Promise<Buffer>,
304
- access: (path) => run("access-edit", path) as Promise<void>,
305
- writeFile: (path, content) => run("write", path, content) as Promise<void>,
337
+ readFile: (path) => run("read", path, undefined, "edit") as Promise<Buffer>,
338
+ access: (path) => run("access-edit", path, undefined, "edit") as Promise<void>,
339
+ writeFile: (path, content) => run("write", path, content, "edit") as Promise<void>,
306
340
  },
307
341
  };
308
342
  }
@@ -15,6 +15,7 @@ import { canonicalizePath, compileWritePolicy, isRemovableUnderWrite, maybeBuild
15
15
  import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
16
16
  import { APPLY_PATCH_TOOL, createApplyPatchToolDefinition } from "./shared-task-apply-patch.ts";
17
17
  import { createProcessListToolDefinition, PROCESS_LIST_TOOL } from "./shared-task-process-list.ts";
18
+ import type { SandboxDiagnostics, DiagnosticResource } from "./shared-sandbox-diagnostics.ts";
18
19
 
19
20
  export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash", PROCESS_LIST_TOOL] as const);
20
21
  /** Harness adapters that follow the file rules; each is a task builtin only when the profile enables it. */
@@ -103,14 +104,26 @@ export function createTaskScratch(): { path: string; anchor: string } {
103
104
  export function createTaskBashOperations(
104
105
  controller: TaskFileController,
105
106
  shellPath: () => string | undefined = () => undefined,
107
+ diagnostics?: Pick<SandboxDiagnostics, "observe">,
106
108
  ): BashOperations {
107
109
  return {
108
110
  async exec(command, cwd, options) {
111
+ const observed = (basis: "policy-refusal" | "os-permission-error", outcome: "denied" | "succeeded") => {
112
+ try { diagnostics?.observe({ tool: "bash", operation: { command, cwd }, resource: "command-execution", basis, outcome }); }
113
+ catch { /* Observation is nonfatal. */ }
114
+ };
109
115
  const configuredShell = shellPath();
110
116
  const local = createLocalBashOperations(configuredShell ? { shellPath: configuredShell } : {});
111
117
  const plan = controller.requireLaunchPlan();
112
- if (!plan.confined) return local.exec(command, cwd, options);
113
- if (plan.policy.permissions?.commands === false) throw new Error("Sandbox: Run commands & applications is Off.");
118
+ if (!plan.confined) {
119
+ const result = await local.exec(command, cwd, options);
120
+ if (result.exitCode === 0) observed("os-permission-error", "succeeded");
121
+ return result;
122
+ }
123
+ if (plan.policy.permissions?.commands === false) {
124
+ observed("policy-refusal", "denied");
125
+ throw new Error("Sandbox: Run commands & applications is Off.");
126
+ }
114
127
  // An explicit path avoids SDK fallback spawning PATH-resolved `which`.
115
128
  const shell = getShellConfig(configuredShell ?? "/bin/bash");
116
129
  const sdkUtils = join(PiCodingAgent.getPackageDir(), "dist", "utils");
@@ -158,9 +171,18 @@ export function createTaskBashOperations(
158
171
  cleanup();
159
172
  if (options.signal?.aborted) reject(new Error("aborted"));
160
173
  else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
161
- else resolve({ exitCode });
162
- }, (error: unknown) => { cleanup(); reject(error); });
174
+ else {
175
+ if (exitCode === 0) observed("os-permission-error", "succeeded");
176
+ resolve({ exitCode });
177
+ }
178
+ }, (error: unknown) => {
179
+ cleanup();
180
+ reject(error);
181
+ });
163
182
  });
183
+ } catch (error) {
184
+ if (["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException)?.code ?? "")) observed("os-permission-error", "denied");
185
+ throw error;
164
186
  } finally {
165
187
  await unlink(profilePath).catch((error: NodeJS.ErrnoException) => {
166
188
  if (error.code !== "ENOENT") throw error;
@@ -188,13 +210,14 @@ export function installTaskTools(pi: ExtensionAPI, options: {
188
210
  processAccess?: () => "off" | "read";
189
211
  /** Build the admitted bash definition from the confined operations (default: the SDK bash tool). */
190
212
  bashDefinition?: (cwd: string, operations: BashOperations) => ReturnType<typeof createBashToolDefinition>;
213
+ diagnostics?: Pick<SandboxDiagnostics, "observe">;
191
214
  }) {
192
215
  const { controller } = options;
193
216
  const sourceKey = (path: string) => path.startsWith("<") ? path : canonicalizePath(path);
194
217
  const trustedSources = new Set(options.trustedSources.map(sourceKey));
195
218
  const trustedSource = (path: string | undefined) => path !== undefined && trustedSources.has(sourceKey(path));
196
- const files = createTaskFileOperations(controller);
197
- const bash = createTaskBashOperations(controller, options.shellPath);
219
+ const files = createTaskFileOperations(controller, options.diagnostics);
220
+ const bash = createTaskBashOperations(controller, options.shellPath, options.diagnostics);
198
221
  const schemas = new Map<string, unknown>();
199
222
  let currentCwd: string | undefined;
200
223
  const register = (cwd: string) => {
@@ -211,37 +234,48 @@ export function installTaskTools(pi: ExtensionAPI, options: {
211
234
  pi.registerTool(own(options.bashDefinition ? options.bashDefinition(cwd, bash) : createBashToolDefinition(cwd, { operations: bash })));
212
235
  pi.registerTool(own(createProcessListToolDefinition(cwd, controller, options.processAccess ?? (() => "off"))) as any);
213
236
  if (options.applyPatch) {
237
+ const patchFiles = createTaskFileOperations(controller, options.diagnostics, "apply_patch");
214
238
  pi.registerTool(own(createApplyPatchToolDefinition(cwd, {
215
- readFile: files.read.readFile, writeFile: files.write.writeFile, mkdir: files.write.mkdir,
216
- remove: files.remove.remove, checkWrite: files.check.write, checkRemove: files.check.remove,
239
+ readFile: patchFiles.read.readFile, writeFile: patchFiles.write.writeFile, mkdir: patchFiles.write.mkdir,
240
+ remove: patchFiles.remove.remove, checkWrite: patchFiles.check.write, checkRemove: patchFiles.check.remove,
217
241
  }, PiCodingAgent.withFileMutationQueue)) as any);
218
242
  }
219
243
  };
220
244
  register(options.cwd);
221
245
  pi.on("user_bash", () => ({ operations: bash }));
222
- pi.on("tool_call", (event) => {
246
+ pi.on("tool_call", (event, ctx) => {
247
+ const blocked = (reason: string, resource: DiagnosticResource) => {
248
+ try { options.diagnostics?.observe({ tool: event.toolName, operation: { input: event.input, cwd: ctx?.cwd ?? currentCwd }, resource,
249
+ basis: "policy-refusal", outcome: "denied" }); } catch { /* Observation is nonfatal. */ }
250
+ return { block: true, reason };
251
+ };
223
252
  try {
224
253
  const plan = controller.requireLaunchPlan();
225
254
  if (!plan.confined) return;
226
255
  const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);
227
256
  if (schemas.has(event.toolName)) {
228
257
  if (!tool || tool.parameters !== schemas.get(event.toolName) || !trustedSource(tool.sourceInfo?.path)) {
229
- return { block: true, reason: `Sandbox: ${event.toolName} was replaced by an unverified implementation.` };
258
+ return blocked(`Sandbox: ${event.toolName} was replaced by an unverified implementation.`, "tool-admission");
230
259
  }
231
260
  if (event.toolName === "bash" && plan.policy.permissions?.commands === false) {
232
- return { block: true, reason: "Sandbox: Run commands & applications is Off." };
261
+ return blocked("Sandbox: Run commands & applications is Off.", "command-execution");
233
262
  }
234
263
  if (event.toolName === PROCESS_LIST_TOOL && options.processAccess?.() !== "read") {
235
- return { block: true, reason: "Sandbox: Process access is Off. Enable Read in /sandbox." };
264
+ return blocked("Sandbox: Process access is Off. Enable Read in /sandbox.", "process-inspection");
236
265
  }
237
266
  return;
238
267
  }
239
268
  if (options.admitExtensionTool?.(event.toolName, event.input, tool?.sourceInfo?.path)) return;
240
- return { block: true, reason: `Sandbox: ${event.toolName} has no verified task execution adapter. Use a guarded file tool or confined bash command.` };
269
+ return blocked(`Sandbox: ${event.toolName} has no verified task execution adapter. Use a guarded file tool or confined bash command.`, "tool-admission");
241
270
  } catch (error) {
242
- return { block: true, reason: error instanceof Error ? error.message : String(error) };
271
+ return blocked(error instanceof Error ? error.message : String(error), "sandbox-backend");
243
272
  }
244
273
  });
274
+ if (options.diagnostics) pi.on("tool_result", (event, ctx) => {
275
+ if (event.isError) return;
276
+ try { options.diagnostics?.observe({ tool: event.toolName, operation: { input: event.input, cwd: ctx?.cwd ?? currentCwd }, resource: "unknown",
277
+ basis: "policy-refusal", outcome: "succeeded" }); } catch { /* Observation is nonfatal. */ }
278
+ });
245
279
  return { register, bash, assertInstalled(names: readonly string[]) {
246
280
  const inventory = pi.getAllTools();
247
281
  for (const name of names) {
package/task-guard.ts CHANGED
@@ -6,6 +6,7 @@ import { parseTaskPolicy, type TaskPolicy } from "./task-policy.ts";
6
6
  import { SANDBOX_POLICY_CHANNEL, SANDBOX_POLICY_REQUEST_CHANNEL } from "./permission-policy.ts";
7
7
  import { failureDispositionTool, intentBashDefinition } from "./child-incidents.ts";
8
8
  import { DISPOSITION_TOOL } from "./incident-model.ts";
9
+ import { SandboxDiagnostics, diagnosticPackageVersion } from "./shared-sandbox-diagnostics.ts";
9
10
 
10
11
  const TRUSTED_INLINE_SOURCE = "<inline:task-sandbox>";
11
12
 
@@ -42,7 +43,19 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
42
43
  const controller = Object.freeze({ requireLaunchPlan: () => plan });
43
44
  let shellPath: string | undefined;
44
45
  let currentCwd = process.cwd();
46
+ let diagnosticsWarned = false;
47
+ const diagnostics = new SandboxDiagnostics({ context: "worker", agentDir: () => policy.agentDir,
48
+ relay: (report) => { process.stdout.write(`${JSON.stringify(report)}\n`); },
49
+ version: diagnosticPackageVersion(new URL("./package.json", import.meta.url)),
50
+ policy: () => ({ permissions: policy.permissions, denyWrite: policy.denyWrite, root: policy.root }),
51
+ backend: () => support.backend,
52
+ onError: () => {
53
+ if (!diagnosticsWarned) process.stderr.write("Sandbox diagnostics collection gap; enforcement unchanged.\n");
54
+ diagnosticsWarned = true;
55
+ },
56
+ });
45
57
  const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
58
+ diagnostics,
46
59
  trustedSources: [TRUSTED_INLINE_SOURCE],
47
60
  // Structured command intent (#315): validated before the confined command runs.
48
61
  bashDefinition: (cwd, operations) => intentBashDefinition(cwd, operations) as any,