pi-better-background-tasks 0.7.1 → 0.9.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.1",
3
+ "version": "0.9.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -5,8 +5,10 @@ import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, e
5
5
  import { resumeScheduledWork, suspendScheduledWork } from "./runtime.js";
6
6
  import { observeForegroundSandboxPolicy } from "./sandbox.js";
7
7
  import { registerTools } from "./tools.js";
8
+ import { appendExecutionPolicy } from "./shared-execution-policy.js";
8
9
 
9
10
  export default function backgroundTasksExtension(pi: ExtensionAPI): void {
11
+ pi.on("before_agent_start", (event) => ({ systemPrompt: appendExecutionPolicy(event.systemPrompt) }));
10
12
  registerBackgroundTasksGoalProvider(pi);
11
13
  ensureBackgroundTasksNavigatorProvider(pi);
12
14
  // Subscribed at load so a sandbox extension that publishes later is heard, and
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 };
@@ -0,0 +1,19 @@
1
+ // Generated from packages/pi-better-harness/extensions/execution-policy.ts. Do not edit directly.
2
+ /** Harness-wide scheduling guidance, vendored so standalone extensions share it. */
3
+ const EXECUTION_POLICY = [
4
+ "Harness execution policy: concurrency is separate from subagent delegation.",
5
+ "Before multi-unit work, identify ready, authorized, independent units, actual prerequisites, shared mutable resources, and capacity or availability constraints. Represent independently verifiable deliverables (such as each host migration) separately in the plan; do not hide them in one serial rollout step or create dependencies merely from list order.",
6
+ "Start all ready, authorized, non-conflicting units concurrently within known resource limits before waiting for any one to finish. Reassess ready work after launches and results; continue unblocked foreground work instead of polling running tasks. Do not equate one background launch with scheduling all ready work.",
7
+ "Serialize only for a concrete dependency, conflicting writes or ownership, resource or concurrency limit, documented rollout gate, or explicit availability requirement. Record the reason in the plan or explain it to the user. Do not invent a canary or host-by-host availability requirement.",
8
+ "If concurrency raises an unresolved material operational trade-off (for example, draining all hosts could leave no available runners), ask the user once before the affected operations. Continue independent preparation meanwhile. Existing authorization, safety checks, workflow prerequisites, and human-approved rollout constraints remain binding; urgency does not waive them.",
9
+ "Choose the execution mechanism after deciding what can run together: foreground tools for small, tightly coupled, or interactive work; subagents for bounded independent reasoning or implementation when the active delegation mode permits; background tasks for independent long-running commands or waits. Manual delegation forbids proactive subagents, not concurrent foreground tools or background commands. Use available batch or parallel tool interfaces where appropriate and inspect every launch receipt for failures or skipped work.",
10
+ "A unit waiting or failing does not block other ready independent units unless the evidence shows a shared blocker. Preserve each unit's guards and rollback boundary. Before completing verification, the plan, or the goal, inspect and integrate every relevant terminal result or failure; launch is not completion.",
11
+ ].join("\n");
12
+
13
+ export function appendExecutionPolicy(systemPrompt: string): string {
14
+ // Inspect the prompt, not module-global state: vendored copies have separate
15
+ // module identities, and every fresh turn or reload needs the policy again.
16
+ return systemPrompt.includes(EXECUTION_POLICY)
17
+ ? systemPrompt
18
+ : `${systemPrompt}\n\n${EXECUTION_POLICY}`;
19
+ }
@@ -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
+ }
package/src/tools.ts CHANGED
@@ -184,6 +184,7 @@ const StatusActionParams = Type.Object({
184
184
  });
185
185
 
186
186
  const BACKGROUND_ORCHESTRATION_GUIDELINES = [
187
+ "Follow the Harness execution policy when scheduling independent background operations; subagent delegation mode does not require background commands to run serially.",
187
188
  "Use background tasks for genuinely long-running processes or repeated checks. Run short commands in the foreground.",
188
189
  "When a structured plan is active, keep it as the coordinator ledger: launch relevant background work early, continue unblocked foreground work without polling, and update the plan after inspecting each terminal result or failure.",
189
190
  "Do not treat launch as completion of the parent milestone; relevant background work must be terminal, inspected, and integrated before verification or completion.",