pi-better-subagents 0.3.0 → 0.4.1
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 +1 -1
- package/index.ts +34 -33
- package/package.json +5 -5
- package/permission-policy.ts +2 -4
- package/registry.ts +17 -3
- package/shared-sandbox-core.ts +161 -19
- package/shared-task-files.ts +246 -0
- package/shared-task-sandbox.ts +216 -0
- package/task-guard.ts +47 -0
- package/task-policy.ts +97 -0
- package/task-runtime.mjs +39 -0
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Try it for one run:
|
|
|
29
29
|
pi -e npm:pi-better-subagents
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Linux
|
|
32
|
+
Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or newer. `/sandbox` controls the independent Subagents profile, which each launch freezes. Pi handles its own startup, authentication, and provider connection; task tools obey the selected file, command, and network permissions. Outside project defaults to Read. Currently confined children admit `read`, `write`, `edit`, and `bash`; unsupported requested tools are reported as unavailable. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox) for the runtime boundary and supported configurations.
|
|
33
33
|
|
|
34
34
|
## When To Use
|
|
35
35
|
|
package/index.ts
CHANGED
|
@@ -37,12 +37,15 @@ import { parseRun, readRunTranscript, resetParseRunCursor, type Usage } from "./
|
|
|
37
37
|
import { finalizeRun as finalizeRunCore } from "./finalization.ts";
|
|
38
38
|
import { loadConfig, normalizeTools, resolveExtensionPath, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
|
|
39
39
|
import { resolveExtensions, extensionArgs } from "./extensions.ts";
|
|
40
|
-
import {
|
|
40
|
+
import { prepareTaskRuntime } from "./task-policy.ts";
|
|
41
|
+
import { canonicalizePath } from "./shared-sandbox-core.ts";
|
|
42
|
+
import { TASK_BUILTINS } from "./shared-task-sandbox.ts";
|
|
41
43
|
import { observeSandboxPermissions, resolveSubagentPermissions } from "./permission-policy.ts";
|
|
42
44
|
import { resolveSubagentWorkspace } from "./git-workspace.ts";
|
|
43
|
-
import {
|
|
44
|
-
import { isAbsolute, join, relative } from "node:path";
|
|
45
|
+
import { join } from "node:path";
|
|
45
46
|
import {
|
|
47
|
+
baseDir,
|
|
48
|
+
taskWorkspaceDir,
|
|
46
49
|
sessionsDir,
|
|
47
50
|
runDir,
|
|
48
51
|
logPathFor,
|
|
@@ -1344,18 +1347,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
1344
1347
|
// Sandbox is ON by default. sandbox_dir moves the confinement + working
|
|
1345
1348
|
// dir elsewhere. git_clone_workspace prepares a disposable clone with
|
|
1346
1349
|
// .git/ inside the writable root for Git-mutating sandboxed subagents.
|
|
1347
|
-
const explicitSandbox = p.sandbox === true || typeof p.sandbox_dir === "string" || p.git_clone_workspace === true || permissionPlan.enforced;
|
|
1348
1350
|
const sandboxEnabled = permissionPlan.sandboxEnabled;
|
|
1349
1351
|
|
|
1350
1352
|
const id = nextRunId();
|
|
1351
|
-
const childSessionDir =
|
|
1353
|
+
const childSessionDir = sandboxEnabled ? join(sessionsDir(), id) : sessionsDir();
|
|
1352
1354
|
mkdirSync(childSessionDir, { recursive: true });
|
|
1353
1355
|
mkdirSync(runDir(id), { recursive: true });
|
|
1354
1356
|
|
|
1355
1357
|
const workspace = resolveSubagentWorkspace({
|
|
1356
1358
|
ctxCwd: ctx.cwd,
|
|
1357
1359
|
cwd: p.cwd,
|
|
1358
|
-
sandboxDir: p.sandbox_dir,
|
|
1360
|
+
sandboxDir: p.sandbox_dir ?? (sandboxEnabled && p.git_clone_workspace ? taskWorkspaceDir(id) : undefined),
|
|
1359
1361
|
gitCloneWorkspace: p.git_clone_workspace,
|
|
1360
1362
|
runId: id,
|
|
1361
1363
|
runDirPath: runDir(id),
|
|
@@ -1379,9 +1381,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1379
1381
|
}
|
|
1380
1382
|
|
|
1381
1383
|
const resolution = resolveExtensions({
|
|
1382
|
-
tools: allow,
|
|
1384
|
+
tools: sandboxEnabled ? allow.split(",").filter((name) => (TASK_BUILTINS as readonly string[]).includes(name)).join(",") : allow,
|
|
1385
|
+
model, clean, allowNested: sandboxEnabled ? false : p.allow_nested, config: cfg,
|
|
1383
1386
|
});
|
|
1384
|
-
const { args:
|
|
1387
|
+
const { args: resolvedExtArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
|
|
1388
|
+
const extArgs = resolvedExtArgs.map((value, index) => sandboxEnabled && resolvedExtArgs[index - 1] === "--extension" ? canonicalizePath(value) : value);
|
|
1389
|
+
if (sandboxEnabled && resolution.mode === "inherit") {
|
|
1390
|
+
throw new Error("Task confinement requires explicit extensions; inheritExtensions is unsupported while sandboxing is enabled.");
|
|
1391
|
+
}
|
|
1385
1392
|
if (missing.length) {
|
|
1386
1393
|
throw new Error(
|
|
1387
1394
|
`Subagent needs extension(s) that are not installed: ${missing.join(", ")}. ` +
|
|
@@ -1402,33 +1409,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
1402
1409
|
...extArgs,
|
|
1403
1410
|
...(model ? ["--model", model] : []),
|
|
1404
1411
|
...(thinking ? ["--thinking", thinking] : []),
|
|
1405
|
-
...(allow ? ["--tools", allow] : []),
|
|
1412
|
+
...(allow && !sandboxEnabled ? ["--tools", allow] : []),
|
|
1406
1413
|
...(excludes.size ? ["--exclude-tools", [...excludes].join(",")] : []),
|
|
1407
|
-
...(p.approve ? ["--approve"] : []),
|
|
1414
|
+
...(sandboxEnabled ? ["--no-builtin-tools", "--no-approve"] : p.approve ? ["--approve"] : []),
|
|
1408
1415
|
p.prompt,
|
|
1409
1416
|
];
|
|
1410
1417
|
|
|
1411
1418
|
const piBin = resolvePiBinary();
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
writableDir: requestedSandboxDir, home: homedir(), piBin, piArgs: args,
|
|
1427
|
-
...(permissionPlan.permissions ? { permissions: permissionPlan.permissions, denyWrite, runtimeDir: childSessionDir } : {}),
|
|
1428
|
-
}, { sandboxEnabled, explicitSandbox })
|
|
1429
|
-
: undefined;
|
|
1430
|
-
const cmd = sandboxCommand ?? { file: piBin, fileArgs: args };
|
|
1431
|
-
const sandboxDir = sandboxCommand ? requestedSandboxDir : undefined;
|
|
1419
|
+
const selectedTools = allow.split(",").filter((name) => name && !excludes.has(name));
|
|
1420
|
+
const unavailableTools = sandboxEnabled ? selectedTools.filter((name) => !(TASK_BUILTINS as readonly string[]).includes(name)) : [];
|
|
1421
|
+
if (sandboxEnabled && selectedTools.length && unavailableTools.length === selectedTools.length) {
|
|
1422
|
+
throw new Error(`No requested tool has a verified task sandbox adapter: ${unavailableTools.join(", ")}.`);
|
|
1423
|
+
}
|
|
1424
|
+
const taskRuntime = sandboxEnabled && requestedSandboxDir ? prepareTaskRuntime({
|
|
1425
|
+
root: requestedSandboxDir, controlDir: join(runDir(id), "control"), piBin,
|
|
1426
|
+
tools: selectedTools, permissions: permissionPlan.permissions,
|
|
1427
|
+
extensionPaths: extArgs.flatMap((arg, index) => arg === "--extension" && extArgs[index + 1] ? [extArgs[index + 1]!] : []),
|
|
1428
|
+
runtimeRoots: [baseDir()],
|
|
1429
|
+
}) : undefined;
|
|
1430
|
+
if (sandboxEnabled && !taskRuntime) throw new Error("Task sandbox has no workspace; refusing an unconfined child.");
|
|
1431
|
+
const cmd = taskRuntime ? { file: taskRuntime.file, fileArgs: [...taskRuntime.fileArgs, ...args] } : { file: piBin, fileArgs: args };
|
|
1432
|
+
const sandboxDir = taskRuntime ? requestedSandboxDir : undefined;
|
|
1432
1433
|
|
|
1433
1434
|
const spawned = spawnDetached({ file: cmd.file, fileArgs: cmd.fileArgs, cwd, logPath: logPathFor(id) });
|
|
1434
1435
|
// Record process identity (pgid, start-time token) so health
|
|
@@ -1446,7 +1447,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1446
1447
|
promptPreview: p.prompt.slice(0, 200),
|
|
1447
1448
|
startedAt: Date.now(), logPath: logPathFor(id), sessionId: id,
|
|
1448
1449
|
callbackOrigin,
|
|
1449
|
-
sandbox: sandboxDir, callback: p.callback !== false,
|
|
1450
|
+
sandbox: sandboxDir, taskRuntime: Boolean(taskRuntime), taskScratch: taskRuntime?.policy.scratch, callback: p.callback !== false,
|
|
1450
1451
|
...batchInfo,
|
|
1451
1452
|
// The launch record is JSON. Registry freezes that value; it does not
|
|
1452
1453
|
// require the resolver's nominal type to carry an index signature.
|
|
@@ -1468,11 +1469,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1468
1469
|
: resolution.specs.length
|
|
1469
1470
|
? `Runtime: isolated · extensions ${resolution.specs.join(", ")}\n`
|
|
1470
1471
|
: `Runtime: isolated · built-in tools only\n`;
|
|
1471
|
-
const warn = resolution.unmapped.length
|
|
1472
|
+
const warn = (unavailableTools.length ? `Task sandbox: unavailable adapters for ${unavailableTools.join(", ")}; these tools are disabled.\n` : "") + (resolution.unmapped.length
|
|
1472
1473
|
? `NOTE: no extension mapped for ${resolution.unmapped.join(", ")} — ` +
|
|
1473
1474
|
`${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
|
|
1474
1475
|
`Add a toolExtensions entry in config.json.\n`
|
|
1475
|
-
: "";
|
|
1476
|
+
: "");
|
|
1476
1477
|
return { id, meta, spawned, runtime, warn, sandboxDir };
|
|
1477
1478
|
}
|
|
1478
1479
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"pretest": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
34
|
-
"prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
35
|
-
"typecheck": "
|
|
33
|
+
"pretest": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
34
|
+
"prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.task-runtime.json",
|
|
36
36
|
"test": "node --import tsx --test tests/*.test.mjs",
|
|
37
37
|
"test:cross-session": "node --import tsx --test --test-name-pattern \"callback session isolation\" tests/extension_health_lifecycle.test.mjs",
|
|
38
38
|
"pretest:macos-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
],
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@earendil-works/pi-ai": "*",
|
|
59
|
-
"@earendil-works/pi-coding-agent": "
|
|
59
|
+
"@earendil-works/pi-coding-agent": ">=0.82.1",
|
|
60
60
|
"@earendil-works/pi-tui": "*"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
package/permission-policy.ts
CHANGED
|
@@ -97,10 +97,8 @@ export function resolveSubagentPermissions(pi: unknown, requestedSandbox: boolea
|
|
|
97
97
|
}
|
|
98
98
|
const sandboxEnabled = child.enabled || requestedSandbox === true;
|
|
99
99
|
if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false };
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
throw new Error("Subagents profile disables network, including model requests. Child provider isolation is not available; enable network in the sandbox UI before launching a subagent.");
|
|
103
|
-
}
|
|
100
|
+
// Starting Pi is runtime work. These capabilities constrain task tools;
|
|
101
|
+
// they do not disable provider transport or the fixed file-operation worker.
|
|
104
102
|
const { enabled: _enabled, ...permissions } = child;
|
|
105
103
|
return { sandboxEnabled: true, enforced: true, permissions };
|
|
106
104
|
}
|
package/registry.ts
CHANGED
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
-
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
21
|
import { processExists } from "./spawn.ts";
|
|
22
22
|
import type { LifecycleClassification } from "./lifecycle.ts";
|
|
23
23
|
|
|
@@ -149,6 +149,9 @@ export interface RunMeta {
|
|
|
149
149
|
lostCallbackSuppressedReason?: string;
|
|
150
150
|
/** Writable dir the child is OS-sandboxed to, if any. */
|
|
151
151
|
sandbox?: string;
|
|
152
|
+
/** Pi is trusted; model task operations use an immutable kernel policy. */
|
|
153
|
+
taskRuntime?: boolean;
|
|
154
|
+
taskScratch?: string;
|
|
152
155
|
/** Whether completion posts the result back to the main session (default true). */
|
|
153
156
|
callback?: boolean;
|
|
154
157
|
/** Batch ID for runs launched via subagent_spawn_batch. */
|
|
@@ -176,11 +179,16 @@ export interface RunMeta {
|
|
|
176
179
|
|
|
177
180
|
/** Root runtime dir, deliberately OUTSIDE any repo. */
|
|
178
181
|
export function baseDir(): string {
|
|
179
|
-
return join(tmpdir(), "pi-better-subagents");
|
|
182
|
+
return join(realpathSync(tmpdir()), "pi-better-subagents");
|
|
180
183
|
}
|
|
181
184
|
export function sessionsDir(): string {
|
|
182
185
|
return join(baseDir(), "sessions");
|
|
183
186
|
}
|
|
187
|
+
export function taskWorkspaceDir(id: string): string {
|
|
188
|
+
if (!/^sa_[a-z0-9]+_[a-z0-9]+$/i.test(id)) throw new Error("Invalid task workspace run ID.");
|
|
189
|
+
return join(realpathSync(tmpdir()), "pi-better-subagent-workspaces", id);
|
|
190
|
+
}
|
|
191
|
+
|
|
184
192
|
export function runDir(id: string): string {
|
|
185
193
|
return join(baseDir(), "runs", id);
|
|
186
194
|
}
|
|
@@ -837,6 +845,12 @@ export function readMeta(id: string): RunMeta | undefined {
|
|
|
837
845
|
export function removeMetaArtifacts(meta: RunMeta): boolean {
|
|
838
846
|
try {
|
|
839
847
|
rmSync(runDir(meta.id), { recursive: true, force: true });
|
|
848
|
+
if (meta.taskRuntime && /^sa_[a-z0-9]+_[a-z0-9]+$/i.test(meta.id) && meta.cwd === taskWorkspaceDir(meta.id)) {
|
|
849
|
+
rmSync(taskWorkspaceDir(meta.id), { recursive: true, force: true });
|
|
850
|
+
}
|
|
851
|
+
if (meta.taskRuntime && meta.taskScratch && dirname(meta.taskScratch) === realpathSync(tmpdir()) && /^pi-task-scratch-[a-z0-9]{6}$/i.test(basename(meta.taskScratch))) {
|
|
852
|
+
rmSync(meta.taskScratch, { recursive: true, force: true });
|
|
853
|
+
}
|
|
840
854
|
metaCache.delete(meta.id);
|
|
841
855
|
removeIndexEntry(join(baseDir(), "by-parent", String(meta.spawnPid)), meta.id);
|
|
842
856
|
removeIndexEntry(join(baseDir(), "by-parent-active", String(meta.spawnPid)), meta.id);
|
package/shared-sandbox-core.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* `SandboxSeams` argument so callers can plan deterministically in tests.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
21
22
|
import { platform as osPlatform } from "node:os";
|
|
22
23
|
import {
|
|
23
24
|
accessSync,
|
|
@@ -67,6 +68,8 @@ export type SandboxWritePolicy = {
|
|
|
67
68
|
permissions?: SandboxPermissions;
|
|
68
69
|
/** Trusted per-launch runtime state, never supplied by model tool arguments. */
|
|
69
70
|
runtimeWrite?: readonly string[];
|
|
71
|
+
/** Bounded historical default runtime directories; inactive without a capability profile. */
|
|
72
|
+
runtimeCompatibility?: boolean;
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
/** The executable and argv to run inside the sandbox, preserved verbatim. */
|
|
@@ -79,6 +82,8 @@ export type SandboxCommandArgs = SandboxTarget & {
|
|
|
79
82
|
/** Where the macOS backend writes its generated SBPL profile. */
|
|
80
83
|
profilePath: string;
|
|
81
84
|
policy: SandboxWritePolicy;
|
|
85
|
+
/** Fixed internal helper only: expose its executable in a hidden Linux root. */
|
|
86
|
+
internalHelperExecutable?: boolean;
|
|
82
87
|
};
|
|
83
88
|
|
|
84
89
|
/** The wrapper command to spawn: the backend executable and its full argv. */
|
|
@@ -114,6 +119,8 @@ export type SandboxSeams = {
|
|
|
114
119
|
* host running them.
|
|
115
120
|
*/
|
|
116
121
|
materializeDenyPath?: (path: string) => boolean;
|
|
122
|
+
/** Defaults to /usr/bin/getconf with a minimal environment (macOS only). */
|
|
123
|
+
getconf?: (name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR") => string | undefined;
|
|
117
124
|
};
|
|
118
125
|
|
|
119
126
|
/** A policy with every path canonicalized, deduplicated, and ordered. */
|
|
@@ -124,6 +131,7 @@ export type CompiledSandboxWritePolicy = {
|
|
|
124
131
|
readonly permissions?: SandboxPermissions;
|
|
125
132
|
readonly credentialPaths?: readonly string[];
|
|
126
133
|
readonly runtimeWrite?: readonly string[];
|
|
134
|
+
readonly compatibilityWrite?: readonly string[];
|
|
127
135
|
};
|
|
128
136
|
|
|
129
137
|
/** Why a write target is or is not permitted by a compiled policy. */
|
|
@@ -174,6 +182,46 @@ const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Libra
|
|
|
174
182
|
const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
|
|
175
183
|
const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
|
|
176
184
|
|
|
185
|
+
function systemGetconf(name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR"): string | undefined {
|
|
186
|
+
const result = spawnSync("/usr/bin/getconf", [name], {
|
|
187
|
+
encoding: "utf8", env: { PATH: "/usr/bin:/bin", LANG: "C" }, timeout: 3000, maxBuffer: 4096,
|
|
188
|
+
});
|
|
189
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function compatibilityPaths(policy: SandboxWritePolicy, seams: SandboxSeams): string[] {
|
|
193
|
+
if (!policy.permissions || !policy.runtimeCompatibility || policy.permissions.outsideProject === "off") return [];
|
|
194
|
+
const platform = currentPlatform(seams);
|
|
195
|
+
if (platform === "linux") return [canonicalizePath("/tmp", seams)];
|
|
196
|
+
if (platform !== "darwin") return [];
|
|
197
|
+
const home = canonicalizePath(policy.home, seams);
|
|
198
|
+
const project = canonicalizePath(policy.writableRoot, seams);
|
|
199
|
+
const paths = [canonicalizePath("/tmp", seams)];
|
|
200
|
+
for (const [key, leaf] of [["DARWIN_USER_TEMP_DIR", ""], ["DARWIN_USER_CACHE_DIR", "mds"]] as const) {
|
|
201
|
+
const raw = (seams.getconf ?? systemGetconf)(key);
|
|
202
|
+
if (!raw || !raw.startsWith("/") || raw.includes("\0") ||
|
|
203
|
+
raw.split("/").some((part) => part === "." || part === "..")) {
|
|
204
|
+
throw new Error(`Invalid ${key} runtime directory`);
|
|
205
|
+
}
|
|
206
|
+
// getconf returns /var/folders on macOS; /var is a system alias for /private/var.
|
|
207
|
+
const normalized = resolve(raw.replace(/^\/var\/folders\//, "/private/var/folders/"));
|
|
208
|
+
const expected = /^\/private\/var\/folders\/[^/]+\/[^/]+\/[TC]$/.test(normalized) &&
|
|
209
|
+
normalized.endsWith(key === "DARWIN_USER_TEMP_DIR" ? "/T" : "/C");
|
|
210
|
+
const canonical = canonicalizePath(normalized, seams);
|
|
211
|
+
if (!expected || canonical !== normalized ||
|
|
212
|
+
(contains(home, canonical) || contains(canonical, home) || contains(project, canonical))) {
|
|
213
|
+
throw new Error(`Unsafe ${key} runtime directory`);
|
|
214
|
+
}
|
|
215
|
+
paths.push(leaf ? join(canonical, leaf) : canonical);
|
|
216
|
+
}
|
|
217
|
+
// An existing MDS symlink must not redirect the narrow write allowance.
|
|
218
|
+
const mds = paths[2];
|
|
219
|
+
if (!mds || canonicalizePath(mds, seams) !== mds || contains(mds, project) || contains(project, mds)) {
|
|
220
|
+
throw new Error("Unsafe MDS runtime directory");
|
|
221
|
+
}
|
|
222
|
+
return [...new Set(paths)];
|
|
223
|
+
}
|
|
224
|
+
|
|
177
225
|
/** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
|
|
178
226
|
export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
|
|
179
227
|
const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
|
|
@@ -233,12 +281,17 @@ function compile(
|
|
|
233
281
|
...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
|
|
234
282
|
].sort();
|
|
235
283
|
|
|
284
|
+
const compatibilityWrite = compatibilityPaths(policy, seams);
|
|
236
285
|
return {
|
|
237
286
|
writableRoot, denyWrite, home: policy.home,
|
|
238
287
|
...(policy.permissions && {
|
|
239
288
|
permissions: { ...policy.permissions },
|
|
240
289
|
credentialPaths: credentialFilePaths(policy.home, seams),
|
|
241
|
-
|
|
290
|
+
compatibilityWrite,
|
|
291
|
+
runtimeWrite: [...new Set([
|
|
292
|
+
...(policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
|
|
293
|
+
...compatibilityWrite,
|
|
294
|
+
])],
|
|
242
295
|
}),
|
|
243
296
|
};
|
|
244
297
|
}
|
|
@@ -277,8 +330,9 @@ export function evaluateReadAccess(
|
|
|
277
330
|
const permissions = policy.permissions;
|
|
278
331
|
if (!permissions) return { allowed: true, path };
|
|
279
332
|
const mode = isCredential(path, policy) ? permissions.storedCredentials
|
|
280
|
-
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
333
|
+
: policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
|
|
281
334
|
: contains(policy.writableRoot, path) ? permissions.projectFiles
|
|
335
|
+
: policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
282
336
|
: path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
|
|
283
337
|
: permissions.outsideProject;
|
|
284
338
|
return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
@@ -305,8 +359,9 @@ export function evaluateWriteAccess(
|
|
|
305
359
|
}
|
|
306
360
|
if (policy.permissions) {
|
|
307
361
|
const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
|
|
308
|
-
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
362
|
+
: policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
|
|
309
363
|
: contains(policy.writableRoot, path) ? policy.permissions.projectFiles
|
|
364
|
+
: policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
310
365
|
: contains(canonicalizePath("/dev", seams), path) ? "read-write"
|
|
311
366
|
: policy.permissions.outsideProject;
|
|
312
367
|
if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
|
|
@@ -342,6 +397,7 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
342
397
|
...policy.denyWrite,
|
|
343
398
|
...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
|
|
344
399
|
...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
|
|
400
|
+
...(policy.compatibilityWrite ?? []),
|
|
345
401
|
];
|
|
346
402
|
const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
347
403
|
if (permissions.outsideProject === "off") {
|
|
@@ -365,13 +421,15 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
365
421
|
};
|
|
366
422
|
// Last matching SBPL rule wins. Credential rules override project and outside;
|
|
367
423
|
// explicit denyWrite entries always override every write allowance.
|
|
424
|
+
for (const path of policy.compatibilityWrite ?? []) scoped(path, "read-write");
|
|
368
425
|
scoped(policy.writableRoot, permissions.projectFiles);
|
|
369
|
-
for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
|
|
426
|
+
for (const path of (policy.runtimeWrite ?? []).filter((path) => !policy.compatibilityWrite?.includes(path))) scoped(path, "read-write");
|
|
370
427
|
for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
|
|
371
428
|
for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
|
|
372
429
|
// Protect the directory entries, not their contents: unrelated children can
|
|
373
430
|
// still be created, while renaming a parent cannot move a denied subtree.
|
|
374
431
|
for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
432
|
+
for (const path of policy.compatibilityWrite ?? []) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
375
433
|
if (!permissions.network) rules.push("(deny network*)");
|
|
376
434
|
return [...rules, ""].join("\n");
|
|
377
435
|
}
|
|
@@ -410,7 +468,21 @@ const macOSSandboxBackend: SandboxBackend = {
|
|
|
410
468
|
buildCommand: buildMacOSSandboxCommand,
|
|
411
469
|
};
|
|
412
470
|
|
|
413
|
-
/** Resolve
|
|
471
|
+
/** Resolve only a root-owned system executable; never inspect task PATH. */
|
|
472
|
+
function systemSandboxExecutable(name: string): string | undefined {
|
|
473
|
+
// Never resolve the host-side confinement launcher through task-influenced PATH.
|
|
474
|
+
for (const directory of ["/usr/bin", "/bin"]) {
|
|
475
|
+
const candidate = join(directory, name);
|
|
476
|
+
try {
|
|
477
|
+
const info = statSync(candidate);
|
|
478
|
+
if (!info.isFile() || info.uid !== 0 || (info.mode & 0o022) !== 0) continue;
|
|
479
|
+
accessSync(candidate, constants.X_OK);
|
|
480
|
+
return candidate;
|
|
481
|
+
} catch { /* Try the next system location. */ }
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
414
486
|
export function executableFromPath(name: string): string | undefined {
|
|
415
487
|
const path = process.env.PATH;
|
|
416
488
|
if (!path) return undefined;
|
|
@@ -592,33 +664,103 @@ function buildLinuxPermissionCommand(
|
|
|
592
664
|
if (existsSync(root)) mounts.push("--ro-bind", root, root);
|
|
593
665
|
}
|
|
594
666
|
mounts.push("--tmpfs", "/tmp");
|
|
667
|
+
if (args.internalHelperExecutable) {
|
|
668
|
+
const executable = canonicalizePath(args.execPath, seams);
|
|
669
|
+
if (!RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), executable))) {
|
|
670
|
+
mounts.push("--ro-bind", executable, executable);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
595
673
|
} else {
|
|
596
|
-
mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
674
|
+
mounts.push(permissions.outsideProject === "read" && !policy.compatibilityWrite?.includes(canonicalizePath("/tmp", seams)) ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
597
675
|
}
|
|
598
676
|
mounts.push("--dev", "/dev");
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
677
|
+
const scopedMounts: { option: "--bind" | "--ro-bind"; path: string }[] = [];
|
|
678
|
+
const writableAncestors = new Set<string>();
|
|
679
|
+
const readOnlyGuards = new Set<string>();
|
|
602
680
|
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
|
|
603
681
|
for (const path of credentials) {
|
|
604
|
-
if (existsSync(path) && !contains(project, path))
|
|
682
|
+
if (existsSync(path) && !contains(project, path)) readOnlyGuards.add(path);
|
|
605
683
|
}
|
|
606
684
|
}
|
|
685
|
+
// A broad /tmp bind is already present for visible outside roots. Cover
|
|
686
|
+
// existing leaves and absent leaves via a read-only existing ancestor; do
|
|
687
|
+
// not create credential or control files in host runtime directories.
|
|
688
|
+
const compatibility = policy.compatibilityWrite ?? [];
|
|
689
|
+
if (compatibility.length) {
|
|
690
|
+
const tmp = canonicalizePath("/tmp", seams);
|
|
691
|
+
const protectedInTmp = [...policy.denyWrite, ...(permissions.storedCredentials !== "read-write" ? credentials : [])]
|
|
692
|
+
.filter((path) => contains(tmp, path) && !contains(project, path));
|
|
693
|
+
if (permissions.storedCredentials === "off" && protectedInTmp.some((path) =>
|
|
694
|
+
credentials.some((credential) => credential === path))) {
|
|
695
|
+
throw new Error("Linux bubblewrap cannot hide credentials under a writable runtime directory.");
|
|
696
|
+
}
|
|
697
|
+
if (policy.denyWrite.some((path) => contains(path, tmp))) {
|
|
698
|
+
throw new Error("Runtime directory overlaps a write-denied control path.");
|
|
699
|
+
}
|
|
700
|
+
const guards = new Set<string>();
|
|
701
|
+
for (const path of protectedInTmp) {
|
|
702
|
+
let guard = path;
|
|
703
|
+
while (!existsSync(guard) && contains(tmp, guard) && guard !== tmp) guard = dirname(guard);
|
|
704
|
+
if (guard === tmp || !contains(tmp, guard)) {
|
|
705
|
+
throw new Error("Cannot protect an absent path directly under writable runtime directory.");
|
|
706
|
+
}
|
|
707
|
+
guards.add(guard);
|
|
708
|
+
}
|
|
709
|
+
for (const guard of [...guards].sort((a, b) => a.length - b.length)) {
|
|
710
|
+
if ([...guards].some((parent) => parent !== guard && contains(parent, guard))) continue;
|
|
711
|
+
for (const parent of protectedAncestors([guard]).filter((parent) => contains(tmp, parent) && parent !== tmp)) {
|
|
712
|
+
writableAncestors.add(parent);
|
|
713
|
+
}
|
|
714
|
+
readOnlyGuards.add(guard);
|
|
715
|
+
}
|
|
716
|
+
// A writable /tmp can rename any directory above the captured project
|
|
717
|
+
// root, then substitute a symlink before the next launch. Anchor each
|
|
718
|
+
// ancestor even when there are no explicit control or credential guards.
|
|
719
|
+
if (permissions.projectFiles !== "off" && contains(tmp, project)) {
|
|
720
|
+
for (const parent of protectedAncestors([project]).filter((path) => contains(tmp, path) && path !== tmp)) {
|
|
721
|
+
writableAncestors.add(parent);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (permissions.projectFiles !== "off") {
|
|
726
|
+
scopedMounts.push({ option: writableProject ? "--bind" : "--ro-bind", path: project });
|
|
727
|
+
}
|
|
607
728
|
for (const path of policy.runtimeWrite ?? []) {
|
|
608
|
-
if (
|
|
729
|
+
if (compatibility.includes(path)) continue;
|
|
730
|
+
if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
|
|
731
|
+
policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
|
|
609
732
|
throw new Error("Runtime directory overlaps protected credentials or control paths.");
|
|
610
733
|
}
|
|
611
|
-
|
|
734
|
+
scopedMounts.push({ option: "--bind", path });
|
|
612
735
|
}
|
|
613
736
|
const protectedPaths = [...policy.denyWrite,
|
|
614
737
|
...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
|
|
615
|
-
|
|
738
|
+
for (const writableRoot of [...(writableProject ? [project] : []),
|
|
739
|
+
...(policy.runtimeWrite ?? []).filter((path) => !compatibility.includes(path))]) {
|
|
616
740
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
617
|
-
const leaves = protectedPaths.filter((path) => contains(
|
|
618
|
-
for (const parent of protectedAncestors(leaves).filter((path) => contains(
|
|
619
|
-
|
|
741
|
+
const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
|
|
742
|
+
for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
|
|
743
|
+
writableAncestors.add(parent);
|
|
620
744
|
}
|
|
621
|
-
for (const path of leaves)
|
|
745
|
+
for (const path of leaves) readOnlyGuards.add(path);
|
|
746
|
+
}
|
|
747
|
+
// A nearest-existing guard can be an ancestor of an unrelated writable
|
|
748
|
+
// project (e.g. absent ~/.npmrc with a project under ~/work). Mount that
|
|
749
|
+
// broad guard in path order, keeping its intervening anchors read-only;
|
|
750
|
+
// only the disjoint project subtree may be rebound writable afterwards.
|
|
751
|
+
const ancestorGuards = [...readOnlyGuards].filter((guard) =>
|
|
752
|
+
scopedMounts.some(({ option, path }) => option === "--bind" && guard !== path && contains(guard, path)));
|
|
753
|
+
for (const guard of ancestorGuards) {
|
|
754
|
+
scopedMounts.push({ option: "--ro-bind", path: guard });
|
|
755
|
+
readOnlyGuards.delete(guard);
|
|
756
|
+
}
|
|
757
|
+
for (const path of writableAncestors) scopedMounts.push({
|
|
758
|
+
option: ancestorGuards.some((guard) => contains(guard, path)) ? "--ro-bind" : "--bind", path,
|
|
759
|
+
});
|
|
760
|
+
scopedMounts.sort((a, b) => a.path.length - b.path.length);
|
|
761
|
+
for (const { option, path } of scopedMounts) mounts.push(option, path, path);
|
|
762
|
+
for (const path of [...readOnlyGuards].sort((a, b) => a.length - b.length)) {
|
|
763
|
+
mounts.push("--ro-bind", path, path);
|
|
622
764
|
}
|
|
623
765
|
return {
|
|
624
766
|
file: bwrap,
|
|
@@ -628,7 +770,7 @@ function buildLinuxPermissionCommand(
|
|
|
628
770
|
}
|
|
629
771
|
|
|
630
772
|
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
631
|
-
const bwrap = (seams.lookupExecutable ??
|
|
773
|
+
const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
|
|
632
774
|
if (!bwrap) return undefined;
|
|
633
775
|
return {
|
|
634
776
|
id: "linux-bubblewrap",
|
|
@@ -651,7 +793,7 @@ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined
|
|
|
651
793
|
*/
|
|
652
794
|
function unavailableMessage(platform: string): string {
|
|
653
795
|
if (platform === "linux") {
|
|
654
|
-
return "Linux sandbox requires executable bubblewrap (bwrap)
|
|
796
|
+
return "Linux sandbox requires executable bubblewrap (bwrap) in /usr/bin or /bin. Install bubblewrap to enable it.";
|
|
655
797
|
}
|
|
656
798
|
if (platform === "darwin") {
|
|
657
799
|
return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";
|
|
@@ -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,216 @@
|
|
|
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, readlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
12
|
+
import { canonicalizePath, compileWritePolicy, maybeBuildSandboxCommand, type SandboxPermissions } 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
|
+
}, runtimeCompatibility = false): string | undefined {
|
|
31
|
+
const compatibility = runtimeCompatibility ? compileWritePolicy({ writableRoot: root, home: homedir(),
|
|
32
|
+
permissions: { ...permissions, commands: true, network: true } as SandboxPermissions,
|
|
33
|
+
runtimeCompatibility: true,
|
|
34
|
+
}).compatibilityWrite ?? [] : [];
|
|
35
|
+
const absolute = resolve(path);
|
|
36
|
+
let current = parse(absolute).root;
|
|
37
|
+
let pending = absolute.slice(current.length).split(sep);
|
|
38
|
+
let links = 0;
|
|
39
|
+
while (pending.length) {
|
|
40
|
+
const component = pending.shift()!;
|
|
41
|
+
if (!component || component === ".") continue;
|
|
42
|
+
if (component === "..") { current = dirname(current); continue; }
|
|
43
|
+
const entry = join(current, component);
|
|
44
|
+
let stat;
|
|
45
|
+
try { stat = lstatSync(entry); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
if (!stat.isSymbolicLink()) { current = entry; continue; }
|
|
51
|
+
if (++links > 40) throw new Error("Too many symlinks in Pi runtime path");
|
|
52
|
+
let replaceable = false;
|
|
53
|
+
try { accessSync(current, constants.W_OK); replaceable = true; }
|
|
54
|
+
catch { /* An OS-protected entry is immutable; its target still needs inspection. */ }
|
|
55
|
+
const inProject = entry === root || entry.startsWith(root + sep);
|
|
56
|
+
const access = inProject ? permissions.projectFiles
|
|
57
|
+
: compatibility.some((directory) => entry === directory || entry.startsWith(directory + sep)) ? "read-write"
|
|
58
|
+
: permissions.outsideProject;
|
|
59
|
+
if (replaceable && (access === "read-write" || permissions.storedCredentials === "read-write")) return entry;
|
|
60
|
+
// Resolve targets component-by-component: realpath would erase the
|
|
61
|
+
// intermediate links whose directory entries need protection.
|
|
62
|
+
const target = readlinkSync(entry);
|
|
63
|
+
if (isAbsolute(target)) {
|
|
64
|
+
current = parse(target).root;
|
|
65
|
+
pending = [...target.slice(current.length).split(sep), ...pending];
|
|
66
|
+
} else pending = [...target.split(sep), ...pending];
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function harnessRuntimeDirectories(): string[] {
|
|
72
|
+
const names = ["pi-better-subagents", "pi-better-background-tasks"];
|
|
73
|
+
const pool = process.env.VITEST_POOL_ID;
|
|
74
|
+
if (pool && /^\d+$/.test(pool)) names.push(`pi-better-background-tasks-vitest-${pool}`);
|
|
75
|
+
return names.map((name) => canonicalizePath(join(tmpdir(), name)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ensureHarnessRuntimeDirectories(): string[] {
|
|
79
|
+
const directories = harnessRuntimeDirectories();
|
|
80
|
+
for (const path of directories) mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
81
|
+
return directories;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createTaskScratch(): { path: string; anchor: string } {
|
|
85
|
+
const path = canonicalizePath(mkdtempSync(join(tmpdir(), "pi-task-scratch-")));
|
|
86
|
+
const anchor = join(path, ".sandbox-anchor");
|
|
87
|
+
writeFileSync(anchor, "", { flag: "wx", mode: 0o400 });
|
|
88
|
+
// Denying the anchor also prevents renaming/replacing its parent directory.
|
|
89
|
+
return { path, anchor };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createTaskBashOperations(
|
|
93
|
+
controller: TaskFileController,
|
|
94
|
+
shellPath: () => string | undefined = () => undefined,
|
|
95
|
+
): BashOperations {
|
|
96
|
+
return {
|
|
97
|
+
async exec(command, cwd, options) {
|
|
98
|
+
const configuredShell = shellPath();
|
|
99
|
+
const local = createLocalBashOperations(configuredShell ? { shellPath: configuredShell } : {});
|
|
100
|
+
const plan = controller.requireLaunchPlan();
|
|
101
|
+
if (!plan.confined) return local.exec(command, cwd, options);
|
|
102
|
+
if (plan.policy.permissions?.commands === false) throw new Error("Sandbox: Run commands & applications is Off.");
|
|
103
|
+
// An explicit path avoids SDK fallback spawning PATH-resolved `which`.
|
|
104
|
+
const shell = getShellConfig(configuredShell ?? "/bin/bash");
|
|
105
|
+
const sdkUtils = join(PiCodingAgent.getPackageDir(), "dist", "utils");
|
|
106
|
+
const { waitForChildProcess } = await import(pathToFileURL(join(sdkUtils, "child-process.js")).href);
|
|
107
|
+
const { trackDetachedChildPid, untrackDetachedChildPid } = await import(pathToFileURL(join(sdkUtils, "shell.js")).href);
|
|
108
|
+
if (shell.commandTransport === "stdin") throw new Error("Sandbox: this shell cannot be confined by the available backend.");
|
|
109
|
+
const scratch = plan.policy.runtimeWrite?.[0];
|
|
110
|
+
const taskEnv = { ...process.env, ...options.env,
|
|
111
|
+
...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
|
|
112
|
+
const wrapped = maybeBuildSandboxCommand({
|
|
113
|
+
policy: plan.policy, profilePath: plan.profilePath,
|
|
114
|
+
execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
|
|
115
|
+
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
116
|
+
.map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
|
|
117
|
+
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
118
|
+
if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
|
|
119
|
+
if (options.signal?.aborted) throw new Error("aborted");
|
|
120
|
+
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
|
|
121
|
+
throw new Error("Invalid timeout: must be a positive, supported number of seconds");
|
|
122
|
+
}
|
|
123
|
+
// No shell or caller-controlled loader environment runs before the boundary.
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
|
|
126
|
+
env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
|
|
127
|
+
let timedOut = false;
|
|
128
|
+
const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
|
|
129
|
+
const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
|
|
130
|
+
if (child.pid) trackDetachedChildPid(child.pid);
|
|
131
|
+
const cleanup = () => {
|
|
132
|
+
if (child.pid) untrackDetachedChildPid(child.pid);
|
|
133
|
+
if (timer) clearTimeout(timer);
|
|
134
|
+
options.signal?.removeEventListener("abort", kill);
|
|
135
|
+
};
|
|
136
|
+
child.stdout.on("data", options.onData);
|
|
137
|
+
child.stderr.on("data", options.onData);
|
|
138
|
+
options.signal?.addEventListener("abort", kill, { once: true });
|
|
139
|
+
if (options.signal?.aborted) kill();
|
|
140
|
+
void waitForChildProcess(child).then((exitCode: number | null) => {
|
|
141
|
+
cleanup();
|
|
142
|
+
if (options.signal?.aborted) reject(new Error("aborted"));
|
|
143
|
+
else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
|
|
144
|
+
else resolve({ exitCode });
|
|
145
|
+
}, (error: unknown) => { cleanup(); reject(error); });
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Only definitions installed here are admitted as builtins. A distinct schema
|
|
153
|
+
* identity detects later replacement, including overrides using SDK factories.
|
|
154
|
+
* Other tool implementations need an explicit, host-owned admission function;
|
|
155
|
+
* knowing a tool's name or enabling network access never makes it confined.
|
|
156
|
+
*/
|
|
157
|
+
export function installTaskTools(pi: ExtensionAPI, options: {
|
|
158
|
+
controller: TaskFileController;
|
|
159
|
+
cwd: string;
|
|
160
|
+
shellPath?: () => string | undefined;
|
|
161
|
+
trustedSources: readonly string[];
|
|
162
|
+
admitExtensionTool?: (name: string, input: unknown, sourcePath: string | undefined) => boolean;
|
|
163
|
+
}) {
|
|
164
|
+
const { controller } = options;
|
|
165
|
+
const sourceKey = (path: string) => path.startsWith("<") ? path : canonicalizePath(path);
|
|
166
|
+
const trustedSources = new Set(options.trustedSources.map(sourceKey));
|
|
167
|
+
const trustedSource = (path: string | undefined) => path !== undefined && trustedSources.has(sourceKey(path));
|
|
168
|
+
const files = createTaskFileOperations(controller);
|
|
169
|
+
const bash = createTaskBashOperations(controller, options.shellPath);
|
|
170
|
+
const schemas = new Map<string, unknown>();
|
|
171
|
+
let currentCwd: string | undefined;
|
|
172
|
+
const register = (cwd: string) => {
|
|
173
|
+
if (cwd === currentCwd) return;
|
|
174
|
+
currentCwd = cwd;
|
|
175
|
+
const own = <T extends { name: string; parameters: object }>(definition: T): T => {
|
|
176
|
+
const parameters = { ...definition.parameters };
|
|
177
|
+
schemas.set(definition.name, parameters);
|
|
178
|
+
return { ...definition, parameters };
|
|
179
|
+
};
|
|
180
|
+
pi.registerTool(own(createReadToolDefinition(cwd, { operations: files.read })));
|
|
181
|
+
pi.registerTool(own(createWriteToolDefinition(cwd, { operations: files.write })));
|
|
182
|
+
pi.registerTool(own(createEditToolDefinition(cwd, { operations: files.edit })));
|
|
183
|
+
pi.registerTool(own(createBashToolDefinition(cwd, { operations: bash })));
|
|
184
|
+
};
|
|
185
|
+
register(options.cwd);
|
|
186
|
+
pi.on("user_bash", () => ({ operations: bash }));
|
|
187
|
+
pi.on("tool_call", (event) => {
|
|
188
|
+
try {
|
|
189
|
+
const plan = controller.requireLaunchPlan();
|
|
190
|
+
if (!plan.confined) return;
|
|
191
|
+
const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);
|
|
192
|
+
if (schemas.has(event.toolName)) {
|
|
193
|
+
if (!tool || tool.parameters !== schemas.get(event.toolName) || !trustedSource(tool.sourceInfo?.path)) {
|
|
194
|
+
return { block: true, reason: `Sandbox: ${event.toolName} was replaced by an unverified implementation.` };
|
|
195
|
+
}
|
|
196
|
+
if (event.toolName === "bash" && plan.policy.permissions?.commands === false) {
|
|
197
|
+
return { block: true, reason: "Sandbox: Run commands & applications is Off." };
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (options.admitExtensionTool?.(event.toolName, event.input, tool?.sourceInfo?.path)) return;
|
|
202
|
+
return { block: true, reason: `Sandbox: ${event.toolName} has no verified task execution adapter. Use a guarded file tool or confined bash command.` };
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
return { register, bash, assertInstalled(names: readonly string[]) {
|
|
208
|
+
const inventory = pi.getAllTools();
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
const tool = inventory.find((candidate) => candidate.name === name);
|
|
211
|
+
if (!schemas.has(name) || tool?.parameters !== schemas.get(name) || !trustedSource(tool?.sourceInfo?.path)) {
|
|
212
|
+
throw new Error(`Sandbox: ${name} is missing or was replaced by an unverified implementation.`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} };
|
|
216
|
+
}
|
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
|
+
runtimeCompatibility: true, 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, true)).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, runtimeCompatibility: true });
|
|
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
|
+
}
|