pi-better-subagents 0.2.0 → 0.4.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/README.md +2 -2
- package/docs/failure-observations.md +11 -0
- package/failures.ts +188 -0
- package/finalization.ts +14 -0
- package/index.ts +130 -40
- package/list.mjs +4 -1
- package/package.json +7 -6
- package/permission-policy.ts +104 -0
- package/registry.ts +17 -3
- package/sandbox.ts +26 -5
- package/shared-callback-batcher.ts +42 -16
- package/shared-failure-observations.ts +205 -0
- package/shared-sandbox-core.ts +309 -14
- package/shared-task-files.ts +246 -0
- package/shared-task-sandbox.ts +189 -0
- package/task-guard.ts +47 -0
- package/task-policy.ts +97 -0
- package/task-runtime.mjs +39 -0
- package/tools.ts +9 -4
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Generated from packages/task-sandbox/files.ts. Do not edit directly.
|
|
2
|
+
/** Kernel-confined operations for Pi's built-in file tools. */
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { access, mkdir, open, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import type { ReadOperations, WriteOperations, EditOperations } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
canonicalizePath, compileWritePolicy, evaluateReadAccess, evaluateWriteAccess, maybeBuildSandboxCommand,
|
|
10
|
+
type SandboxWritePolicy,
|
|
11
|
+
} from "./shared-sandbox-core.ts";
|
|
12
|
+
|
|
13
|
+
export interface TaskFileController {
|
|
14
|
+
requireLaunchPlan(): { confined: false } | { confined: true; policy: SandboxWritePolicy; profilePath: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The SDK truncates display output after reading. This separate operation cap
|
|
18
|
+
// bounds worker memory and pipe traffic, and rejects rather than truncates files.
|
|
19
|
+
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
const MAX_REQUEST_BYTES = 12 * 1024 * 1024;
|
|
21
|
+
const MAX_RESPONSE_BYTES = 12 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
// Fixed program: tool-supplied path/content are JSON on stdin, never argv/code.
|
|
24
|
+
const FILE_WORKER = String.raw`
|
|
25
|
+
const fs = require('node:fs/promises');
|
|
26
|
+
const { constants } = require('node:fs');
|
|
27
|
+
const MAX = 8 * 1024 * 1024;
|
|
28
|
+
let input = Buffer.alloc(0);
|
|
29
|
+
process.stdin.on('data', chunk => {
|
|
30
|
+
if (input.length + chunk.length > 12 * 1024 * 1024) {
|
|
31
|
+
process.stdout.write(JSON.stringify({error:'File operation request exceeds 12 MiB',code:'EFBIG'}));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
input = Buffer.concat([input, chunk]);
|
|
35
|
+
});
|
|
36
|
+
process.stdin.on('end', async () => {
|
|
37
|
+
try {
|
|
38
|
+
const { operation, path, content } = JSON.parse(input.toString('utf8'));
|
|
39
|
+
if (typeof path !== 'string') throw new Error('Invalid file path');
|
|
40
|
+
let data;
|
|
41
|
+
switch (operation) {
|
|
42
|
+
case 'read':
|
|
43
|
+
case 'mime': {
|
|
44
|
+
const handle = await fs.open(path, 'r');
|
|
45
|
+
try {
|
|
46
|
+
if (operation === 'mime') {
|
|
47
|
+
const sample = Buffer.alloc(4100);
|
|
48
|
+
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
|
|
49
|
+
data = sample.subarray(0, bytesRead).toString('base64');
|
|
50
|
+
} else {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const chunk = Buffer.alloc(Math.min(65536, MAX + 1 - size));
|
|
55
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
56
|
+
if (!bytesRead) break;
|
|
57
|
+
size += bytesRead;
|
|
58
|
+
if (size > MAX) throw Object.assign(new Error('File exceeds 8 MiB operation limit'), {code:'EFBIG'});
|
|
59
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
60
|
+
}
|
|
61
|
+
data = Buffer.concat(chunks, size).toString('base64');
|
|
62
|
+
}
|
|
63
|
+
} finally { await handle.close(); }
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'access-read': await fs.access(path, constants.R_OK); break;
|
|
67
|
+
case 'access-edit': await fs.access(path, constants.R_OK | constants.W_OK); break;
|
|
68
|
+
case 'existing-directory':
|
|
69
|
+
if (!(await fs.stat(path)).isDirectory()) throw new Error('Not an existing directory');
|
|
70
|
+
break;
|
|
71
|
+
case 'mkdir': await fs.mkdir(path, {recursive:true}); break;
|
|
72
|
+
case 'write':
|
|
73
|
+
if (typeof content !== 'string' || Buffer.byteLength(content, 'utf8') > MAX)
|
|
74
|
+
throw Object.assign(new Error('File exceeds 8 MiB operation limit'), {code:'EFBIG'});
|
|
75
|
+
await fs.writeFile(path, content, 'utf8'); break;
|
|
76
|
+
default: throw new Error('Unknown file operation');
|
|
77
|
+
}
|
|
78
|
+
process.stdout.write(JSON.stringify({ok:true, data}));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
process.stdout.write(JSON.stringify({error: String(error.message || error), code: error.code}));
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
});`;
|
|
84
|
+
|
|
85
|
+
type Operation = "read" | "mime" | "access-read" | "access-edit" | "mkdir" | "existing-directory" | "write";
|
|
86
|
+
|
|
87
|
+
function mimeType(data: Buffer): string | null {
|
|
88
|
+
const ascii = (at: number, text: string) => data.toString("ascii", at, at + text.length) === text;
|
|
89
|
+
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
|
90
|
+
return data[3] === 0xf7 ? null : "image/jpeg";
|
|
91
|
+
}
|
|
92
|
+
if (data.length >= 16 && data.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")) &&
|
|
93
|
+
data.readUInt32BE(8) === 13 && ascii(12, "IHDR")) {
|
|
94
|
+
for (let offset = 8; offset + 8 <= data.length;) {
|
|
95
|
+
if (ascii(offset + 4, "acTL")) return null;
|
|
96
|
+
if (ascii(offset + 4, "IDAT")) break;
|
|
97
|
+
const next = offset + 12 + data.readUInt32BE(offset);
|
|
98
|
+
if (next <= offset || next > data.length) break;
|
|
99
|
+
offset = next;
|
|
100
|
+
}
|
|
101
|
+
return "image/png";
|
|
102
|
+
}
|
|
103
|
+
if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
|
|
104
|
+
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
|
|
105
|
+
if (ascii(0, "BM") && data.length >= 30) {
|
|
106
|
+
const size = data.readUInt32LE(2);
|
|
107
|
+
const offset = data.readUInt32LE(10);
|
|
108
|
+
const dib = data.readUInt32LE(14);
|
|
109
|
+
const planes = dib === 12 ? data.readUInt16LE(22) : data.readUInt16LE(26);
|
|
110
|
+
const bits = dib === 12 ? data.readUInt16LE(24) : data.readUInt16LE(28);
|
|
111
|
+
if ((size === 0 || size >= 26) && offset >= 14 + dib && (size === 0 || offset < size) &&
|
|
112
|
+
(dib === 12 || (dib >= 40 && dib <= 124)) && planes === 1 && [1, 4, 8, 16, 24, 32].includes(bits)) {
|
|
113
|
+
return "image/bmp";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createTaskFileOperations(controller: TaskFileController): {
|
|
120
|
+
read: ReadOperations; write: WriteOperations; edit: EditOperations;
|
|
121
|
+
} {
|
|
122
|
+
async function run(operation: Operation, path: string, content?: string): Promise<Buffer | void> {
|
|
123
|
+
const plan = controller.requireLaunchPlan();
|
|
124
|
+
if (!plan.confined) {
|
|
125
|
+
switch (operation) {
|
|
126
|
+
case "read": return readFile(path);
|
|
127
|
+
case "mime": {
|
|
128
|
+
const handle = await open(path, "r");
|
|
129
|
+
try {
|
|
130
|
+
const sample = Buffer.alloc(4100);
|
|
131
|
+
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
|
|
132
|
+
return sample.subarray(0, bytesRead);
|
|
133
|
+
} finally { await handle.close(); }
|
|
134
|
+
}
|
|
135
|
+
case "access-read": return access(path, constants.R_OK);
|
|
136
|
+
case "access-edit": return access(path, constants.R_OK | constants.W_OK);
|
|
137
|
+
case "existing-directory":
|
|
138
|
+
if (!(await stat(path)).isDirectory()) throw new Error("Not an existing directory");
|
|
139
|
+
return;
|
|
140
|
+
case "mkdir": await mkdir(path, { recursive: true }); return;
|
|
141
|
+
case "write": return writeFile(path, content!, "utf8");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const policy = compileWritePolicy(plan.policy);
|
|
146
|
+
const writing = operation === "write" || operation === "mkdir" || operation === "access-edit";
|
|
147
|
+
const decision = writing ? evaluateWriteAccess(path, policy) : evaluateReadAccess(path, policy);
|
|
148
|
+
if (!decision.allowed && operation !== "existing-directory") {
|
|
149
|
+
if (operation === "mkdir" && decision.reason === "permission-denied") {
|
|
150
|
+
// SDK write prepares the parent even when it already exists.
|
|
151
|
+
// Confirm that no-op inside confinement; never grant mkdir.
|
|
152
|
+
try { await run("existing-directory", path); return; } catch { /* Preserve the original refusal. */ }
|
|
153
|
+
}
|
|
154
|
+
const detail = "deniedBy" in decision ? ` (protected by ${decision.deniedBy})` : "";
|
|
155
|
+
throw new Error(`Task sandbox refused to ${writing ? "write" : "read"} ${decision.path}: ${decision.reason}${detail}.`);
|
|
156
|
+
}
|
|
157
|
+
if (operation === "access-edit") {
|
|
158
|
+
const readable = evaluateReadAccess(path, policy);
|
|
159
|
+
if (!readable.allowed) throw new Error(`Task sandbox refused to read ${readable.path}: ${readable.reason}.`);
|
|
160
|
+
}
|
|
161
|
+
if (content !== undefined && Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
|
|
162
|
+
throw new Error("File exceeds 8 MiB operation limit");
|
|
163
|
+
}
|
|
164
|
+
const payload = Buffer.from(JSON.stringify({ operation, path, content }), "utf8");
|
|
165
|
+
if (payload.length > MAX_REQUEST_BYTES) throw new Error("File operation request exceeds 12 MiB");
|
|
166
|
+
|
|
167
|
+
// Internal helper privilege only: never alter file/credential rules.
|
|
168
|
+
const helperPolicy: SandboxWritePolicy = {
|
|
169
|
+
...plan.policy,
|
|
170
|
+
permissions: {
|
|
171
|
+
projectFiles: "read-write", outsideProject: "read", storedCredentials: "read",
|
|
172
|
+
...plan.policy.permissions,
|
|
173
|
+
commands: true, network: false,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
const profilePath = `${plan.profilePath}.${randomUUID()}.files.sb`;
|
|
177
|
+
let command;
|
|
178
|
+
try {
|
|
179
|
+
command = maybeBuildSandboxCommand({
|
|
180
|
+
execPath: canonicalizePath(process.execPath), execArgs: ["-e", FILE_WORKER], internalHelperExecutable: true,
|
|
181
|
+
profilePath, policy: helperPolicy,
|
|
182
|
+
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
void unlink(profilePath).catch(() => {});
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
if (!command) throw new Error("Task file sandbox backend unavailable");
|
|
188
|
+
|
|
189
|
+
return new Promise<Buffer | void>((resolve, reject) => {
|
|
190
|
+
const child = spawn(command.file, command.fileArgs, {
|
|
191
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
192
|
+
env: { PATH: process.env.PATH ?? "", HOME: plan.policy.home },
|
|
193
|
+
});
|
|
194
|
+
const chunks: Buffer[] = [];
|
|
195
|
+
let size = 0;
|
|
196
|
+
let stderr = "";
|
|
197
|
+
let finished = false;
|
|
198
|
+
const timer = setTimeout(() => child.kill(), 30_000);
|
|
199
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
200
|
+
size += chunk.length;
|
|
201
|
+
if (size > MAX_RESPONSE_BYTES) child.kill();
|
|
202
|
+
else chunks.push(chunk);
|
|
203
|
+
});
|
|
204
|
+
child.stderr.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-2048); });
|
|
205
|
+
child.stdin.on("error", () => {}); // An early sandbox failure may close stdin.
|
|
206
|
+
child.on("error", (error) => {
|
|
207
|
+
if (!finished) { finished = true; clearTimeout(timer); void unlink(profilePath).catch(() => {}); reject(error); }
|
|
208
|
+
});
|
|
209
|
+
child.on("close", (code) => {
|
|
210
|
+
void unlink(profilePath).catch(() => {});
|
|
211
|
+
if (finished) return;
|
|
212
|
+
finished = true;
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
if (size > MAX_RESPONSE_BYTES) return reject(new Error("File operation response exceeds 12 MiB"));
|
|
215
|
+
let result: { ok?: boolean; data?: string; error?: string; code?: string };
|
|
216
|
+
try { result = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
217
|
+
catch { return reject(new Error(`Task file sandbox failed (${code}): ${stderr || "no worker response"}`)); }
|
|
218
|
+
if (!result.ok) {
|
|
219
|
+
const error = new Error(result.error ?? `Task file sandbox failed (${code})`) as NodeJS.ErrnoException;
|
|
220
|
+
error.code = result.code;
|
|
221
|
+
return reject(error);
|
|
222
|
+
}
|
|
223
|
+
if (code !== 0) return reject(new Error(`Task file sandbox exited ${code}: ${stderr}`));
|
|
224
|
+
resolve(result.data === undefined ? undefined : Buffer.from(result.data, "base64"));
|
|
225
|
+
});
|
|
226
|
+
child.stdin.end(payload);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
read: {
|
|
232
|
+
readFile: (path) => run("read", path) as Promise<Buffer>,
|
|
233
|
+
access: (path) => run("access-read", path) as Promise<void>,
|
|
234
|
+
detectImageMimeType: async (path) => mimeType(await run("mime", path) as Buffer),
|
|
235
|
+
},
|
|
236
|
+
write: {
|
|
237
|
+
mkdir: (path) => run("mkdir", path) as Promise<void>,
|
|
238
|
+
writeFile: (path, content) => run("write", path, content) as Promise<void>,
|
|
239
|
+
},
|
|
240
|
+
edit: {
|
|
241
|
+
readFile: (path) => run("read", path) as Promise<Buffer>,
|
|
242
|
+
access: (path) => run("access-edit", path) as Promise<void>,
|
|
243
|
+
writeFile: (path, content) => run("write", path, content) as Promise<void>,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Generated from packages/task-sandbox/index.ts. Do not edit directly.
|
|
2
|
+
/** The task boundary: trusted Pi owns the runtime; every admitted task tool uses this executor. */
|
|
3
|
+
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { BashOperations, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
const { createBashToolDefinition, createReadToolDefinition, createWriteToolDefinition,
|
|
6
|
+
createEditToolDefinition, createLocalBashOperations, getShellConfig } = PiCodingAgent;
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
12
|
+
import { canonicalizePath, maybeBuildSandboxCommand } from "./shared-sandbox-core.ts";
|
|
13
|
+
import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
|
|
14
|
+
|
|
15
|
+
export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash"] as const);
|
|
16
|
+
|
|
17
|
+
/** Loaded code and its installed dependencies must remain task-read-only. */
|
|
18
|
+
export function runtimeCodeRoot(path: string): string {
|
|
19
|
+
const canonical = canonicalizePath(path);
|
|
20
|
+
let current = dirname(canonical);
|
|
21
|
+
while (dirname(current) !== current) {
|
|
22
|
+
if (basename(current) === "node_modules") return current;
|
|
23
|
+
current = dirname(current);
|
|
24
|
+
}
|
|
25
|
+
return dirname(canonical);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writableRuntimeAlias(path: string, root: string, permissions: {
|
|
29
|
+
projectFiles: string; outsideProject: string; storedCredentials: string;
|
|
30
|
+
}): string | undefined {
|
|
31
|
+
for (let current = resolve(path); dirname(current) !== current; current = dirname(current)) {
|
|
32
|
+
try {
|
|
33
|
+
if (!lstatSync(current).isSymbolicLink()) continue;
|
|
34
|
+
const entry = join(canonicalizePath(dirname(current)), basename(current));
|
|
35
|
+
accessSync(dirname(entry), constants.W_OK);
|
|
36
|
+
const inProject = entry === root || entry.startsWith(root + sep);
|
|
37
|
+
const access = inProject ? permissions.projectFiles : permissions.outsideProject;
|
|
38
|
+
if (access === "read-write" || permissions.storedCredentials === "read-write") return entry;
|
|
39
|
+
} catch { /* Nonexistent or OS-protected entries cannot be replaced by the task. */ }
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function harnessRuntimeDirectories(): string[] {
|
|
45
|
+
const names = ["pi-better-subagents", "pi-better-background-tasks"];
|
|
46
|
+
const pool = process.env.VITEST_POOL_ID;
|
|
47
|
+
if (pool && /^\d+$/.test(pool)) names.push(`pi-better-background-tasks-vitest-${pool}`);
|
|
48
|
+
return names.map((name) => canonicalizePath(join(tmpdir(), name)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ensureHarnessRuntimeDirectories(): string[] {
|
|
52
|
+
const directories = harnessRuntimeDirectories();
|
|
53
|
+
for (const path of directories) mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
54
|
+
return directories;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createTaskScratch(): { path: string; anchor: string } {
|
|
58
|
+
const path = canonicalizePath(mkdtempSync(join(tmpdir(), "pi-task-scratch-")));
|
|
59
|
+
const anchor = join(path, ".sandbox-anchor");
|
|
60
|
+
writeFileSync(anchor, "", { flag: "wx", mode: 0o400 });
|
|
61
|
+
// Denying the anchor also prevents renaming/replacing its parent directory.
|
|
62
|
+
return { path, anchor };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createTaskBashOperations(
|
|
66
|
+
controller: TaskFileController,
|
|
67
|
+
shellPath: () => string | undefined = () => undefined,
|
|
68
|
+
): BashOperations {
|
|
69
|
+
return {
|
|
70
|
+
async exec(command, cwd, options) {
|
|
71
|
+
const configuredShell = shellPath();
|
|
72
|
+
const local = createLocalBashOperations(configuredShell ? { shellPath: configuredShell } : {});
|
|
73
|
+
const plan = controller.requireLaunchPlan();
|
|
74
|
+
if (!plan.confined) return local.exec(command, cwd, options);
|
|
75
|
+
if (plan.policy.permissions?.commands === false) throw new Error("Sandbox: Run commands & applications is Off.");
|
|
76
|
+
// An explicit path avoids SDK fallback spawning PATH-resolved `which`.
|
|
77
|
+
const shell = getShellConfig(configuredShell ?? "/bin/bash");
|
|
78
|
+
const sdkUtils = join(PiCodingAgent.getPackageDir(), "dist", "utils");
|
|
79
|
+
const { waitForChildProcess } = await import(pathToFileURL(join(sdkUtils, "child-process.js")).href);
|
|
80
|
+
const { trackDetachedChildPid, untrackDetachedChildPid } = await import(pathToFileURL(join(sdkUtils, "shell.js")).href);
|
|
81
|
+
if (shell.commandTransport === "stdin") throw new Error("Sandbox: this shell cannot be confined by the available backend.");
|
|
82
|
+
const scratch = plan.policy.runtimeWrite?.[0];
|
|
83
|
+
const taskEnv = { ...process.env, ...options.env,
|
|
84
|
+
...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
|
|
85
|
+
const wrapped = maybeBuildSandboxCommand({
|
|
86
|
+
policy: plan.policy, profilePath: plan.profilePath,
|
|
87
|
+
execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
|
|
88
|
+
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
89
|
+
.map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
|
|
90
|
+
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
91
|
+
if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
|
|
92
|
+
if (options.signal?.aborted) throw new Error("aborted");
|
|
93
|
+
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
|
|
94
|
+
throw new Error("Invalid timeout: must be a positive, supported number of seconds");
|
|
95
|
+
}
|
|
96
|
+
// No shell or caller-controlled loader environment runs before the boundary.
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
|
|
99
|
+
env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
|
|
100
|
+
let timedOut = false;
|
|
101
|
+
const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
|
|
102
|
+
const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
|
|
103
|
+
if (child.pid) trackDetachedChildPid(child.pid);
|
|
104
|
+
const cleanup = () => {
|
|
105
|
+
if (child.pid) untrackDetachedChildPid(child.pid);
|
|
106
|
+
if (timer) clearTimeout(timer);
|
|
107
|
+
options.signal?.removeEventListener("abort", kill);
|
|
108
|
+
};
|
|
109
|
+
child.stdout.on("data", options.onData);
|
|
110
|
+
child.stderr.on("data", options.onData);
|
|
111
|
+
options.signal?.addEventListener("abort", kill, { once: true });
|
|
112
|
+
if (options.signal?.aborted) kill();
|
|
113
|
+
void waitForChildProcess(child).then((exitCode: number | null) => {
|
|
114
|
+
cleanup();
|
|
115
|
+
if (options.signal?.aborted) reject(new Error("aborted"));
|
|
116
|
+
else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
|
|
117
|
+
else resolve({ exitCode });
|
|
118
|
+
}, (error: unknown) => { cleanup(); reject(error); });
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Only definitions installed here are admitted as builtins. A distinct schema
|
|
126
|
+
* identity detects later replacement, including overrides using SDK factories.
|
|
127
|
+
* Other tool implementations need an explicit, host-owned admission function;
|
|
128
|
+
* knowing a tool's name or enabling network access never makes it confined.
|
|
129
|
+
*/
|
|
130
|
+
export function installTaskTools(pi: ExtensionAPI, options: {
|
|
131
|
+
controller: TaskFileController;
|
|
132
|
+
cwd: string;
|
|
133
|
+
shellPath?: () => string | undefined;
|
|
134
|
+
trustedSources: readonly string[];
|
|
135
|
+
admitExtensionTool?: (name: string, input: unknown, sourcePath: string | undefined) => boolean;
|
|
136
|
+
}) {
|
|
137
|
+
const { controller } = options;
|
|
138
|
+
const sourceKey = (path: string) => path.startsWith("<") ? path : canonicalizePath(path);
|
|
139
|
+
const trustedSources = new Set(options.trustedSources.map(sourceKey));
|
|
140
|
+
const trustedSource = (path: string | undefined) => path !== undefined && trustedSources.has(sourceKey(path));
|
|
141
|
+
const files = createTaskFileOperations(controller);
|
|
142
|
+
const bash = createTaskBashOperations(controller, options.shellPath);
|
|
143
|
+
const schemas = new Map<string, unknown>();
|
|
144
|
+
let currentCwd: string | undefined;
|
|
145
|
+
const register = (cwd: string) => {
|
|
146
|
+
if (cwd === currentCwd) return;
|
|
147
|
+
currentCwd = cwd;
|
|
148
|
+
const own = <T extends { name: string; parameters: object }>(definition: T): T => {
|
|
149
|
+
const parameters = { ...definition.parameters };
|
|
150
|
+
schemas.set(definition.name, parameters);
|
|
151
|
+
return { ...definition, parameters };
|
|
152
|
+
};
|
|
153
|
+
pi.registerTool(own(createReadToolDefinition(cwd, { operations: files.read })));
|
|
154
|
+
pi.registerTool(own(createWriteToolDefinition(cwd, { operations: files.write })));
|
|
155
|
+
pi.registerTool(own(createEditToolDefinition(cwd, { operations: files.edit })));
|
|
156
|
+
pi.registerTool(own(createBashToolDefinition(cwd, { operations: bash })));
|
|
157
|
+
};
|
|
158
|
+
register(options.cwd);
|
|
159
|
+
pi.on("user_bash", () => ({ operations: bash }));
|
|
160
|
+
pi.on("tool_call", (event) => {
|
|
161
|
+
try {
|
|
162
|
+
const plan = controller.requireLaunchPlan();
|
|
163
|
+
if (!plan.confined) return;
|
|
164
|
+
const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);
|
|
165
|
+
if (schemas.has(event.toolName)) {
|
|
166
|
+
if (!tool || tool.parameters !== schemas.get(event.toolName) || !trustedSource(tool.sourceInfo?.path)) {
|
|
167
|
+
return { block: true, reason: `Sandbox: ${event.toolName} was replaced by an unverified implementation.` };
|
|
168
|
+
}
|
|
169
|
+
if (event.toolName === "bash" && plan.policy.permissions?.commands === false) {
|
|
170
|
+
return { block: true, reason: "Sandbox: Run commands & applications is Off." };
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (options.admitExtensionTool?.(event.toolName, event.input, tool?.sourceInfo?.path)) return;
|
|
175
|
+
return { block: true, reason: `Sandbox: ${event.toolName} has no verified task execution adapter. Use a guarded file tool or confined bash command.` };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return { register, bash, assertInstalled(names: readonly string[]) {
|
|
181
|
+
const inventory = pi.getAllTools();
|
|
182
|
+
for (const name of names) {
|
|
183
|
+
const tool = inventory.find((candidate) => candidate.name === name);
|
|
184
|
+
if (!schemas.has(name) || tool?.parameters !== schemas.get(name) || !trustedSource(tool?.sourceInfo?.path)) {
|
|
185
|
+
throw new Error(`Sandbox: ${name} is missing or was replaced by an unverified implementation.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} };
|
|
189
|
+
}
|
package/task-guard.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Installed as the final inline extension by the trusted native launcher. */
|
|
2
|
+
import { SettingsManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { describeSandboxSupport } from "./shared-sandbox-core.ts";
|
|
4
|
+
import { installTaskTools, TASK_BUILTINS } from "./shared-task-sandbox.ts";
|
|
5
|
+
import { parseTaskPolicy } from "./task-policy.ts";
|
|
6
|
+
import { SANDBOX_POLICY_CHANNEL, SANDBOX_POLICY_REQUEST_CHANNEL } from "./permission-policy.ts";
|
|
7
|
+
|
|
8
|
+
export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (error: unknown) => never): void {
|
|
9
|
+
const policy = parseTaskPolicy(input);
|
|
10
|
+
const support = describeSandboxSupport();
|
|
11
|
+
if (!support.supported) throw new Error(`Task sandbox unavailable: ${support.reason}`);
|
|
12
|
+
const plan = Object.freeze({
|
|
13
|
+
confined: true as const, profilePath: policy.profilePath,
|
|
14
|
+
policy: Object.freeze({ writableRoot: policy.root, home: policy.home, permissions: policy.permissions, denyWrite: policy.denyWrite,
|
|
15
|
+
runtimeWrite: Object.freeze([policy.scratch]) }),
|
|
16
|
+
});
|
|
17
|
+
const controller = Object.freeze({ requireLaunchPlan: () => plan });
|
|
18
|
+
let shellPath: string | undefined;
|
|
19
|
+
const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
|
|
20
|
+
trustedSources: ["<inline:task-sandbox>"],
|
|
21
|
+
});
|
|
22
|
+
const profile = Object.freeze({ enabled: true, ...policy.permissions });
|
|
23
|
+
const status = Object.freeze({
|
|
24
|
+
state: "enabled", projectRoot: policy.root, writableRoot: policy.root,
|
|
25
|
+
denyWrite: policy.denyWrite, platform: process.platform, backend: support.backend, executable: support.executable,
|
|
26
|
+
permissions: profile, subagentPermissions: profile,
|
|
27
|
+
readPolicy: "restricted", networkPolicy: policy.permissions.network ? "unrestricted" : "blocked",
|
|
28
|
+
reason: "Immutable child task policy; Pi runtime operations remain trusted.",
|
|
29
|
+
});
|
|
30
|
+
pi.events.on(SANDBOX_POLICY_REQUEST_CHANNEL, () => pi.events.emit(SANDBOX_POLICY_CHANNEL, status));
|
|
31
|
+
pi.on("session_start", (_event, ctx) => {
|
|
32
|
+
try {
|
|
33
|
+
shellPath = SettingsManager.create(ctx.cwd, policy.agentDir, { projectTrusted: false }).getShellPath();
|
|
34
|
+
boundary.register(ctx.cwd);
|
|
35
|
+
const selected = policy.tools.filter((name) => (TASK_BUILTINS as readonly string[]).includes(name));
|
|
36
|
+
boundary.assertInstalled(selected);
|
|
37
|
+
pi.setActiveTools(selected);
|
|
38
|
+
const active = new Set(pi.getActiveTools());
|
|
39
|
+
if (active.size !== selected.length || selected.some((name) => !active.has(name))) {
|
|
40
|
+
throw new Error("SDK did not activate the guarded tool set; refusing to start the task.");
|
|
41
|
+
}
|
|
42
|
+
pi.events.emit(SANDBOX_POLICY_CHANNEL, status);
|
|
43
|
+
// A typed lifecycle marker, never inferred from assistant prose.
|
|
44
|
+
process.stdout.write(`${JSON.stringify({ type: "task_sandbox_ready", root: policy.root, tools: selected })}\n`);
|
|
45
|
+
} catch (error) { fatal(error); }
|
|
46
|
+
});
|
|
47
|
+
}
|
package/task-policy.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Immutable launch contract. This file contains policy, never credentials. */
|
|
2
|
+
import { realpathSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { canonicalizePath, compileWritePolicy, describeSandboxSupport, type SandboxPermissions } from "./shared-sandbox-core.ts";
|
|
8
|
+
import { createTaskScratch, ensureHarnessRuntimeDirectories, runtimeCodeRoot, writableRuntimeAlias } from "./shared-task-sandbox.ts";
|
|
9
|
+
|
|
10
|
+
export type TaskPolicy = Readonly<{
|
|
11
|
+
version: 1;
|
|
12
|
+
root: string;
|
|
13
|
+
home: string;
|
|
14
|
+
agentDir: string;
|
|
15
|
+
profilePath: string;
|
|
16
|
+
scratch: string;
|
|
17
|
+
permissions: Readonly<SandboxPermissions>;
|
|
18
|
+
denyWrite: readonly string[];
|
|
19
|
+
tools: readonly string[];
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_TASK_PERMISSIONS: Readonly<SandboxPermissions> = Object.freeze({
|
|
23
|
+
projectFiles: "read-write", outsideProject: "read", storedCredentials: "read", commands: true, network: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export function parseTaskPolicy(value: unknown): TaskPolicy {
|
|
27
|
+
if (!value || typeof value !== "object") throw new Error("Task sandbox policy is missing.");
|
|
28
|
+
const v = value as Record<string, unknown>;
|
|
29
|
+
const absolute = (key: string) => {
|
|
30
|
+
const path = v[key];
|
|
31
|
+
if (typeof path !== "string" || !isAbsolute(path)) throw new Error(`Task sandbox ${key} must be absolute.`);
|
|
32
|
+
return path;
|
|
33
|
+
};
|
|
34
|
+
if (v.version !== 1 || !v.permissions || typeof v.permissions !== "object") throw new Error("Unsupported task sandbox policy.");
|
|
35
|
+
const p = v.permissions as Record<string, unknown>;
|
|
36
|
+
for (const key of ["projectFiles", "outsideProject", "storedCredentials"]) {
|
|
37
|
+
if (!["off", "read", "read-write"].includes(String(p[key]))) throw new Error(`Invalid task sandbox ${key}.`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof p.commands !== "boolean" || typeof p.network !== "boolean") throw new Error("Invalid task sandbox capabilities.");
|
|
40
|
+
if (!Array.isArray(v.denyWrite) || !v.denyWrite.every((path) => typeof path === "string" && isAbsolute(path))) throw new Error("Invalid task sandbox protected paths.");
|
|
41
|
+
if (!Array.isArray(v.tools) || !v.tools.every((name) => typeof name === "string" && name.length > 0)) throw new Error("Invalid task sandbox tool selection.");
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
version: 1, root: absolute("root"), home: absolute("home"), agentDir: absolute("agentDir"), profilePath: absolute("profilePath"), scratch: absolute("scratch"),
|
|
44
|
+
permissions: Object.freeze({ ...p } as SandboxPermissions),
|
|
45
|
+
denyWrite: Object.freeze([...v.denyWrite]), tools: Object.freeze([...v.tools]),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function prepareTaskRuntime(options: {
|
|
50
|
+
root: string; controlDir: string; tools: readonly string[]; piBin: string;
|
|
51
|
+
permissions?: SandboxPermissions; extensionPaths?: readonly string[]; runtimeRoots?: readonly string[];
|
|
52
|
+
}): { file: string; fileArgs: string[]; policy: TaskPolicy; policyPath: string } {
|
|
53
|
+
if (typeof PiCodingAgent.getAgentDir !== "function" || typeof PiCodingAgent.getPackageDir !== "function") {
|
|
54
|
+
throw new Error("Task sandbox requires a supported active Pi SDK; refusing an unguarded child.");
|
|
55
|
+
}
|
|
56
|
+
const support = describeSandboxSupport();
|
|
57
|
+
if (!support.supported) throw new Error(`Task sandbox unavailable: ${support.reason}`);
|
|
58
|
+
const root = realpathSync(options.root);
|
|
59
|
+
const home = homedir();
|
|
60
|
+
if (root === dirname(root) || root === canonicalizePath(home)) throw new Error("Task sandbox requires a project directory, not the filesystem or home root.");
|
|
61
|
+
const permissions = options.permissions ?? DEFAULT_TASK_PERMISSIONS;
|
|
62
|
+
const alias = [PiCodingAgent.getAgentDir(), PiCodingAgent.getPackageDir(), tmpdir()]
|
|
63
|
+
.map((path) => writableRuntimeAlias(path, root, permissions)).find(Boolean);
|
|
64
|
+
if (alias) throw new Error(`Task sandbox runtime path uses a task-writable symlink (${alias}); restart Pi with canonical runtime paths.`);
|
|
65
|
+
const runtimeDirectories = ensureHarnessRuntimeDirectories();
|
|
66
|
+
const agentDir = canonicalizePath(PiCodingAgent.getAgentDir());
|
|
67
|
+
const controlDir = canonicalizePath(options.controlDir);
|
|
68
|
+
mkdirSync(controlDir, { recursive: true });
|
|
69
|
+
const sdkEntry = canonicalizePath(join(PiCodingAgent.getPackageDir(), "dist", "index.js"));
|
|
70
|
+
const cliPaths = ["cli.js", join("bundle", "cli.js")].map((entry) => canonicalizePath(join(PiCodingAgent.getPackageDir(), "dist", entry)));
|
|
71
|
+
if (!cliPaths.includes(canonicalizePath(options.piBin))) {
|
|
72
|
+
throw new Error("Task sandbox requires the active Pi SDK CLI; a different pi executable is on PATH.");
|
|
73
|
+
}
|
|
74
|
+
const ownEntry = fileURLToPath(import.meta.url);
|
|
75
|
+
const scratch = createTaskScratch();
|
|
76
|
+
const policy = parseTaskPolicy({
|
|
77
|
+
version: 1, root, home, agentDir, profilePath: join(controlDir, "task.sb"), scratch: scratch.path,
|
|
78
|
+
permissions,
|
|
79
|
+
denyWrite: [...new Set([
|
|
80
|
+
controlDir, scratch.anchor, agentDir, ...runtimeDirectories, ...(options.runtimeRoots ?? []), join(root, ".pi"), join(root, ".git", "hooks"), join(root, ".env"), join(root, ".env.local"),
|
|
81
|
+
runtimeCodeRoot(sdkEntry), runtimeCodeRoot(ownEntry), ...(options.extensionPaths ?? []).map(runtimeCodeRoot),
|
|
82
|
+
])],
|
|
83
|
+
tools: options.tools,
|
|
84
|
+
});
|
|
85
|
+
compileWritePolicy({ writableRoot: root, home, permissions: policy.permissions, denyWrite: policy.denyWrite });
|
|
86
|
+
const policyPath = join(controlDir, "task-policy.json");
|
|
87
|
+
writeFileSync(policyPath, JSON.stringify(policy), { mode: 0o600 });
|
|
88
|
+
return {
|
|
89
|
+
file: process.execPath,
|
|
90
|
+
fileArgs: [canonicalizePath(fileURLToPath(new URL("./task-runtime.mjs", import.meta.url))), sdkEntry, policyPath],
|
|
91
|
+
policy, policyPath,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readTaskPolicy(path: string): TaskPolicy {
|
|
96
|
+
return parseTaskPolicy(JSON.parse(readFileSync(path, "utf8")));
|
|
97
|
+
}
|
package/task-runtime.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Trusted native entry point. Any failure before/inside guard installation is fatal. */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const fail = (error) => {
|
|
8
|
+
process.stderr.write(`Task sandbox bootstrap failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const [sdkEntry, policyPath, ...args] = process.argv.slice(2);
|
|
14
|
+
if (!sdkEntry || !policyPath || !isAbsolute(sdkEntry) || !isAbsolute(policyPath)) throw new Error("Missing trusted runtime paths.");
|
|
15
|
+
const policy = JSON.parse(readFileSync(policyPath, "utf8"));
|
|
16
|
+
if (typeof policy.agentDir !== "string" || !isAbsolute(policy.agentDir)) throw new Error("Invalid runtime agent directory.");
|
|
17
|
+
process.env.PI_CODING_AGENT_DIR = policy.agentDir;
|
|
18
|
+
const sdkRequire = createRequire(sdkEntry);
|
|
19
|
+
const { version } = JSON.parse(readFileSync(join(dirname(sdkEntry), "..", "package.json"), "utf8"));
|
|
20
|
+
const [major, minor, patch] = version.split(".").map(Number);
|
|
21
|
+
if (!(major > 0 || minor > 82 || (minor === 82 && patch >= 1))) throw new Error("Task confinement requires Pi SDK 0.82.1 or newer.");
|
|
22
|
+
const { createJiti } = await import(pathToFileURL(sdkRequire.resolve("jiti")).href);
|
|
23
|
+
const load = createJiti(import.meta.url, {
|
|
24
|
+
tryNative: false, fsCache: false, moduleCache: false, interopDefault: true,
|
|
25
|
+
alias: { "@earendil-works/pi-coding-agent": sdkEntry },
|
|
26
|
+
});
|
|
27
|
+
// Import the complete guard before invoking Pi. Unlike an ordinary CLI -e
|
|
28
|
+
// extension, an import error here cannot be downgraded to a startup warning.
|
|
29
|
+
const guard = await load.import(fileURLToPath(new URL("./task-guard.ts", import.meta.url)), { default: true });
|
|
30
|
+
if (typeof guard !== "function") throw new Error("Task guard has no extension factory.");
|
|
31
|
+
const { main } = await import(pathToFileURL(join(dirname(sdkEntry), "main.js")).href);
|
|
32
|
+
await main(args, {
|
|
33
|
+
extensionFactories: [{ name: "task-sandbox", factory: async (pi) => {
|
|
34
|
+
try { await guard(pi, policy, fail); } catch (error) { fail(error); }
|
|
35
|
+
} }],
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
fail(error);
|
|
39
|
+
}
|