pi-better-subagents 0.1.20 → 0.1.22
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/package.json +7 -3
- package/sandbox.ts +32 -125
- package/shared-navigator.ts +21 -10
- package/shared-sandbox-core.ts +462 -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 sandboxing uses `bubblewrap` when available, for example from `sudo apt-get install bubblewrap`.
|
|
32
|
+
Linux sandboxing uses `bubblewrap` when available, for example from `sudo apt-get install bubblewrap`. It is the same shared mechanism [`pi-better-sandbox`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme) applies to Pi's foreground tools: writes only, reads and network untouched. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox).
|
|
33
33
|
|
|
34
34
|
## When To Use
|
|
35
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
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,11 +30,15 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"pretest": "node ../../scripts/sync-shared-log-utils.mjs",
|
|
34
|
-
"prepack": "node ../../scripts/sync-shared-log-utils.mjs",
|
|
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
35
|
"typecheck": "node -e \"console.log('pi-better-subagents: typecheck skipped for legacy mixed TS/MJS package')\"",
|
|
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
|
+
"pretest:macos-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
39
|
+
"test:macos-sandbox": "bash tests/test_sandbox_applied.sh && bash tests/test_sandbox_deny_outside.sh && bash tests/test_sandbox_wrapper_argv.sh && node --import tsx --test tests/sandbox_profile.test.mjs",
|
|
40
|
+
"pretest:linux-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
41
|
+
"test:linux-sandbox": "node --import tsx --test tests/linux_bubblewrap.integration.mjs",
|
|
38
42
|
"verify": "npm run typecheck && npm test"
|
|
39
43
|
},
|
|
40
44
|
"files": [
|
package/sandbox.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OS
|
|
2
|
+
* Subagent policy adapter over the shared OS write-sandbox mechanism.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and command-wrapper contract. Linux support adds a backend without changing
|
|
12
|
-
* detached spawning policy in the extension.
|
|
4
|
+
* The mechanism itself — backend discovery, canonical containment, profile and
|
|
5
|
+
* mount construction, ordered argv wrapping — lives in `sandbox-core` and is
|
|
6
|
+
* vendored here as `shared-sandbox-core.ts`. This file holds only the subagent
|
|
7
|
+
* shape of that call: the detached child's writable directory is the whole
|
|
8
|
+
* policy, and the wrapped executable is always the pi binary. Detached spawning
|
|
9
|
+
* policy (default-on, explicit request, explicit opt-out) stays in index.ts and
|
|
10
|
+
* is passed through unchanged.
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
import {
|
|
14
|
+
buildSandboxCommand as buildSharedSandboxCommand,
|
|
15
|
+
maybeBuildSandboxCommand as maybeBuildSharedSandboxCommand,
|
|
16
|
+
sandboxSupported as sharedSandboxSupported,
|
|
17
|
+
type SandboxCommand,
|
|
18
|
+
type SandboxCommandArgs as SharedSandboxCommandArgs,
|
|
19
|
+
type SandboxRequest,
|
|
20
|
+
} from "./shared-sandbox-core.ts";
|
|
18
21
|
|
|
19
22
|
type SandboxCommandArgs = {
|
|
20
23
|
profilePath: string;
|
|
@@ -24,115 +27,20 @@ type SandboxCommandArgs = {
|
|
|
24
27
|
piArgs: string[];
|
|
25
28
|
};
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
type SandboxBackend = {
|
|
30
|
-
buildCommand(args: SandboxCommandArgs): SandboxCommand;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
type SandboxRequest = {
|
|
34
|
-
sandboxEnabled: boolean;
|
|
35
|
-
explicitSandbox: boolean;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
/** Quote a path as an SBPL string literal. */
|
|
39
|
-
function sbpl(path: string): string {
|
|
40
|
-
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Build the existing macOS sandbox-exec wrapper and SBPL profile. */
|
|
44
|
-
function buildMacOSSandboxCommand(args: SandboxCommandArgs): SandboxCommand {
|
|
45
|
-
// Match on the real (symlink-resolved) path — sandbox-exec evaluates the
|
|
46
|
-
// canonical path, so /tmp/x must be written as /private/tmp/x.
|
|
47
|
-
let dir = args.writableDir;
|
|
48
|
-
try { dir = realpathSync(dir); } catch { /* not yet created; use as given */ }
|
|
49
|
-
|
|
50
|
-
const profile = [
|
|
51
|
-
"(version 1)",
|
|
52
|
-
"(allow default)", // permissive base: reads, exec, network
|
|
53
|
-
"(deny file-write*)", // ...then deny all writes...
|
|
54
|
-
`(allow file-write* (subpath ${sbpl(dir)}))`, // ...except here
|
|
55
|
-
`(allow file-write* (subpath ${sbpl(`${args.home}/.pi`)}))`, // pi state
|
|
56
|
-
'(allow file-write* (subpath "/private/var/folders"))', // macOS temp / our runtime
|
|
57
|
-
'(allow file-write* (subpath "/private/tmp"))',
|
|
58
|
-
'(allow file-write* (subpath "/dev"))', // /dev/null etc.
|
|
59
|
-
"",
|
|
60
|
-
].join("\n");
|
|
61
|
-
writeFileSync(args.profilePath, profile);
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
file: "/usr/bin/sandbox-exec",
|
|
65
|
-
fileArgs: ["-f", args.profilePath, args.piBin, ...args.piArgs],
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const macOSSandboxBackend: SandboxBackend = {
|
|
70
|
-
buildCommand: buildMacOSSandboxCommand,
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/** Resolve an executable from PATH without starting it or probing namespaces. */
|
|
74
|
-
function executableFromPath(name: string): string | undefined {
|
|
75
|
-
const path = process.env.PATH;
|
|
76
|
-
if (!path) return undefined;
|
|
77
|
-
|
|
78
|
-
for (const entry of path.split(delimiter)) {
|
|
79
|
-
const candidate = resolve(entry || ".", name);
|
|
80
|
-
try {
|
|
81
|
-
if (!statSync(candidate).isFile()) continue;
|
|
82
|
-
accessSync(candidate, constants.X_OK);
|
|
83
|
-
return candidate;
|
|
84
|
-
} catch {
|
|
85
|
-
// A PATH entry may disappear or be inaccessible between lookup and use.
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function buildLinuxSandboxCommand(bwrap: string, args: SandboxCommandArgs): SandboxCommand {
|
|
92
|
-
// The caller creates the selected work directory before it reaches this
|
|
93
|
-
// boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
|
|
94
|
-
// widening the writable root.
|
|
95
|
-
const dir = realpathSync(args.writableDir);
|
|
30
|
+
/** Map the subagent's single-writable-directory shape onto the shared policy. */
|
|
31
|
+
function sharedArgs(args: SandboxCommandArgs): SharedSandboxCommandArgs {
|
|
96
32
|
return {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"--dev", "/dev",
|
|
103
|
-
"--",
|
|
104
|
-
args.piBin, ...args.piArgs,
|
|
105
|
-
],
|
|
33
|
+
profilePath: args.profilePath,
|
|
34
|
+
// Subagents have no write-deny list: the run directory is the policy.
|
|
35
|
+
policy: { writableRoot: args.writableDir, home: args.home },
|
|
36
|
+
execPath: args.piBin,
|
|
37
|
+
execArgs: args.piArgs,
|
|
106
38
|
};
|
|
107
39
|
}
|
|
108
40
|
|
|
109
|
-
function linuxSandboxBackend(): SandboxBackend | undefined {
|
|
110
|
-
const bwrap = executableFromPath("bwrap");
|
|
111
|
-
if (!bwrap) return undefined;
|
|
112
|
-
return { buildCommand: (args) => buildLinuxSandboxCommand(bwrap, args) };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function selectedSandboxBackend(): SandboxBackend | undefined {
|
|
116
|
-
const currentPlatform = platform();
|
|
117
|
-
if (currentPlatform === "darwin") return macOSSandboxBackend;
|
|
118
|
-
if (currentPlatform === "linux") return linuxSandboxBackend();
|
|
119
|
-
return undefined;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function sandboxUnavailableMessage(): string {
|
|
123
|
-
const currentPlatform = platform();
|
|
124
|
-
if (currentPlatform === "linux") {
|
|
125
|
-
return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap or pass sandbox:false.";
|
|
126
|
-
}
|
|
127
|
-
if (currentPlatform === "darwin") {
|
|
128
|
-
return "macOS sandbox requires /usr/bin/sandbox-exec. Pass sandbox:false if it is unavailable.";
|
|
129
|
-
}
|
|
130
|
-
return `sandbox is unsupported on ${currentPlatform}. Pass sandbox:false on this platform.`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
41
|
/** True when an OS write-sandbox backend can be applied on this platform. */
|
|
134
42
|
export function sandboxSupported(): boolean {
|
|
135
|
-
return
|
|
43
|
+
return sharedSandboxSupported();
|
|
136
44
|
}
|
|
137
45
|
|
|
138
46
|
/**
|
|
@@ -144,14 +52,13 @@ export function maybeBuildSandboxCommand(
|
|
|
144
52
|
args: SandboxCommandArgs,
|
|
145
53
|
request: SandboxRequest,
|
|
146
54
|
): SandboxCommand | undefined {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
return backend.buildCommand(args);
|
|
55
|
+
// `sandbox:false` is this surface's opt-out, and the only one its operator
|
|
56
|
+
// has: a subagent has no slash commands. A caller that states its own remedy
|
|
57
|
+
// keeps it.
|
|
58
|
+
return maybeBuildSharedSandboxCommand(sharedArgs(args), {
|
|
59
|
+
...request,
|
|
60
|
+
remedy: request.remedy ?? "Pass sandbox:false to run this subagent unconfined.",
|
|
61
|
+
});
|
|
155
62
|
}
|
|
156
63
|
|
|
157
64
|
/**
|
|
@@ -160,5 +67,5 @@ export function maybeBuildSandboxCommand(
|
|
|
160
67
|
* bypass the request-policy helper above.
|
|
161
68
|
*/
|
|
162
69
|
export function buildSandboxCommand(args: SandboxCommandArgs): SandboxCommand {
|
|
163
|
-
return (
|
|
70
|
+
return buildSharedSandboxCommand(sharedArgs(args));
|
|
164
71
|
}
|
package/shared-navigator.ts
CHANGED
|
@@ -909,13 +909,13 @@ function createOverlayComponent(
|
|
|
909
909
|
width,
|
|
910
910
|
deps.truncate,
|
|
911
911
|
fg,
|
|
912
|
-
{ minRows: detailRows },
|
|
912
|
+
{ minRows: detailRows, bottomFooter: false, logTailRows },
|
|
913
913
|
);
|
|
914
914
|
} else {
|
|
915
915
|
transcriptDetail = null;
|
|
916
916
|
transcriptComponent = null;
|
|
917
917
|
contentLines = mode === "detail"
|
|
918
|
-
? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows })
|
|
918
|
+
? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, bottomFooter: false })
|
|
919
919
|
: buildListLines(overlayState, width, deps.truncate, fg);
|
|
920
920
|
}
|
|
921
921
|
if (mode !== "detail") return contentLines;
|
|
@@ -994,9 +994,11 @@ function buildTranscriptDetailLines(
|
|
|
994
994
|
width: number,
|
|
995
995
|
truncate: (s: string, width: number) => string,
|
|
996
996
|
fg: (color: string, value: string) => string,
|
|
997
|
-
options: { minRows?: number } = {},
|
|
997
|
+
options: { minRows?: number; bottomFooter?: boolean; logTailRows?: number } = {},
|
|
998
998
|
): string[] {
|
|
999
999
|
const actions = [...(detail.footerActions ?? ["x close"]), "Esc close"].join(" · ");
|
|
1000
|
+
const tailRows = options.logTailRows ?? DEFAULT_LOG_TAIL_ROWS;
|
|
1001
|
+
const shownTranscriptLines = transcriptLines.length ? transcriptLines.slice(-tailRows) : [" (no transcript yet)"];
|
|
1000
1002
|
const lines: string[] = [
|
|
1001
1003
|
fg("accent", rule(detail.title, width)),
|
|
1002
1004
|
dim(` ← main · ${actions}`, fg),
|
|
@@ -1005,10 +1007,11 @@ function buildTranscriptDetailLines(
|
|
|
1005
1007
|
];
|
|
1006
1008
|
if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
|
|
1007
1009
|
for (const item of detail.metadata) lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
|
|
1008
|
-
lines.push("", dim(section(
|
|
1010
|
+
lines.push("", dim(section(`transcript · latest ${tailRows} rows`, width), fg));
|
|
1009
1011
|
if (detail.transcriptDiagnostic) lines.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
|
|
1010
|
-
lines.push(...
|
|
1012
|
+
lines.push(...shownTranscriptLines);
|
|
1011
1013
|
lines.push("");
|
|
1014
|
+
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1012
1015
|
const footerLines = [dim(` ← main · ${actions}`, fg), dim(rule("", width), fg)];
|
|
1013
1016
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
1014
1017
|
lines.push(...footerLines);
|
|
@@ -1092,10 +1095,11 @@ function buildDetailLines(
|
|
|
1092
1095
|
width: number,
|
|
1093
1096
|
truncate: (s: string, width: number) => string,
|
|
1094
1097
|
fg: (color: string, value: string) => string,
|
|
1095
|
-
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number } = {},
|
|
1098
|
+
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number; bottomFooter?: boolean } = {},
|
|
1096
1099
|
): string[] {
|
|
1097
1100
|
if (!detail) {
|
|
1098
1101
|
const lines = [rule("Work unavailable", width), dim(" ← back · Esc close", fg), ""];
|
|
1102
|
+
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1099
1103
|
const footerLines = [dim(rule("", width), fg)];
|
|
1100
1104
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
1101
1105
|
lines.push(...footerLines);
|
|
@@ -1149,14 +1153,17 @@ function buildDetailLines(
|
|
|
1149
1153
|
for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
|
|
1150
1154
|
}
|
|
1151
1155
|
} else {
|
|
1152
|
-
const
|
|
1156
|
+
const tailEvidence = isTailEvidence(detail);
|
|
1157
|
+
const evidenceLabel = tailEvidence ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
|
|
1153
1158
|
lines.push(dim(section(evidenceLabel, width), fg));
|
|
1154
1159
|
const wrapped = /log/i.test(detail.evidence.label)
|
|
1155
1160
|
? wrapLogText(body, width - 6)
|
|
1156
|
-
: body
|
|
1157
|
-
|
|
1161
|
+
: wrapEvidenceText(body, width - 6);
|
|
1162
|
+
const shown = tailEvidence ? wrapped.slice(-tailRows) : wrapped;
|
|
1163
|
+
for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
|
|
1158
1164
|
}
|
|
1159
1165
|
lines.push("");
|
|
1166
|
+
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1160
1167
|
const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
|
|
1161
1168
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
1162
1169
|
lines.push(...footerLines);
|
|
@@ -1182,7 +1189,11 @@ function applyDefaultExpandedSections(detail: BackgroundWorkDetail | null, expan
|
|
|
1182
1189
|
}
|
|
1183
1190
|
|
|
1184
1191
|
function isFoldableEvidence(detail: BackgroundWorkDetail): boolean {
|
|
1185
|
-
return
|
|
1192
|
+
return !isTailEvidence(detail);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function isTailEvidence(detail: BackgroundWorkDetail): boolean {
|
|
1196
|
+
return /log|transcript/i.test(detail.evidence.label);
|
|
1186
1197
|
}
|
|
1187
1198
|
|
|
1188
1199
|
function sectionHeader(label: string, width: number): string {
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// Generated from packages/sandbox-core/index.ts. Do not edit directly.
|
|
2
|
+
/**
|
|
3
|
+
* OS-level write sandbox mechanism shared by Pi extensions.
|
|
4
|
+
*
|
|
5
|
+
* Kernel-enforced confinement: the sandboxed process may READ anywhere and use
|
|
6
|
+
* the network (so web_fetch and the model API keep working), but may only WRITE
|
|
7
|
+
* under a single canonical root plus the system paths pi itself needs. Unlike a
|
|
8
|
+
* cooperative guardrails layer (which pattern-matches tool inputs), this cannot
|
|
9
|
+
* be evaded by a crafted bash command — the write syscall itself is denied.
|
|
10
|
+
*
|
|
11
|
+
* This module owns the mechanism only: backend discovery, canonical path
|
|
12
|
+
* containment, write-deny compilation, macOS SBPL profile construction, Linux
|
|
13
|
+
* Bubblewrap mount construction, ordered executable/argv wrapping, and support
|
|
14
|
+
* diagnostics. It owns no Pi tool, TUI, background-task, or subagent lifecycle
|
|
15
|
+
* policy — callers decide when a sandbox is requested and what it may write.
|
|
16
|
+
*
|
|
17
|
+
* Every platform/filesystem dependency is reachable through the optional
|
|
18
|
+
* `SandboxSeams` argument so callers can plan deterministically in tests.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { platform as osPlatform } from "node:os";
|
|
22
|
+
import {
|
|
23
|
+
accessSync,
|
|
24
|
+
closeSync,
|
|
25
|
+
constants,
|
|
26
|
+
existsSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
openSync,
|
|
29
|
+
realpathSync,
|
|
30
|
+
statSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from "node:fs";
|
|
33
|
+
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
34
|
+
|
|
35
|
+
/** Identifies which kernel mechanism a plan will use. */
|
|
36
|
+
export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
|
|
40
|
+
* be relative or contain symlinks; they are canonicalized before use.
|
|
41
|
+
*/
|
|
42
|
+
export type SandboxWritePolicy = {
|
|
43
|
+
/** The single directory subtree the sandboxed process may write under. */
|
|
44
|
+
writableRoot: string;
|
|
45
|
+
/**
|
|
46
|
+
* Concrete paths that stay non-writable even inside `writableRoot`. A
|
|
47
|
+
* directory entry denies its whole subtree; a file entry denies that file.
|
|
48
|
+
*
|
|
49
|
+
* An entry need not exist. The Linux backend needs a mount point, so it
|
|
50
|
+
* materializes an absent entry as an empty file (see `materializeDenyPath`);
|
|
51
|
+
* an entry that has to be a *directory* must therefore already exist when the
|
|
52
|
+
* command is built. Callers denying their own state directory create it
|
|
53
|
+
* first, which they do anyway to write into it.
|
|
54
|
+
*/
|
|
55
|
+
denyWrite?: readonly string[];
|
|
56
|
+
/** Home directory whose `~/.pi` state stays writable on macOS. */
|
|
57
|
+
home: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The executable and argv to run inside the sandbox, preserved verbatim. */
|
|
61
|
+
export type SandboxTarget = {
|
|
62
|
+
execPath: string;
|
|
63
|
+
execArgs: readonly string[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type SandboxCommandArgs = SandboxTarget & {
|
|
67
|
+
/** Where the macOS backend writes its generated SBPL profile. */
|
|
68
|
+
profilePath: string;
|
|
69
|
+
policy: SandboxWritePolicy;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The wrapper command to spawn: the backend executable and its full argv. */
|
|
73
|
+
export type SandboxCommand = { file: string; fileArgs: string[] };
|
|
74
|
+
|
|
75
|
+
/** The caller's default-on / explicit-request / opt-out decision. */
|
|
76
|
+
export type SandboxRequest = {
|
|
77
|
+
sandboxEnabled: boolean;
|
|
78
|
+
explicitSandbox: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* What this caller's operator can actually do about a missing backend,
|
|
81
|
+
* appended when an explicit request has to be refused. Surfaces differ: a
|
|
82
|
+
* subagent tool takes `sandbox:false`, a foreground session takes
|
|
83
|
+
* `/sandbox off`, so the remedy cannot be stated here.
|
|
84
|
+
*/
|
|
85
|
+
remedy?: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Injectable platform and filesystem dependencies. Defaults hit the real OS. */
|
|
89
|
+
export type SandboxSeams = {
|
|
90
|
+
/** Defaults to `os.platform()`. */
|
|
91
|
+
platform?: () => string;
|
|
92
|
+
/** Defaults to a PATH scan that stats and access-checks without executing. */
|
|
93
|
+
lookupExecutable?: (name: string) => string | undefined;
|
|
94
|
+
/** Defaults to `fs.realpathSync`. Must throw when the path does not exist. */
|
|
95
|
+
canonicalize?: (path: string) => string;
|
|
96
|
+
/** Defaults to `fs.writeFileSync`. */
|
|
97
|
+
writeProfile?: (path: string, contents: string) => void;
|
|
98
|
+
/**
|
|
99
|
+
* Defaults to creating an empty placeholder file for an absent denied path
|
|
100
|
+
* (see `materializeDenyPath`). Returns whether the path exists afterwards.
|
|
101
|
+
* Injected by tests that plan Linux argv for paths that do not exist on the
|
|
102
|
+
* host running them.
|
|
103
|
+
*/
|
|
104
|
+
materializeDenyPath?: (path: string) => boolean;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** A policy with every path canonicalized, deduplicated, and ordered. */
|
|
108
|
+
export type CompiledSandboxWritePolicy = {
|
|
109
|
+
readonly writableRoot: string;
|
|
110
|
+
readonly denyWrite: readonly string[];
|
|
111
|
+
readonly home: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Why a write target is or is not permitted by a compiled policy. */
|
|
115
|
+
export type WriteAccessDecision =
|
|
116
|
+
| { allowed: true; path: string }
|
|
117
|
+
| {
|
|
118
|
+
allowed: false;
|
|
119
|
+
path: string;
|
|
120
|
+
reason: "outside-writable-root" | "write-denied";
|
|
121
|
+
/** The compiled deny entry that matched, for `write-denied` only. */
|
|
122
|
+
deniedBy?: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** What the current platform can enforce, and why it cannot when it cannot. */
|
|
126
|
+
export type SandboxSupport =
|
|
127
|
+
| { supported: true; platform: string; backend: SandboxBackendId; executable: string }
|
|
128
|
+
| {
|
|
129
|
+
supported: false;
|
|
130
|
+
platform: string;
|
|
131
|
+
backend: undefined;
|
|
132
|
+
executable: undefined;
|
|
133
|
+
reason: string;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
type SandboxBackend = {
|
|
137
|
+
id: SandboxBackendId;
|
|
138
|
+
executable: string;
|
|
139
|
+
buildCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const MACOS_SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
143
|
+
|
|
144
|
+
function currentPlatform(seams: SandboxSeams): string {
|
|
145
|
+
return (seams.platform ?? osPlatform)();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve `path` to an absolute canonical path. Symlinks are resolved on the
|
|
150
|
+
* longest existing ancestor so a target that does not exist yet still
|
|
151
|
+
* canonicalizes through its real parent chain.
|
|
152
|
+
*/
|
|
153
|
+
export function canonicalizePath(path: string, seams: SandboxSeams = {}): string {
|
|
154
|
+
const canonicalize = seams.canonicalize ?? realpathSync;
|
|
155
|
+
const absolute = resolve(path);
|
|
156
|
+
try {
|
|
157
|
+
return canonicalize(absolute);
|
|
158
|
+
} catch {
|
|
159
|
+
// Not created yet (or unreadable): canonicalize the parent instead.
|
|
160
|
+
}
|
|
161
|
+
const parent = dirname(absolute);
|
|
162
|
+
if (parent === absolute) return absolute;
|
|
163
|
+
return join(canonicalizePath(parent, seams), basename(absolute));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function compile(
|
|
167
|
+
policy: SandboxWritePolicy,
|
|
168
|
+
seams: SandboxSeams,
|
|
169
|
+
strictRoot: boolean,
|
|
170
|
+
): CompiledSandboxWritePolicy {
|
|
171
|
+
// The Linux backend has always required the writable root to exist before it
|
|
172
|
+
// bind-mounts it; the macOS backend has always tolerated a not-yet-created
|
|
173
|
+
// one. Keep both behaviors rather than unifying them here.
|
|
174
|
+
const writableRoot = strictRoot
|
|
175
|
+
? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
|
|
176
|
+
: canonicalizePath(policy.writableRoot, seams);
|
|
177
|
+
|
|
178
|
+
const denyWrite = [
|
|
179
|
+
...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
|
|
180
|
+
].sort();
|
|
181
|
+
|
|
182
|
+
return { writableRoot, denyWrite, home: policy.home };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Canonicalize a write policy once so containment checks and backend rules
|
|
187
|
+
* agree on exactly which paths they are talking about.
|
|
188
|
+
*/
|
|
189
|
+
export function compileWritePolicy(
|
|
190
|
+
policy: SandboxWritePolicy,
|
|
191
|
+
seams: SandboxSeams = {},
|
|
192
|
+
): CompiledSandboxWritePolicy {
|
|
193
|
+
return compile(policy, seams, false);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function contains(root: string, target: string): boolean {
|
|
197
|
+
if (target === root) return true;
|
|
198
|
+
return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decide whether an in-process write to `target` is permitted by a compiled
|
|
203
|
+
* policy. This is the same containment rule the kernel backends enforce, for
|
|
204
|
+
* callers that mutate files directly instead of spawning a child.
|
|
205
|
+
*/
|
|
206
|
+
export function evaluateWriteAccess(
|
|
207
|
+
target: string,
|
|
208
|
+
policy: CompiledSandboxWritePolicy,
|
|
209
|
+
seams: SandboxSeams = {},
|
|
210
|
+
): WriteAccessDecision {
|
|
211
|
+
const path = canonicalizePath(target, seams);
|
|
212
|
+
if (!contains(policy.writableRoot, path)) {
|
|
213
|
+
return { allowed: false, path, reason: "outside-writable-root" };
|
|
214
|
+
}
|
|
215
|
+
for (const denied of policy.denyWrite) {
|
|
216
|
+
if (contains(denied, path)) {
|
|
217
|
+
return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { allowed: true, path };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Quote a path as an SBPL string literal. */
|
|
224
|
+
function sbpl(path: string): string {
|
|
225
|
+
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Build the macOS sandbox-exec wrapper and its SBPL profile. */
|
|
229
|
+
function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
|
|
230
|
+
// Match on the real (symlink-resolved) path — sandbox-exec evaluates the
|
|
231
|
+
// canonical path, so /tmp/x must be written as /private/tmp/x.
|
|
232
|
+
const policy = compile(args.policy, seams, false);
|
|
233
|
+
|
|
234
|
+
const profile = [
|
|
235
|
+
"(version 1)",
|
|
236
|
+
"(allow default)", // permissive base: reads, exec, network
|
|
237
|
+
"(deny file-write*)", // ...then deny all writes...
|
|
238
|
+
`(allow file-write* (subpath ${sbpl(policy.writableRoot)}))`, // ...except here
|
|
239
|
+
`(allow file-write* (subpath ${sbpl(`${policy.home}/.pi`)}))`, // pi state
|
|
240
|
+
'(allow file-write* (subpath "/private/var/folders"))', // macOS temp / our runtime
|
|
241
|
+
'(allow file-write* (subpath "/private/tmp"))',
|
|
242
|
+
'(allow file-write* (subpath "/dev"))', // /dev/null etc.
|
|
243
|
+
// Deny rules come last: SBPL applies the last matching rule, so these
|
|
244
|
+
// carve holes back out of the allowances above.
|
|
245
|
+
...policy.denyWrite.map((path) => `(deny file-write* (subpath ${sbpl(path)}))`),
|
|
246
|
+
"",
|
|
247
|
+
].join("\n");
|
|
248
|
+
(seams.writeProfile ?? writeFileSync)(args.profilePath, profile);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
file: MACOS_SANDBOX_EXEC,
|
|
252
|
+
fileArgs: ["-f", args.profilePath, args.execPath, ...args.execArgs],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const macOSSandboxBackend: SandboxBackend = {
|
|
257
|
+
id: "macos-seatbelt",
|
|
258
|
+
executable: MACOS_SANDBOX_EXEC,
|
|
259
|
+
buildCommand: buildMacOSSandboxCommand,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** Resolve an executable from PATH without starting it or probing namespaces. */
|
|
263
|
+
export function executableFromPath(name: string): string | undefined {
|
|
264
|
+
const path = process.env.PATH;
|
|
265
|
+
if (!path) return undefined;
|
|
266
|
+
|
|
267
|
+
for (const entry of path.split(delimiter)) {
|
|
268
|
+
const candidate = resolve(entry || ".", name);
|
|
269
|
+
try {
|
|
270
|
+
if (!statSync(candidate).isFile()) continue;
|
|
271
|
+
accessSync(candidate, constants.X_OK);
|
|
272
|
+
return candidate;
|
|
273
|
+
} catch {
|
|
274
|
+
// A PATH entry may disappear or be inaccessible between lookup and use.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Give an absent denied path something the kernel can hold out.
|
|
282
|
+
*
|
|
283
|
+
* A mount needs a mount point. `--ro-bind-try` skips a source that does not
|
|
284
|
+
* exist, so before this a denied path that had not been created yet was not
|
|
285
|
+
* denied at all: inside the sandbox `echo secret > .env.local` simply created
|
|
286
|
+
* it. `.env` and `.env.local` are absent in most projects, which made the
|
|
287
|
+
* packaged defaults hold on macOS — SBPL denies by resolved path, existing or
|
|
288
|
+
* not — and not on Linux.
|
|
289
|
+
*
|
|
290
|
+
* Nothing bubblewrap offers closes that without a mount point, and every
|
|
291
|
+
* bubblewrap operation that would create one (`--dir`, `--file`, `--tmpfs`)
|
|
292
|
+
* creates it through the read-write bind of the project, which is to say on the
|
|
293
|
+
* real filesystem anyway. So the placeholder is created here, deliberately and
|
|
294
|
+
* visibly, rather than as a side effect of a mount operation.
|
|
295
|
+
*
|
|
296
|
+
* An empty regular file is the least destructive placeholder: a later
|
|
297
|
+
* `cp .env.example .env` overwrites it, where an empty *directory* at that path
|
|
298
|
+
* would fail. Callers whose denied entry has to be a directory create it before
|
|
299
|
+
* building the command, and an entry that already exists — file or directory —
|
|
300
|
+
* is bound as it is. The file is created with O_EXCL, so anything that appears
|
|
301
|
+
* in the meantime is bound rather than clobbered, and it is left in place
|
|
302
|
+
* afterwards because a resumed task re-runs the launch vector it captured and
|
|
303
|
+
* its `--ro-bind` sources have to still be there.
|
|
304
|
+
*
|
|
305
|
+
* Returns false when the placeholder could not be created. That is not a hole:
|
|
306
|
+
* the confined process runs as this same user, so a path this process cannot
|
|
307
|
+
* create is a path that process cannot create either.
|
|
308
|
+
*/
|
|
309
|
+
function materializeDenyPath(path: string): boolean {
|
|
310
|
+
if (existsSync(path)) return true;
|
|
311
|
+
try {
|
|
312
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
313
|
+
closeSync(openSync(path, "wx"));
|
|
314
|
+
return true;
|
|
315
|
+
} catch {
|
|
316
|
+
// Re-check rather than trust the errno. EEXIST from the O_EXCL create
|
|
317
|
+
// means the path appeared in between, which is the outcome we wanted and
|
|
318
|
+
// not ours to overwrite; EEXIST from the mkdir means a parent is a
|
|
319
|
+
// regular file, and nothing can exist under it. Only the first leaves a
|
|
320
|
+
// source a bind can use.
|
|
321
|
+
return existsSync(path);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Whether a denied path lies in a region this backend binds read-write.
|
|
327
|
+
*
|
|
328
|
+
* Everywhere else is already covered by the read-only bind of `/`, so a
|
|
329
|
+
* placeholder there would deny nothing that is not denied already — and would
|
|
330
|
+
* scatter empty files across the host for the sake of it. Materializing is
|
|
331
|
+
* confined to the two regions that are genuinely writable inside the sandbox:
|
|
332
|
+
* the writable root, and the `/tmp` rebind that pi's own tooling needs.
|
|
333
|
+
*/
|
|
334
|
+
function writableInsideLinuxSandbox(path: string, writableRoot: string): boolean {
|
|
335
|
+
return contains(writableRoot, path) || contains("/tmp", path);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildLinuxSandboxCommand(
|
|
339
|
+
bwrap: string,
|
|
340
|
+
args: SandboxCommandArgs,
|
|
341
|
+
seams: SandboxSeams,
|
|
342
|
+
): SandboxCommand {
|
|
343
|
+
// The caller creates the selected work directory before it reaches this
|
|
344
|
+
// boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
|
|
345
|
+
// widening the writable root.
|
|
346
|
+
const policy = compile(args.policy, seams, true);
|
|
347
|
+
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
348
|
+
const denyBinds = policy.denyWrite.flatMap((path) => {
|
|
349
|
+
const mountable = writableInsideLinuxSandbox(path, policy.writableRoot) && materialize(path);
|
|
350
|
+
return [mountable ? "--ro-bind" : "--ro-bind-try", path, path];
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
file: bwrap,
|
|
354
|
+
fileArgs: [
|
|
355
|
+
"--ro-bind", "/", "/",
|
|
356
|
+
"--bind", policy.writableRoot, policy.writableRoot,
|
|
357
|
+
"--bind", "/tmp", "/tmp",
|
|
358
|
+
"--dev", "/dev",
|
|
359
|
+
// Layered last so a denied path wins over every writable bind above.
|
|
360
|
+
// A denied path need not exist yet, so one inside a writable region
|
|
361
|
+
// is materialized first; `-try` remains for the paths that are
|
|
362
|
+
// read-only regardless and for the ones that could not be created,
|
|
363
|
+
// which are paths the confined process cannot create either.
|
|
364
|
+
...denyBinds,
|
|
365
|
+
"--",
|
|
366
|
+
args.execPath, ...args.execArgs,
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
372
|
+
const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
|
|
373
|
+
if (!bwrap) return undefined;
|
|
374
|
+
return {
|
|
375
|
+
id: "linux-bubblewrap",
|
|
376
|
+
executable: bwrap,
|
|
377
|
+
buildCommand: (args, buildSeams) => buildLinuxSandboxCommand(bwrap, args, buildSeams),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
382
|
+
const platform = currentPlatform(seams);
|
|
383
|
+
if (platform === "darwin") return macOSSandboxBackend;
|
|
384
|
+
if (platform === "linux") return linuxSandboxBackend(seams);
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Why no backend applies here. The requirement only: what a caller can do
|
|
390
|
+
* instead is a property of that caller's surface, not of the platform, and is
|
|
391
|
+
* supplied through `SandboxRequest.remedy`.
|
|
392
|
+
*/
|
|
393
|
+
function unavailableMessage(platform: string): string {
|
|
394
|
+
if (platform === "linux") {
|
|
395
|
+
return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap to enable it.";
|
|
396
|
+
}
|
|
397
|
+
if (platform === "darwin") {
|
|
398
|
+
return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";
|
|
399
|
+
}
|
|
400
|
+
return `sandbox is unsupported on ${platform}.`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Report which backend this platform would select, and why it would not. */
|
|
404
|
+
export function describeSandboxSupport(seams: SandboxSeams = {}): SandboxSupport {
|
|
405
|
+
const platform = currentPlatform(seams);
|
|
406
|
+
const backend = selectedSandboxBackend(seams);
|
|
407
|
+
if (!backend) {
|
|
408
|
+
return {
|
|
409
|
+
supported: false,
|
|
410
|
+
platform,
|
|
411
|
+
backend: undefined,
|
|
412
|
+
executable: undefined,
|
|
413
|
+
reason: unavailableMessage(platform),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
return { supported: true, platform, backend: backend.id, executable: backend.executable };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The message explaining why no backend is available on this platform. */
|
|
420
|
+
export function sandboxUnavailableMessage(seams: SandboxSeams = {}): string {
|
|
421
|
+
return unavailableMessage(currentPlatform(seams));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** True when an OS write-sandbox backend can be applied on this platform. */
|
|
425
|
+
export function sandboxSupported(seams: SandboxSeams = {}): boolean {
|
|
426
|
+
return selectedSandboxBackend(seams) !== undefined;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Resolve the caller's default-on, explicit-request, and opt-out policy before
|
|
431
|
+
* spawning. A selected backend always returns its wrapper; callers never retry
|
|
432
|
+
* the child directly when that wrapper exits or cannot initialize.
|
|
433
|
+
*/
|
|
434
|
+
export function maybeBuildSandboxCommand(
|
|
435
|
+
args: SandboxCommandArgs,
|
|
436
|
+
request: SandboxRequest,
|
|
437
|
+
seams: SandboxSeams = {},
|
|
438
|
+
): SandboxCommand | undefined {
|
|
439
|
+
if (!request.sandboxEnabled) return undefined;
|
|
440
|
+
|
|
441
|
+
const backend = selectedSandboxBackend(seams);
|
|
442
|
+
if (!backend) {
|
|
443
|
+
if (request.explicitSandbox) {
|
|
444
|
+
const reason = sandboxUnavailableMessage(seams);
|
|
445
|
+
throw new Error(request.remedy ? `${reason} ${request.remedy}` : reason);
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
return backend.buildCommand(args, seams);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Return the selected backend's executable and ordered argv wrapper around the
|
|
454
|
+
* target. The fallback preserves the pre-existing direct-call result for callers
|
|
455
|
+
* that bypass the request-policy helper above.
|
|
456
|
+
*/
|
|
457
|
+
export function buildSandboxCommand(
|
|
458
|
+
args: SandboxCommandArgs,
|
|
459
|
+
seams: SandboxSeams = {},
|
|
460
|
+
): SandboxCommand {
|
|
461
|
+
return (selectedSandboxBackend(seams) ?? macOSSandboxBackend).buildCommand(args, seams);
|
|
462
|
+
}
|