pi-better-background-tasks 0.7.1 → 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 +1 -1
- package/src/runtime.ts +3 -3
- package/src/sandbox.ts +35 -9
- package/src/shared-sandbox-diagnostics.ts +491 -0
package/package.json
CHANGED
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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)
|
|
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,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
|
+
}
|