pi-better-subagents 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/failures.ts +16 -1
- package/package.json +1 -1
- package/shared-sandbox-diagnostics.ts +491 -0
- package/shared-task-files.ts +46 -12
- package/shared-task-sandbox.ts +48 -14
- package/task-guard.ts +13 -0
package/failures.ts
CHANGED
|
@@ -4,6 +4,17 @@ import { logPathFor, runDir, taskRuntimeTrust } from "./registry.ts";
|
|
|
4
4
|
import { activeFailures, actionableFailures, disposeIncidents, failureIdentity, findIncident, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
|
|
5
5
|
observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
|
|
6
6
|
import { evidenceText, foldToolEnd, foldToolStart, newIncidentModel, toolOperation, type IncidentModel, type IncidentSink } from "./incident-model.ts";
|
|
7
|
+
import { SandboxDiagnostics, isDiagnosticReport } from "./shared-sandbox-diagnostics.ts";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
let diagnosticsWarned = false;
|
|
11
|
+
const workerDiagnostics = new SandboxDiagnostics({ context: "worker",
|
|
12
|
+
version: "unknown", policy: () => undefined, backend: () => undefined,
|
|
13
|
+
onError: () => {
|
|
14
|
+
if (!diagnosticsWarned) process.stderr.write("Sandbox diagnostics collection gap; enforcement unchanged.\n");
|
|
15
|
+
diagnosticsWarned = true;
|
|
16
|
+
},
|
|
17
|
+
});
|
|
7
18
|
|
|
8
19
|
export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered, toolOperation };
|
|
9
20
|
export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
|
|
@@ -230,7 +241,11 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
230
241
|
if (line.startsWith("{")) {
|
|
231
242
|
try {
|
|
232
243
|
const row = JSON.parse(line);
|
|
233
|
-
|
|
244
|
+
// Separate sampling from the incident model. Even a trusted worker log
|
|
245
|
+
// only supplies agent-reported evidence, never a policy decision.
|
|
246
|
+
if (row?.type === "sandbox_diagnostic_report") {
|
|
247
|
+
if (taskRuntimeTrust(id) === "trusted" && isDiagnosticReport(row)) workerDiagnostics.observeReport(row, id);
|
|
248
|
+
} else if (["tool_execution_start", "tool_execution_end", "message_end", "auto_retry_start", "auto_retry_end"].includes(row?.type)) {
|
|
234
249
|
if ((row.type.startsWith("tool_execution_") &&
|
|
235
250
|
(typeof row.toolCallId !== "string" || !row.toolCallId || typeof row.toolName !== "string")) ||
|
|
236
251
|
(row.type === "tool_execution_end" && typeof row.isError !== "boolean" && typeof row.result?.isError !== "boolean" && typeof row.result?.exitCode !== "number") ||
|
package/package.json
CHANGED
|
@@ -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/shared-task-files.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { constants, realpathSync } from "node:fs";
|
|
|
6
6
|
import { access, lstat, mkdir, open, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
7
|
import { basename, dirname, join } from "node:path";
|
|
8
8
|
import type { ReadOperations, WriteOperations, EditOperations } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { SandboxDiagnostics, DiagnosticResource } from "./shared-sandbox-diagnostics.ts";
|
|
9
10
|
import {
|
|
10
11
|
canonicalizePath, compileWritePolicy, evaluateDeleteAccess, evaluateReadAccess, evaluateWriteAccess, maybeBuildSandboxCommand,
|
|
11
12
|
type CompiledSandboxWritePolicy, type SandboxSeams, type SandboxWritePolicy, type WriteAccessDecision,
|
|
@@ -161,10 +162,29 @@ function mimeType(data: Buffer): string | null {
|
|
|
161
162
|
return null;
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
export function createTaskFileOperations(controller: TaskFileController): {
|
|
165
|
+
export function createTaskFileOperations(controller: TaskFileController, diagnostics?: Pick<SandboxDiagnostics, "observe">, toolOverride?: string): {
|
|
165
166
|
read: ReadOperations; write: WriteOperations; edit: EditOperations; remove: TaskRemoveOperations; check: TaskAccessChecks;
|
|
166
167
|
} {
|
|
167
|
-
|
|
168
|
+
function resource(path: string, policy: CompiledSandboxWritePolicy): DiagnosticResource {
|
|
169
|
+
path = canonicalizePath(path);
|
|
170
|
+
const inside = (root: string) => path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
171
|
+
if (policy.credentialPaths?.some(inside)) return "credential-files";
|
|
172
|
+
return inside(policy.writableRoot) ? "project-files" : "outside-project-files";
|
|
173
|
+
}
|
|
174
|
+
const observe = (tool: string, operation: Operation | "remove", path: string, policy: CompiledSandboxWritePolicy,
|
|
175
|
+
basis: "policy-refusal" | "os-permission-error", outcome: "denied" | "succeeded") =>
|
|
176
|
+
{ try { diagnostics?.observe({ tool, operation: { operation, path }, resource: resource(path, policy), basis, outcome }); }
|
|
177
|
+
catch { /* Diagnostics must never change the operation's outcome. */ } };
|
|
178
|
+
|
|
179
|
+
async function run(operation: Operation, path: string, content?: string, tool = "read"): Promise<Buffer | void> {
|
|
180
|
+
tool = toolOverride ?? tool;
|
|
181
|
+
const result = await perform(operation, path, content, tool);
|
|
182
|
+
try { diagnostics?.observe({ tool, operation: { operation, path }, resource: "unknown",
|
|
183
|
+
basis: "os-permission-error", outcome: "succeeded" }); } catch { /* Observation is nonfatal. */ }
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function perform(operation: Operation, path: string, content?: string, tool = "read"): Promise<Buffer | void> {
|
|
168
188
|
const plan = controller.requireLaunchPlan();
|
|
169
189
|
if (!plan.confined) {
|
|
170
190
|
switch (operation) {
|
|
@@ -193,7 +213,10 @@ export function createTaskFileOperations(controller: TaskFileController): {
|
|
|
193
213
|
const policy = compileWritePolicy(plan.policy);
|
|
194
214
|
if (operation === "unlink") {
|
|
195
215
|
const decision = removeDecision(path, policy);
|
|
196
|
-
if (!decision.allowed)
|
|
216
|
+
if (!decision.allowed) {
|
|
217
|
+
observe(tool, operation, path, policy, "policy-refusal", "denied");
|
|
218
|
+
throw refusal("remove", decision);
|
|
219
|
+
}
|
|
197
220
|
}
|
|
198
221
|
const writing = operation === "write" || operation === "mkdir" || operation === "access-edit" || operation === "unlink";
|
|
199
222
|
const decision = writing ? evaluateWriteAccess(path, policy) : evaluateReadAccess(path, policy);
|
|
@@ -201,14 +224,18 @@ export function createTaskFileOperations(controller: TaskFileController): {
|
|
|
201
224
|
if (operation === "mkdir" && decision.reason === "permission-denied") {
|
|
202
225
|
// SDK write prepares the parent even when it already exists.
|
|
203
226
|
// Confirm that no-op inside confinement; never grant mkdir.
|
|
204
|
-
try { await run("existing-directory", path); return; } catch { /* Preserve the original refusal. */ }
|
|
227
|
+
try { await run("existing-directory", path, undefined, tool); return; } catch { /* Preserve the original refusal. */ }
|
|
205
228
|
}
|
|
206
229
|
const detail = "deniedBy" in decision ? ` (protected by ${decision.deniedBy})` : "";
|
|
230
|
+
observe(tool, operation, path, policy, "policy-refusal", "denied");
|
|
207
231
|
throw new Error(`Task sandbox refused to ${writing ? "write" : "read"} ${decision.path}: ${decision.reason}${detail}.`);
|
|
208
232
|
}
|
|
209
233
|
if (operation === "access-edit") {
|
|
210
234
|
const readable = evaluateReadAccess(path, policy);
|
|
211
|
-
if (!readable.allowed)
|
|
235
|
+
if (!readable.allowed) {
|
|
236
|
+
observe(tool, operation, path, policy, "policy-refusal", "denied");
|
|
237
|
+
throw new Error(`Task sandbox refused to read ${readable.path}: ${readable.reason}.`);
|
|
238
|
+
}
|
|
212
239
|
}
|
|
213
240
|
if (content !== undefined && Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
|
|
214
241
|
throw new Error("File exceeds 8 MiB operation limit");
|
|
@@ -256,6 +283,9 @@ export function createTaskFileOperations(controller: TaskFileController): {
|
|
|
256
283
|
child.stderr.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-2048); });
|
|
257
284
|
child.stdin.on("error", () => {}); // An early sandbox failure may close stdin.
|
|
258
285
|
child.on("error", (error) => {
|
|
286
|
+
if (["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) {
|
|
287
|
+
observe(tool, operation, path, policy, "os-permission-error", "denied");
|
|
288
|
+
}
|
|
259
289
|
if (!finished) { finished = true; clearTimeout(timer); void unlink(profilePath).catch(() => {}); reject(error); }
|
|
260
290
|
});
|
|
261
291
|
child.on("close", (code) => {
|
|
@@ -270,6 +300,7 @@ export function createTaskFileOperations(controller: TaskFileController): {
|
|
|
270
300
|
if (!result.ok) {
|
|
271
301
|
const error = new Error(result.error ?? `Task file sandbox failed (${code})`) as NodeJS.ErrnoException;
|
|
272
302
|
error.code = result.code;
|
|
303
|
+
if (["EACCES", "EPERM"].includes(result.code ?? "")) observe(tool, operation, path, policy, "os-permission-error", "denied");
|
|
273
304
|
return reject(error);
|
|
274
305
|
}
|
|
275
306
|
if (code !== 0) return reject(new Error(`Task file sandbox exited ${code}: ${stderr}`));
|
|
@@ -284,11 +315,14 @@ export function createTaskFileOperations(controller: TaskFileController): {
|
|
|
284
315
|
if (!plan.confined) return;
|
|
285
316
|
const policy = compileWritePolicy(plan.policy);
|
|
286
317
|
const decision = verb === "write" ? evaluateWriteAccess(path, policy) : removeDecision(path, policy);
|
|
287
|
-
if (!decision.allowed)
|
|
318
|
+
if (!decision.allowed) {
|
|
319
|
+
observe("apply_patch", verb === "remove" ? "unlink" : "write", path, policy, "policy-refusal", "denied");
|
|
320
|
+
throw refusal(verb, decision);
|
|
321
|
+
}
|
|
288
322
|
};
|
|
289
323
|
|
|
290
324
|
return {
|
|
291
|
-
remove: { remove: (path) => run("unlink", path) as Promise<void> },
|
|
325
|
+
remove: { remove: (path) => run("unlink", path, undefined, "apply_patch") as Promise<void> },
|
|
292
326
|
check: { write: check("write"), remove: check("remove") },
|
|
293
327
|
read: {
|
|
294
328
|
readFile: (path) => run("read", path) as Promise<Buffer>,
|
|
@@ -296,13 +330,13 @@ export function createTaskFileOperations(controller: TaskFileController): {
|
|
|
296
330
|
detectImageMimeType: async (path) => mimeType(await run("mime", path) as Buffer),
|
|
297
331
|
},
|
|
298
332
|
write: {
|
|
299
|
-
mkdir: (path) => run("mkdir", path) as Promise<void>,
|
|
300
|
-
writeFile: (path, content) => run("write", path, content) as Promise<void>,
|
|
333
|
+
mkdir: (path) => run("mkdir", path, undefined, "write") as Promise<void>,
|
|
334
|
+
writeFile: (path, content) => run("write", path, content, "write") as Promise<void>,
|
|
301
335
|
},
|
|
302
336
|
edit: {
|
|
303
|
-
readFile: (path) => run("read", path) as Promise<Buffer>,
|
|
304
|
-
access: (path) => run("access-edit", path) as Promise<void>,
|
|
305
|
-
writeFile: (path, content) => run("write", path, content) as Promise<void>,
|
|
337
|
+
readFile: (path) => run("read", path, undefined, "edit") as Promise<Buffer>,
|
|
338
|
+
access: (path) => run("access-edit", path, undefined, "edit") as Promise<void>,
|
|
339
|
+
writeFile: (path, content) => run("write", path, content, "edit") as Promise<void>,
|
|
306
340
|
},
|
|
307
341
|
};
|
|
308
342
|
}
|
package/shared-task-sandbox.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { canonicalizePath, compileWritePolicy, isRemovableUnderWrite, maybeBuild
|
|
|
15
15
|
import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
|
|
16
16
|
import { APPLY_PATCH_TOOL, createApplyPatchToolDefinition } from "./shared-task-apply-patch.ts";
|
|
17
17
|
import { createProcessListToolDefinition, PROCESS_LIST_TOOL } from "./shared-task-process-list.ts";
|
|
18
|
+
import type { SandboxDiagnostics, DiagnosticResource } from "./shared-sandbox-diagnostics.ts";
|
|
18
19
|
|
|
19
20
|
export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash", PROCESS_LIST_TOOL] as const);
|
|
20
21
|
/** Harness adapters that follow the file rules; each is a task builtin only when the profile enables it. */
|
|
@@ -103,14 +104,26 @@ export function createTaskScratch(): { path: string; anchor: string } {
|
|
|
103
104
|
export function createTaskBashOperations(
|
|
104
105
|
controller: TaskFileController,
|
|
105
106
|
shellPath: () => string | undefined = () => undefined,
|
|
107
|
+
diagnostics?: Pick<SandboxDiagnostics, "observe">,
|
|
106
108
|
): BashOperations {
|
|
107
109
|
return {
|
|
108
110
|
async exec(command, cwd, options) {
|
|
111
|
+
const observed = (basis: "policy-refusal" | "os-permission-error", outcome: "denied" | "succeeded") => {
|
|
112
|
+
try { diagnostics?.observe({ tool: "bash", operation: { command, cwd }, resource: "command-execution", basis, outcome }); }
|
|
113
|
+
catch { /* Observation is nonfatal. */ }
|
|
114
|
+
};
|
|
109
115
|
const configuredShell = shellPath();
|
|
110
116
|
const local = createLocalBashOperations(configuredShell ? { shellPath: configuredShell } : {});
|
|
111
117
|
const plan = controller.requireLaunchPlan();
|
|
112
|
-
if (!plan.confined)
|
|
113
|
-
|
|
118
|
+
if (!plan.confined) {
|
|
119
|
+
const result = await local.exec(command, cwd, options);
|
|
120
|
+
if (result.exitCode === 0) observed("os-permission-error", "succeeded");
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
if (plan.policy.permissions?.commands === false) {
|
|
124
|
+
observed("policy-refusal", "denied");
|
|
125
|
+
throw new Error("Sandbox: Run commands & applications is Off.");
|
|
126
|
+
}
|
|
114
127
|
// An explicit path avoids SDK fallback spawning PATH-resolved `which`.
|
|
115
128
|
const shell = getShellConfig(configuredShell ?? "/bin/bash");
|
|
116
129
|
const sdkUtils = join(PiCodingAgent.getPackageDir(), "dist", "utils");
|
|
@@ -158,9 +171,18 @@ export function createTaskBashOperations(
|
|
|
158
171
|
cleanup();
|
|
159
172
|
if (options.signal?.aborted) reject(new Error("aborted"));
|
|
160
173
|
else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
|
|
161
|
-
else
|
|
162
|
-
|
|
174
|
+
else {
|
|
175
|
+
if (exitCode === 0) observed("os-permission-error", "succeeded");
|
|
176
|
+
resolve({ exitCode });
|
|
177
|
+
}
|
|
178
|
+
}, (error: unknown) => {
|
|
179
|
+
cleanup();
|
|
180
|
+
reject(error);
|
|
181
|
+
});
|
|
163
182
|
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException)?.code ?? "")) observed("os-permission-error", "denied");
|
|
185
|
+
throw error;
|
|
164
186
|
} finally {
|
|
165
187
|
await unlink(profilePath).catch((error: NodeJS.ErrnoException) => {
|
|
166
188
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -188,13 +210,14 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
188
210
|
processAccess?: () => "off" | "read";
|
|
189
211
|
/** Build the admitted bash definition from the confined operations (default: the SDK bash tool). */
|
|
190
212
|
bashDefinition?: (cwd: string, operations: BashOperations) => ReturnType<typeof createBashToolDefinition>;
|
|
213
|
+
diagnostics?: Pick<SandboxDiagnostics, "observe">;
|
|
191
214
|
}) {
|
|
192
215
|
const { controller } = options;
|
|
193
216
|
const sourceKey = (path: string) => path.startsWith("<") ? path : canonicalizePath(path);
|
|
194
217
|
const trustedSources = new Set(options.trustedSources.map(sourceKey));
|
|
195
218
|
const trustedSource = (path: string | undefined) => path !== undefined && trustedSources.has(sourceKey(path));
|
|
196
|
-
const files = createTaskFileOperations(controller);
|
|
197
|
-
const bash = createTaskBashOperations(controller, options.shellPath);
|
|
219
|
+
const files = createTaskFileOperations(controller, options.diagnostics);
|
|
220
|
+
const bash = createTaskBashOperations(controller, options.shellPath, options.diagnostics);
|
|
198
221
|
const schemas = new Map<string, unknown>();
|
|
199
222
|
let currentCwd: string | undefined;
|
|
200
223
|
const register = (cwd: string) => {
|
|
@@ -211,37 +234,48 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
211
234
|
pi.registerTool(own(options.bashDefinition ? options.bashDefinition(cwd, bash) : createBashToolDefinition(cwd, { operations: bash })));
|
|
212
235
|
pi.registerTool(own(createProcessListToolDefinition(cwd, controller, options.processAccess ?? (() => "off"))) as any);
|
|
213
236
|
if (options.applyPatch) {
|
|
237
|
+
const patchFiles = createTaskFileOperations(controller, options.diagnostics, "apply_patch");
|
|
214
238
|
pi.registerTool(own(createApplyPatchToolDefinition(cwd, {
|
|
215
|
-
readFile:
|
|
216
|
-
remove:
|
|
239
|
+
readFile: patchFiles.read.readFile, writeFile: patchFiles.write.writeFile, mkdir: patchFiles.write.mkdir,
|
|
240
|
+
remove: patchFiles.remove.remove, checkWrite: patchFiles.check.write, checkRemove: patchFiles.check.remove,
|
|
217
241
|
}, PiCodingAgent.withFileMutationQueue)) as any);
|
|
218
242
|
}
|
|
219
243
|
};
|
|
220
244
|
register(options.cwd);
|
|
221
245
|
pi.on("user_bash", () => ({ operations: bash }));
|
|
222
|
-
pi.on("tool_call", (event) => {
|
|
246
|
+
pi.on("tool_call", (event, ctx) => {
|
|
247
|
+
const blocked = (reason: string, resource: DiagnosticResource) => {
|
|
248
|
+
try { options.diagnostics?.observe({ tool: event.toolName, operation: { input: event.input, cwd: ctx?.cwd ?? currentCwd }, resource,
|
|
249
|
+
basis: "policy-refusal", outcome: "denied" }); } catch { /* Observation is nonfatal. */ }
|
|
250
|
+
return { block: true, reason };
|
|
251
|
+
};
|
|
223
252
|
try {
|
|
224
253
|
const plan = controller.requireLaunchPlan();
|
|
225
254
|
if (!plan.confined) return;
|
|
226
255
|
const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);
|
|
227
256
|
if (schemas.has(event.toolName)) {
|
|
228
257
|
if (!tool || tool.parameters !== schemas.get(event.toolName) || !trustedSource(tool.sourceInfo?.path)) {
|
|
229
|
-
return
|
|
258
|
+
return blocked(`Sandbox: ${event.toolName} was replaced by an unverified implementation.`, "tool-admission");
|
|
230
259
|
}
|
|
231
260
|
if (event.toolName === "bash" && plan.policy.permissions?.commands === false) {
|
|
232
|
-
return
|
|
261
|
+
return blocked("Sandbox: Run commands & applications is Off.", "command-execution");
|
|
233
262
|
}
|
|
234
263
|
if (event.toolName === PROCESS_LIST_TOOL && options.processAccess?.() !== "read") {
|
|
235
|
-
return
|
|
264
|
+
return blocked("Sandbox: Process access is Off. Enable Read in /sandbox.", "process-inspection");
|
|
236
265
|
}
|
|
237
266
|
return;
|
|
238
267
|
}
|
|
239
268
|
if (options.admitExtensionTool?.(event.toolName, event.input, tool?.sourceInfo?.path)) return;
|
|
240
|
-
return
|
|
269
|
+
return blocked(`Sandbox: ${event.toolName} has no verified task execution adapter. Use a guarded file tool or confined bash command.`, "tool-admission");
|
|
241
270
|
} catch (error) {
|
|
242
|
-
return
|
|
271
|
+
return blocked(error instanceof Error ? error.message : String(error), "sandbox-backend");
|
|
243
272
|
}
|
|
244
273
|
});
|
|
274
|
+
if (options.diagnostics) pi.on("tool_result", (event, ctx) => {
|
|
275
|
+
if (event.isError) return;
|
|
276
|
+
try { options.diagnostics?.observe({ tool: event.toolName, operation: { input: event.input, cwd: ctx?.cwd ?? currentCwd }, resource: "unknown",
|
|
277
|
+
basis: "policy-refusal", outcome: "succeeded" }); } catch { /* Observation is nonfatal. */ }
|
|
278
|
+
});
|
|
245
279
|
return { register, bash, assertInstalled(names: readonly string[]) {
|
|
246
280
|
const inventory = pi.getAllTools();
|
|
247
281
|
for (const name of names) {
|
package/task-guard.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { parseTaskPolicy, type TaskPolicy } from "./task-policy.ts";
|
|
|
6
6
|
import { SANDBOX_POLICY_CHANNEL, SANDBOX_POLICY_REQUEST_CHANNEL } from "./permission-policy.ts";
|
|
7
7
|
import { failureDispositionTool, intentBashDefinition } from "./child-incidents.ts";
|
|
8
8
|
import { DISPOSITION_TOOL } from "./incident-model.ts";
|
|
9
|
+
import { SandboxDiagnostics, diagnosticPackageVersion } from "./shared-sandbox-diagnostics.ts";
|
|
9
10
|
|
|
10
11
|
const TRUSTED_INLINE_SOURCE = "<inline:task-sandbox>";
|
|
11
12
|
|
|
@@ -42,7 +43,19 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
|
|
|
42
43
|
const controller = Object.freeze({ requireLaunchPlan: () => plan });
|
|
43
44
|
let shellPath: string | undefined;
|
|
44
45
|
let currentCwd = process.cwd();
|
|
46
|
+
let diagnosticsWarned = false;
|
|
47
|
+
const diagnostics = new SandboxDiagnostics({ context: "worker", agentDir: () => policy.agentDir,
|
|
48
|
+
relay: (report) => { process.stdout.write(`${JSON.stringify(report)}\n`); },
|
|
49
|
+
version: diagnosticPackageVersion(new URL("./package.json", import.meta.url)),
|
|
50
|
+
policy: () => ({ permissions: policy.permissions, denyWrite: policy.denyWrite, root: policy.root }),
|
|
51
|
+
backend: () => support.backend,
|
|
52
|
+
onError: () => {
|
|
53
|
+
if (!diagnosticsWarned) process.stderr.write("Sandbox diagnostics collection gap; enforcement unchanged.\n");
|
|
54
|
+
diagnosticsWarned = true;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
45
57
|
const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
|
|
58
|
+
diagnostics,
|
|
46
59
|
trustedSources: [TRUSTED_INLINE_SOURCE],
|
|
47
60
|
// Structured command intent (#315): validated before the confined command runs.
|
|
48
61
|
bashDefinition: (cwd, operations) => intentBashDefinition(cwd, operations) as any,
|