pi-better-sandbox 0.4.0 → 0.5.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 +38 -43
- package/index.ts +15 -55
- package/package.json +5 -5
- package/shared-sandbox-core.ts +30 -7
- package/shared-task-files.ts +246 -0
- package/shared-task-sandbox.ts +189 -0
- package/state.ts +34 -4
package/README.md
CHANGED
|
@@ -41,52 +41,47 @@ OS vault services such as Keychain and Secret Service, and tokens inherited in
|
|
|
41
41
|
environment variables, are excluded. Read / write may be needed by a CLI that
|
|
42
42
|
refreshes a token or updates its credential database.
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
check on the canonical target instead, run inside Pi's own file-mutation queue,
|
|
52
|
-
immediately before the filesystem call it guards. A refused mutation leaves
|
|
53
|
-
nothing behind on disk.
|
|
44
|
+
File and shell operations use the kernel: macOS uses Seatbelt (`sandbox-exec`)
|
|
45
|
+
and Linux uses Bubblewrap (`bwrap`). `read`, `write`, and `edit` keep Pi's normal
|
|
46
|
+
tool behavior and mutation queues, while a fixed worker performs filesystem
|
|
47
|
+
syscalls under the selected policy. Canonical checks explain denials; kernel
|
|
48
|
+
enforcement also protects against a symlink changing between checking and use.
|
|
49
|
+
The confined file worker rejects files over 8 MiB instead of silently truncating
|
|
50
|
+
them; larger-file processing can use a confined command when commands are On.
|
|
54
51
|
|
|
55
52
|
## What is confined, and what is not
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
This is a tool-execution sandbox. It limits accidental damage from commands the
|
|
89
|
-
model or you run through Pi's shell; it is not a boundary around Pi itself.
|
|
54
|
+
Pi is the trusted runtime. It can lock configuration/authentication files,
|
|
55
|
+
connect to its provider, and persist sessions. Main and Subagents permissions
|
|
56
|
+
apply to task operations. Subagents can start with task Network access or Run
|
|
57
|
+
commands & applications Off. The fixed file worker remains available according
|
|
58
|
+
to the file permissions even when task commands are Off.
|
|
59
|
+
|
|
60
|
+
The task executor provides a private scratch directory through `TMPDIR`, `TMP`,
|
|
61
|
+
and `TEMP`. Only that directory is writable in addition to the selected project
|
|
62
|
+
and explicit file grants; a protected anchor prevents replacing its root with a
|
|
63
|
+
symlink. It is separate from Pi's runtime control files.
|
|
64
|
+
|
|
65
|
+
The currently admitted model-tool implementations are `read`, `write`, `edit`,
|
|
66
|
+
and `bash`. User-entered `!` and `!!` commands use the same shell policy. Other
|
|
67
|
+
model-callable tools require a verified execution adapter and are refused while
|
|
68
|
+
that actor's sandbox is enabled; enabling network alone does not admit them.
|
|
69
|
+
Subagent launch output identifies requested tools that are unavailable. Main
|
|
70
|
+
remains Off by default, so its ordinary orchestration tools remain available
|
|
71
|
+
unless the user enables Main confinement.
|
|
72
|
+
|
|
73
|
+
Pi and installed runtime extensions remain trusted code, including their
|
|
74
|
+
initialization, provider hooks, and internal `pi.exec` calls. The tool gate is
|
|
75
|
+
not a sandbox around malicious runtime extensions. Loaded runtime code,
|
|
76
|
+
configuration, and policy/control files are protected from task writes, even
|
|
77
|
+
under broader file grants. Task access to `~/.pi` does not receive a blanket
|
|
78
|
+
write allowance or lock-file exception.
|
|
79
|
+
|
|
80
|
+
Local confinement cannot govern a remote host's filesystem. Dedicated SSH,
|
|
81
|
+
MCP, scripting, background, and nested-agent tools currently lack admission
|
|
82
|
+
adapters and fail closed under an enabled actor profile. SSH through confined
|
|
83
|
+
`bash` receives local file, credential, and network restrictions; remote effects
|
|
84
|
+
remain outside the local filesystem policy.
|
|
90
85
|
|
|
91
86
|
Overriding `write` and `edit` changes nothing you can see: the parameter
|
|
92
87
|
schemas, prompt guidance, call rendering, write previews, edit diffs, result
|
package/index.ts
CHANGED
|
@@ -7,19 +7,15 @@
|
|
|
7
7
|
* with one writable root — the canonical directory Pi was launched from — and
|
|
8
8
|
* the packaged write-denied paths carved back out of it. Selected file,
|
|
9
9
|
* credential-file, command, and network permissions apply to protected tools.
|
|
10
|
-
*
|
|
10
|
+
* File-tool syscalls and shell commands run under the same kernel policy.
|
|
11
11
|
*
|
|
12
12
|
* This is a tool-execution sandbox. Pi's own process, `pi.exec` calls, and
|
|
13
13
|
* unrelated third-party extension code are not confined by it.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
18
|
import {
|
|
19
|
-
createBashToolDefinition,
|
|
20
|
-
createReadToolDefinition,
|
|
21
|
-
createEditToolDefinition,
|
|
22
|
-
createWriteToolDefinition,
|
|
23
19
|
SettingsManager,
|
|
24
20
|
type ExtensionAPI,
|
|
25
21
|
type ExtensionContext,
|
|
@@ -36,16 +32,12 @@ import {
|
|
|
36
32
|
FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL,
|
|
37
33
|
publishForegroundSandboxPolicy,
|
|
38
34
|
} from "./events.ts";
|
|
39
|
-
import {
|
|
40
|
-
createSandboxedEditOperations,
|
|
41
|
-
createSandboxedWriteOperations,
|
|
42
|
-
createForegroundReadGuard,
|
|
43
|
-
} from "./files.ts";
|
|
35
|
+
import { installTaskTools, runtimeCodeRoot } from "./shared-task-sandbox.ts";
|
|
44
36
|
import { writeSandboxDefault } from "./preferences.ts";
|
|
45
37
|
import { readPermissionSettings, writePermissionSettings } from "./permission-settings.ts";
|
|
46
38
|
import { defaultSandboxPermissions } from "./permissions.ts";
|
|
47
39
|
import { openPermissionsPage } from "./permissions-page.ts";
|
|
48
|
-
|
|
40
|
+
|
|
49
41
|
import { footerTone, formatFooterStatus } from "./status.ts";
|
|
50
42
|
import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
|
|
51
43
|
|
|
@@ -57,47 +49,12 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
57
49
|
// Pi's shell setting is only readable once a session directory is known, so
|
|
58
50
|
// it is resolved lazily and re-read on every session start.
|
|
59
51
|
let shellPath: string | undefined;
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
pi.registerTool(createBashToolDefinition(process.cwd(), { operations }));
|
|
67
|
-
|
|
68
|
-
// The same backend for user-entered ! and !! commands.
|
|
69
|
-
pi.on("user_bash", () => ({ operations }));
|
|
70
|
-
|
|
71
|
-
// Overriding the built-in write and edit tools the same way: only their
|
|
72
|
-
// file operations change, so Pi's own definitions keep the parameter
|
|
73
|
-
// schemas, prompt guidance, call rendering, write previews, edit diffs,
|
|
74
|
-
// result details, file-mutation queue, and cancellation checks. The guarded
|
|
75
|
-
// operations run inside that queue, which is where the enforcement belongs.
|
|
76
|
-
const writeOperations = createSandboxedWriteOperations(controller);
|
|
77
|
-
const editOperations = createSandboxedEditOperations(controller);
|
|
78
|
-
const assertReadable = createForegroundReadGuard(controller);
|
|
79
|
-
|
|
80
|
-
// `cwd` is what these tools resolve a relative `path` against, so it has to
|
|
81
|
-
// be the directory Pi itself resolves against. Registration is re-run when
|
|
82
|
-
// a session reports a different cwd (`pi --cwd ...`), which Pi supports and
|
|
83
|
-
// refreshes in the same session.
|
|
84
|
-
let fileToolCwd: string | undefined;
|
|
85
|
-
const registerFileTools = (cwd: string): void => {
|
|
86
|
-
if (fileToolCwd === cwd) return;
|
|
87
|
-
fileToolCwd = cwd;
|
|
88
|
-
pi.registerTool(createWriteToolDefinition(cwd, { operations: writeOperations }));
|
|
89
|
-
pi.registerTool(createEditToolDefinition(cwd, { operations: editOperations }));
|
|
90
|
-
const read = createReadToolDefinition(cwd);
|
|
91
|
-
pi.registerTool({
|
|
92
|
-
...read,
|
|
93
|
-
execute: (id, params, signal, update, ctx) => {
|
|
94
|
-
const path = params.path.replace(/^@/, "");
|
|
95
|
-
const expanded = path === "~" ? homedir() : path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : resolve(cwd, path);
|
|
96
|
-
return read.execute(id, { ...params, path: assertReadable(expanded) }, signal, update, ctx);
|
|
97
|
-
},
|
|
98
|
-
});
|
|
99
|
-
};
|
|
100
|
-
registerFileTools(process.cwd());
|
|
52
|
+
const ownEntry = fileURLToPath(import.meta.url);
|
|
53
|
+
const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
|
|
54
|
+
trustedSources: [ownEntry,
|
|
55
|
+
join(dirname(ownEntry), "../../extensions/sandbox/index.ts"),
|
|
56
|
+
join(dirname(ownEntry), "../pi-better-harness/extensions/sandbox/index.ts")],
|
|
57
|
+
});
|
|
101
58
|
|
|
102
59
|
pi.on("tool_call", (event) => {
|
|
103
60
|
const status = controller.status();
|
|
@@ -142,7 +99,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
142
99
|
|
|
143
100
|
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
144
101
|
shellPath = resolveShellPath(ctx.cwd);
|
|
145
|
-
|
|
102
|
+
boundary.register(ctx.cwd);
|
|
146
103
|
paintFooter = (status) => {
|
|
147
104
|
ctx.ui.setStatus(
|
|
148
105
|
FOOTER_KEY,
|
|
@@ -167,6 +124,9 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
167
124
|
return;
|
|
168
125
|
}
|
|
169
126
|
controller.beginSession(ctx.cwd, settings.main.enabled);
|
|
127
|
+
controller.protectRuntimePaths((pi.getAllTools?.() ?? [])
|
|
128
|
+
.map((tool) => tool.sourceInfo?.path).filter((path): path is string => typeof path === "string" && isAbsolute(path))
|
|
129
|
+
.map(runtimeCodeRoot));
|
|
170
130
|
controller.setPermissionSettings(settings);
|
|
171
131
|
controller.applyDefault(settings.main.enabled);
|
|
172
132
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
35
|
-
"pretest": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
36
|
-
"prepack": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
34
|
+
"pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
35
|
+
"pretest": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
36
|
+
"prepack": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
38
|
"test": "node --import tsx --test test/*.test.ts",
|
|
39
39
|
"verify": "npm run typecheck && npm test"
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"LICENSE"
|
|
45
45
|
],
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": "
|
|
47
|
+
"@earendil-works/pi-coding-agent": ">=0.82.1",
|
|
48
48
|
"@earendil-works/pi-tui": "*",
|
|
49
49
|
"typebox": "*"
|
|
50
50
|
},
|
package/shared-sandbox-core.ts
CHANGED
|
@@ -79,6 +79,8 @@ export type SandboxCommandArgs = SandboxTarget & {
|
|
|
79
79
|
/** Where the macOS backend writes its generated SBPL profile. */
|
|
80
80
|
profilePath: string;
|
|
81
81
|
policy: SandboxWritePolicy;
|
|
82
|
+
/** Fixed internal helper only: expose its executable in a hidden Linux root. */
|
|
83
|
+
internalHelperExecutable?: boolean;
|
|
82
84
|
};
|
|
83
85
|
|
|
84
86
|
/** The wrapper command to spawn: the backend executable and its full argv. */
|
|
@@ -410,7 +412,21 @@ const macOSSandboxBackend: SandboxBackend = {
|
|
|
410
412
|
buildCommand: buildMacOSSandboxCommand,
|
|
411
413
|
};
|
|
412
414
|
|
|
413
|
-
/** Resolve
|
|
415
|
+
/** Resolve only a root-owned system executable; never inspect task PATH. */
|
|
416
|
+
function systemSandboxExecutable(name: string): string | undefined {
|
|
417
|
+
// Never resolve the host-side confinement launcher through task-influenced PATH.
|
|
418
|
+
for (const directory of ["/usr/bin", "/bin"]) {
|
|
419
|
+
const candidate = join(directory, name);
|
|
420
|
+
try {
|
|
421
|
+
const info = statSync(candidate);
|
|
422
|
+
if (!info.isFile() || info.uid !== 0 || (info.mode & 0o022) !== 0) continue;
|
|
423
|
+
accessSync(candidate, constants.X_OK);
|
|
424
|
+
return candidate;
|
|
425
|
+
} catch { /* Try the next system location. */ }
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
414
430
|
export function executableFromPath(name: string): string | undefined {
|
|
415
431
|
const path = process.env.PATH;
|
|
416
432
|
if (!path) return undefined;
|
|
@@ -592,6 +608,12 @@ function buildLinuxPermissionCommand(
|
|
|
592
608
|
if (existsSync(root)) mounts.push("--ro-bind", root, root);
|
|
593
609
|
}
|
|
594
610
|
mounts.push("--tmpfs", "/tmp");
|
|
611
|
+
if (args.internalHelperExecutable) {
|
|
612
|
+
const executable = canonicalizePath(args.execPath, seams);
|
|
613
|
+
if (!RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), executable))) {
|
|
614
|
+
mounts.push("--ro-bind", executable, executable);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
595
617
|
} else {
|
|
596
618
|
mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
597
619
|
}
|
|
@@ -605,17 +627,18 @@ function buildLinuxPermissionCommand(
|
|
|
605
627
|
}
|
|
606
628
|
}
|
|
607
629
|
for (const path of policy.runtimeWrite ?? []) {
|
|
608
|
-
if (
|
|
630
|
+
if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
|
|
631
|
+
policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
|
|
609
632
|
throw new Error("Runtime directory overlaps protected credentials or control paths.");
|
|
610
633
|
}
|
|
611
634
|
mounts.push("--bind", path, path);
|
|
612
635
|
}
|
|
613
636
|
const protectedPaths = [...policy.denyWrite,
|
|
614
637
|
...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
|
|
615
|
-
|
|
638
|
+
for (const writableRoot of [...(writableProject ? [project] : []), ...(policy.runtimeWrite ?? [])]) {
|
|
616
639
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
617
|
-
const leaves = protectedPaths.filter((path) => contains(
|
|
618
|
-
for (const parent of protectedAncestors(leaves).filter((path) => contains(
|
|
640
|
+
const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
|
|
641
|
+
for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
|
|
619
642
|
mounts.push("--bind", parent, parent);
|
|
620
643
|
}
|
|
621
644
|
for (const path of leaves) mounts.push("--ro-bind", path, path);
|
|
@@ -628,7 +651,7 @@ function buildLinuxPermissionCommand(
|
|
|
628
651
|
}
|
|
629
652
|
|
|
630
653
|
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
631
|
-
const bwrap = (seams.lookupExecutable ??
|
|
654
|
+
const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
|
|
632
655
|
if (!bwrap) return undefined;
|
|
633
656
|
return {
|
|
634
657
|
id: "linux-bubblewrap",
|
|
@@ -651,7 +674,7 @@ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined
|
|
|
651
674
|
*/
|
|
652
675
|
function unavailableMessage(platform: string): string {
|
|
653
676
|
if (platform === "linux") {
|
|
654
|
-
return "Linux sandbox requires executable bubblewrap (bwrap)
|
|
677
|
+
return "Linux sandbox requires executable bubblewrap (bwrap) in /usr/bin or /bin. Install bubblewrap to enable it.";
|
|
655
678
|
}
|
|
656
679
|
if (platform === "darwin") {
|
|
657
680
|
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,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/state.ts
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
* default and clears the previous override.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
17
17
|
import { homedir, tmpdir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { createTaskScratch, ensureHarnessRuntimeDirectories, harnessRuntimeDirectories, runtimeCodeRoot, writableRuntimeAlias } from "./shared-task-sandbox.ts";
|
|
19
21
|
|
|
20
22
|
import { parseSandboxPermissions, type SandboxPermissionSettings, type SandboxPermissionProfile } from "./permissions.ts";
|
|
21
23
|
import {
|
|
@@ -119,6 +121,8 @@ export class ForegroundSandboxController {
|
|
|
119
121
|
#profileDir: string | undefined;
|
|
120
122
|
#permissions: SandboxPermissionSettings | undefined;
|
|
121
123
|
#policyProblem: string | undefined;
|
|
124
|
+
#runtimePaths: readonly string[] = [];
|
|
125
|
+
#taskScratch: ReturnType<typeof createTaskScratch> | undefined;
|
|
122
126
|
|
|
123
127
|
constructor(seams: ForegroundSandboxSeams = {}) {
|
|
124
128
|
this.#seams = seams;
|
|
@@ -210,13 +214,21 @@ export class ForegroundSandboxController {
|
|
|
210
214
|
return this.status();
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
/** Host-discovered runtime code, never task-supplied permission grants. */
|
|
218
|
+
protectRuntimePaths(paths: readonly string[]): void {
|
|
219
|
+
this.#runtimePaths = Object.freeze(paths.map((path) => canonicalizePath(path, this.#seams)));
|
|
220
|
+
}
|
|
221
|
+
|
|
213
222
|
/** The current effective status, recomputed from live runtime evidence. */
|
|
214
223
|
status(): ForegroundSandboxStatus {
|
|
215
224
|
const support = describeSandboxSupport(this.#seams);
|
|
216
225
|
const base = {
|
|
217
226
|
projectRoot: this.#projectRoot,
|
|
218
227
|
denyWrite: this.#permissions
|
|
219
|
-
? Object.freeze([...this.#denyWrite,
|
|
228
|
+
? Object.freeze([...this.#denyWrite, getAgentDir(), ...harnessRuntimeDirectories(),
|
|
229
|
+
runtimeCodeRoot(join(getPackageDir(), "dist", "index.js")),
|
|
230
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
231
|
+
...(this.#projectRoot ? [join(this.#projectRoot, ".pi")] : []), ...this.#runtimePaths])
|
|
220
232
|
: this.#denyWrite,
|
|
221
233
|
platform: support.platform,
|
|
222
234
|
readPolicy: this.isUserEnabled() && this.#permissions && (this.#permissions.main.projectFiles === "off" || this.#permissions.main.outsideProject === "off" || this.#permissions.main.storedCredentials === "off") ? "restricted" as const : "unrestricted" as const,
|
|
@@ -261,6 +273,17 @@ export class ForegroundSandboxController {
|
|
|
261
273
|
});
|
|
262
274
|
}
|
|
263
275
|
|
|
276
|
+
const projectRoot = this.#projectRoot;
|
|
277
|
+
const permissions = this.#permissions?.main;
|
|
278
|
+
const runtimeAlias = permissions && [getAgentDir(), tmpdir(), getPackageDir()]
|
|
279
|
+
.map((path) => writableRuntimeAlias(path, projectRoot, permissions)).find(Boolean);
|
|
280
|
+
if (runtimeAlias) {
|
|
281
|
+
return Object.freeze({ ...base, state: "failed", writableRoot: undefined,
|
|
282
|
+
backend: support.supported ? support.backend : undefined,
|
|
283
|
+
executable: support.supported ? support.executable : undefined,
|
|
284
|
+
reason: `A Pi runtime directory uses a task-writable symlink (${runtimeAlias}). Restart Pi with canonical runtime, PI_CODING_AGENT_DIR, and TMPDIR paths before enabling confinement.` });
|
|
285
|
+
}
|
|
286
|
+
|
|
264
287
|
if (this.#unsafeRootReason !== undefined) {
|
|
265
288
|
return Object.freeze({
|
|
266
289
|
...base,
|
|
@@ -315,9 +338,12 @@ export class ForegroundSandboxController {
|
|
|
315
338
|
// published status: this is the mechanism protecting itself, not a rule
|
|
316
339
|
// the operator wrote or can remove.
|
|
317
340
|
const profileDir = this.#profileDirectory();
|
|
341
|
+
ensureHarnessRuntimeDirectories();
|
|
342
|
+
this.#taskScratch ??= createTaskScratch();
|
|
318
343
|
const policy: SandboxWritePolicy = {
|
|
319
344
|
writableRoot: status.writableRoot,
|
|
320
|
-
denyWrite: Object.freeze([...status.denyWrite, profileDir]),
|
|
345
|
+
denyWrite: Object.freeze([...status.denyWrite, profileDir, this.#taskScratch.anchor]),
|
|
346
|
+
runtimeWrite: Object.freeze([this.#taskScratch.path]),
|
|
321
347
|
home: (this.#seams.home ?? homedir)(),
|
|
322
348
|
...(status.permissions ? { permissions: status.permissions } : {}),
|
|
323
349
|
};
|
|
@@ -326,6 +352,10 @@ export class ForegroundSandboxController {
|
|
|
326
352
|
|
|
327
353
|
/** Drop the generated profiles this session created. */
|
|
328
354
|
dispose(): void {
|
|
355
|
+
if (this.#taskScratch) {
|
|
356
|
+
rmSync(this.#taskScratch.path, { recursive: true, force: true });
|
|
357
|
+
this.#taskScratch = undefined;
|
|
358
|
+
}
|
|
329
359
|
if (this.#profileDir === undefined) return;
|
|
330
360
|
if (this.#seams.createProfileDir === undefined) {
|
|
331
361
|
rmSync(this.#profileDir, { recursive: true, force: true });
|