pi-better-background-tasks 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,5 +58,8 @@
58
58
  "typebox": "^1.1.38",
59
59
  "typescript": "^5.7.0",
60
60
  "vitest": "^3.0.0"
61
+ },
62
+ "dependencies": {
63
+ "proper-lockfile": "^4.1.2"
61
64
  }
62
65
  }
package/src/runtime.ts CHANGED
@@ -204,7 +204,7 @@ export function spawnTask(
204
204
  // Resolved before any task directory, log, or metadata exists so a blocked
205
205
  // launch leaves nothing behind. Structured SSH must also honor launch restrictions.
206
206
  const intent = readTaskIntent(params);
207
- const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
207
+ const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh, params);
208
208
  const id = nextTaskId();
209
209
  const cwd = params.cwd ?? defaultCwd;
210
210
  const logPath = logPathFor(id);
@@ -495,7 +495,7 @@ export function startWatchTask(
495
495
  }
496
496
  const blindChecks = readBlindChecks(params.blind_checks);
497
497
  const intent = readTaskIntent(params);
498
- const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
498
+ const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh, params, "bg_task_watch");
499
499
  const id = nextTaskId();
500
500
  const cwd = params.cwd ?? defaultCwd;
501
501
  const now = Date.now();
@@ -514,7 +514,7 @@ export function startWatchTask(
514
514
  const sandboxNotices: string[] = [];
515
515
  const launchSpec = remoteTask
516
516
  ? commandSpec
517
- : confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id), {}, (line) => sandboxNotices.push(line));
517
+ : confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id), {}, (line) => sandboxNotices.push(line), "bg_task_watch");
518
518
  const meta: BackgroundTaskMeta = {
519
519
  id,
520
520
  name: params.name,
package/src/sandbox.ts CHANGED
@@ -29,7 +29,7 @@ import { dirname } from "node:path";
29
29
 
30
30
  import { commandExecution } from "./process.js";
31
31
  import { baseDir } from "./registry.js";
32
- import { compileWritePolicy, maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
32
+ import { compileWritePolicy, describeSandboxSupport, maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
33
33
 
34
34
  type SandboxPermissions = {
35
35
  /** `write` = read, create and overwrite in place; `read-write` = Write & delete. */
@@ -40,6 +40,18 @@ type SandboxPermissions = {
40
40
  network: boolean;
41
41
  };
42
42
  import type { CommandSpec } from "./types.js";
43
+ import { SandboxDiagnostics, diagnosticPackageVersion, type DiagnosticResource } from "./shared-sandbox-diagnostics.js";
44
+
45
+ function backgroundDiagnostics(policy: unknown): SandboxDiagnostics | undefined {
46
+ try {
47
+ return new SandboxDiagnostics({ context: "background",
48
+ version: diagnosticPackageVersion(new URL("../package.json", import.meta.url)),
49
+ policy: () => policy,
50
+ backend: () => { const support = describeSandboxSupport(); return support.supported ? support.backend : undefined; },
51
+ onError: () => { console.error("Sandbox diagnostics collection gap; enforcement unchanged."); },
52
+ });
53
+ } catch { return undefined; }
54
+ }
43
55
 
44
56
  /**
45
57
  * What an operator can do about a missing backend here.
@@ -225,16 +237,25 @@ export function currentForegroundSandboxPolicy(pi: unknown): ForegroundSandboxPo
225
237
  * Throws for every state that is neither confinable nor intentionally
226
238
  * unconfined, which keeps a blocked launch from leaving task state behind.
227
239
  */
228
- export function resolveForegroundSandboxPlan(pi: unknown, remote = false): ForegroundSandboxPlan {
240
+ export function resolveForegroundSandboxPlan(pi: unknown, remote = false, operation?: unknown, tool = "bg_task_spawn"): ForegroundSandboxPlan {
229
241
  const policy = currentForegroundSandboxPolicy(pi);
230
242
  if (remote && !policy?.permissions) return UNCONFINED; // Preserve legacy SSH behavior.
231
- const plan = planFor(policy);
232
- if (remote && plan.confined) {
233
- throw new ForegroundSandboxBlockedError(policy!, !plan.permissions?.network
234
- ? "Main profile disables network. SSH launches are blocked."
235
- : "Structured SSH cannot apply local file and credential permissions yet. Use SSH through confined bash, or change Main permissions in /sandbox.");
243
+ try {
244
+ const plan = planFor(policy);
245
+ if (remote && plan.confined) {
246
+ throw new ForegroundSandboxBlockedError(policy!, !plan.permissions?.network
247
+ ? "Main profile disables network. SSH launches are blocked."
248
+ : "Structured SSH cannot apply local file and credential permissions yet. Use SSH through confined bash, or change Main permissions in /sandbox.");
249
+ }
250
+ return plan;
251
+ } catch (error) {
252
+ if (error instanceof ForegroundSandboxBlockedError) {
253
+ const resource: DiagnosticResource = policy?.permissions?.enabled && !policy.permissions.commands ? "command-execution"
254
+ : remote && policy?.permissions?.network === false ? "network-access" : "sandbox-backend";
255
+ backgroundDiagnostics(policy)?.observe({ tool, operation, resource, basis: "policy-refusal", outcome: "denied" });
256
+ }
257
+ throw error;
236
258
  }
237
- return plan;
238
259
  }
239
260
 
240
261
  /** The plan for one already-read policy. Exposed for tests and reuse. */
@@ -281,6 +302,7 @@ export function confineCommandSpec(
281
302
  seams: SandboxSeams = {},
282
303
  /** Receives lines to show with the launch (e.g. a placeholder left in the user's files). */
283
304
  onNotice: (line: string) => void = () => {},
305
+ tool = "bg_task_spawn",
284
306
  ): CommandSpec {
285
307
  if (!plan.confined) return spec;
286
308
 
@@ -330,9 +352,13 @@ export function confineCommandSpec(
330
352
  seams,
331
353
  );
332
354
  } catch (error) {
355
+ backgroundDiagnostics(plan)?.observe({ tool, operation: spec, resource: "sandbox-backend", basis: "policy-refusal", outcome: "denied" });
333
356
  throw blocked(plan, error instanceof Error ? error.message : String(error));
334
357
  }
335
- if (!command) throw blocked(plan, "no sandbox backend was applied");
358
+ if (!command) {
359
+ backgroundDiagnostics(plan)?.observe({ tool, operation: spec, resource: "sandbox-backend", basis: "policy-refusal", outcome: "denied" });
360
+ throw blocked(plan, "no sandbox backend was applied");
361
+ }
336
362
  for (const line of command.notices ?? []) onNotice(line);
337
363
 
338
364
  return { ...spec, argv: [command.file, ...command.fileArgs], shell: false };
@@ -1,8 +1,12 @@
1
1
  // Generated from packages/callback-batcher/index.ts. Do not edit directly.
2
- import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { randomUUID } from "node:crypto";
4
2
  import { join } from "node:path";
5
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ harnessSettingsPath,
6
+ readHarnessSetting,
7
+ updateHarnessSetting,
8
+ type HarnessSettingsSeams,
9
+ } from "./shared-harness-settings.ts";
6
10
 
7
11
  export type CallbackSource = "subagent" | "background-task";
8
12
  export type CallbackDetailTool = "subagent_result" | "bg_task_status";
@@ -13,9 +17,7 @@ export interface CallbackSettingsContext {
13
17
  ui?: { notify(message: string, type: "error"): void };
14
18
  }
15
19
 
16
- export interface CallbackSettingsSeams {
17
- agentDir?: () => string;
18
- }
20
+ export type CallbackSettingsSeams = HarnessSettingsSeams;
19
21
 
20
22
  export interface CallbackSettings {
21
23
  mode: CallbackDeliveryMode;
@@ -36,7 +38,12 @@ function validCallbackPreferences(value: unknown): value is { version: 1; mode:
36
38
  && data.version === 1 && isDeliveryMode(data.mode);
37
39
  }
38
40
 
39
- function callbackPreferencesPath(seams: CallbackSettingsSeams): string {
41
+ function parseCallbackPreferences(value: unknown): { version: 1; mode: CallbackDeliveryMode } {
42
+ if (!validCallbackPreferences(value)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
43
+ return value;
44
+ }
45
+
46
+ function legacyCallbackPreferencesPath(seams: CallbackSettingsSeams): string {
40
47
  return join((seams.agentDir ?? getAgentDir)(), "extensions", CALLBACK_PREFERENCES_FILE);
41
48
  }
42
49
 
@@ -64,41 +71,27 @@ function getSessionDefault(ctx: CallbackSettingsContext, seams: CallbackSettings
64
71
  if (ctx.sessionManager) defaults.set(ctx.sessionManager, { sessionId, mode });
65
72
  return { mode, source: "default" };
66
73
  };
67
- const path = callbackPreferencesPath(seams);
68
- let text: string;
74
+ const path = harnessSettingsPath(seams);
69
75
  try {
70
- text = readFileSync(path, "utf8");
71
- } catch (error) {
72
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return snapshot("hold");
73
- throw new Error(`Cannot read callback default at ${path}: ${String(error)}`, { cause: error });
74
- }
75
- try {
76
- const data: unknown = JSON.parse(text);
77
- if (!validCallbackPreferences(data)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
78
- return snapshot(data.mode);
76
+ const data = readHarnessSetting<{ version: 1; mode: CallbackDeliveryMode }>("callbacks", seams, {
77
+ path: legacyCallbackPreferencesPath(seams),
78
+ parse: parseCallbackPreferences,
79
+ });
80
+ return snapshot(data === undefined ? "hold" : parseCallbackPreferences(data).mode);
79
81
  } catch (error) {
80
- throw new Error(`Invalid callback default at ${path}: ${String(error)}`, { cause: error });
82
+ const prefix = (error as NodeJS.ErrnoException).code ? "Cannot read" : "Invalid";
83
+ throw new Error(`${prefix} callback default at ${path}: ${String(error)}`, { cause: error });
81
84
  }
82
85
  }
83
86
 
84
87
  /** Saves only the future-session default; never changes the active branch or batcher. */
85
88
  export function saveCallbackDefault(mode: CallbackDeliveryMode, seams: CallbackSettingsSeams = {}): void {
86
89
  if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing saved.");
87
- const dir = join((seams.agentDir ?? getAgentDir)(), "extensions");
88
- const path = join(dir, CALLBACK_PREFERENCES_FILE);
89
- const tmp = join(dir, `.${CALLBACK_PREFERENCES_FILE}.${randomUUID()}.tmp`);
90
- let created = false;
90
+ const path = harnessSettingsPath(seams);
91
91
  try {
92
- mkdirSync(dir, { recursive: true });
93
- const fd = openSync(tmp, "wx", 0o600);
94
- created = true;
95
- try { writeFileSync(fd, `${JSON.stringify({ version: 1, mode })}\n`); }
96
- finally { closeSync(fd); }
97
- renameSync(tmp, path);
92
+ updateHarnessSetting("callbacks", () => ({ version: 1, mode }), seams);
98
93
  } catch (error) {
99
94
  throw new Error(`Cannot save callback default at ${path}: ${String(error)}`, { cause: error });
100
- } finally {
101
- if (created) rmSync(tmp, { force: true });
102
95
  }
103
96
  }
104
97
 
@@ -0,0 +1,114 @@
1
+ // Generated from packages/harness-settings/index.ts. Do not edit directly.
2
+ import { existsSync, lstatSync, realpathSync, readFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createRequire } from "node:module";
5
+ import { join } from "node:path";
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
+
8
+ // Match Pi's settings lock without depending on a private SDK import.
9
+ const lockfile = createRequire(import.meta.url)("proper-lockfile") as {
10
+ lockSync(path: string, options: { realpath: boolean }): () => void;
11
+ };
12
+ const NAMESPACE = "piBetterHarness";
13
+ export interface HarnessSettingsSeams { agentDir?: () => string }
14
+
15
+ export function harnessSettingsPath(seams: HarnessSettingsSeams = {}): string {
16
+ return join((seams.agentDir ?? getAgentDir)(), "settings.json");
17
+ }
18
+
19
+ function object(value: unknown, label: string): Record<string, unknown> {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object.`);
21
+ return value as Record<string, unknown>;
22
+ }
23
+
24
+ function readSettings(path: string): Record<string, unknown> {
25
+ try { return object(JSON.parse(readFileSync(path, "utf8")), `Settings at ${path}`); }
26
+ catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ function section(settings: Record<string, unknown>): Record<string, unknown> {
33
+ return Object.hasOwn(settings, NAMESPACE) ? object(settings[NAMESPACE], NAMESPACE) : {};
34
+ }
35
+
36
+ function acquire(path: string): () => void {
37
+ for (let attempt = 0; attempt < 10; attempt++) {
38
+ try { return lockfile.lockSync(path, { realpath: false }); }
39
+ catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code !== "ELOCKED" || attempt === 9) throw error;
41
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
42
+ }
43
+ }
44
+ throw new Error(`Cannot lock settings at ${path}`);
45
+ }
46
+
47
+ function lockedRead(path: string): Record<string, unknown> {
48
+ if (!existsSync(path)) return {};
49
+ const release = acquire(path);
50
+ try { return readSettings(path); } finally { release(); }
51
+ }
52
+
53
+ function writeDestination(path: string): string {
54
+ let stat;
55
+ try { stat = lstatSync(path); }
56
+ catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return path;
58
+ throw error;
59
+ }
60
+ // Keep managed dotfiles links intact. A dangling link fails rather than being replaced.
61
+ return stat.isSymbolicLink() ? realpathSync(path) : path;
62
+ }
63
+
64
+ function mutate<T>(seams: HarnessSettingsSeams, change: (values: Record<string, unknown>) => T): T {
65
+ const path = harnessSettingsPath(seams);
66
+ mkdirSync((seams.agentDir ?? getAgentDir)(), { recursive: true });
67
+ const release = acquire(path);
68
+ let pending: string | undefined;
69
+ try {
70
+ // Read inside the lock so another session's settings are never replaced by a stale snapshot.
71
+ const settings = readSettings(path);
72
+ const values = section(settings);
73
+ const result = change(values);
74
+ settings[NAMESPACE] = values;
75
+ const destination = writeDestination(path);
76
+ pending = `${destination}.${randomUUID()}.tmp`;
77
+ writeFileSync(pending, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: "wx" });
78
+ renameSync(pending, destination);
79
+ return result;
80
+ } finally {
81
+ try { if (pending) rmSync(pending, { force: true }); } finally { release(); }
82
+ }
83
+ }
84
+
85
+ export function readHarnessSetting<T = unknown>(
86
+ key: string,
87
+ seams: HarnessSettingsSeams = {},
88
+ legacy?: { path: string; parse: (value: unknown) => T },
89
+ ): T | undefined {
90
+ const values = section(lockedRead(harnessSettingsPath(seams)));
91
+ if (Object.hasOwn(values, key)) return values[key] as T;
92
+ if (!legacy) return undefined;
93
+ let raw: string;
94
+ try { raw = readFileSync(legacy.path, "utf8"); }
95
+ catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
97
+ throw error;
98
+ }
99
+ const migrated = legacy.parse(JSON.parse(raw));
100
+ return mutate(seams, current => {
101
+ if (!Object.hasOwn(current, key)) current[key] = migrated;
102
+ return current[key] as T;
103
+ });
104
+ }
105
+
106
+ export function updateHarnessSetting<T>(
107
+ key: string, update: (current: unknown) => T, seams: HarnessSettingsSeams = {},
108
+ ): T {
109
+ return mutate(seams, values => {
110
+ const next = update(values[key]);
111
+ values[key] = next;
112
+ return next;
113
+ });
114
+ }
@@ -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
+ }