pi-t3-bridge 0.2.0 → 0.3.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 +5 -1
- package/package.json +2 -1
- package/src/bridge/config.ts +8 -0
- package/src/pstack/child.ts +49 -74
- package/src/pstack/sandbox.ts +253 -0
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ You need the following programs:
|
|
|
25
25
|
- Pi 1.4.0 or later
|
|
26
26
|
- T3 Code, if you want to run Pi threads inside T3 or call T3 tools from Pi
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Delegated children run their shell commands in a sandbox: `sandbox-exec` on macOS, and `bwrap` (bubblewrap) on Linux and WSL2. Linux needs the `bubblewrap` package and unprivileged user namespaces. On Ubuntu, the `bubblewrap` package ships an AppArmor profile that lets `bwrap` run (checked on 26.04). If AppArmor still blocks it, run `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`. Native Windows and WSL1 have no sandbox. Where no sandbox can run, a child's shell commands are blocked unless `childSandbox` is `"off"` in the config.
|
|
29
|
+
|
|
30
|
+
The tests need Docker, because they also run the child tests in Linux containers.
|
|
29
31
|
|
|
30
32
|
## Install
|
|
31
33
|
|
|
@@ -220,6 +222,7 @@ The bridge reads one JSON file per machine, at `~/.config/pi-t3/config.json`. Ev
|
|
|
220
222
|
```json
|
|
221
223
|
{
|
|
222
224
|
"bashTimeoutSeconds": 300,
|
|
225
|
+
"childSandbox": "on",
|
|
223
226
|
"mcpUrl": "http://127.0.0.1:3773/mcp",
|
|
224
227
|
"steer": "on"
|
|
225
228
|
}
|
|
@@ -228,6 +231,7 @@ The bridge reads one JSON file per machine, at `~/.config/pi-t3/config.json`. Ev
|
|
|
228
231
|
| Key | Default | Meaning |
|
|
229
232
|
| --- | --- | --- |
|
|
230
233
|
| `bashTimeoutSeconds` | `300` | The longest a shell command can run, in seconds. |
|
|
234
|
+
| `childSandbox` | `"on"` | Whether a delegated child's shell commands need a sandbox. With `"on"`, a child's `bash` is blocked when no sandbox can run. With `"off"`, commands run with no sandbox on every platform, and the child's report says `SANDBOX: off`. |
|
|
231
235
|
| `mcpUrl` | `http://127.0.0.1:3773/mcp` | The T3 MCP endpoint to use outside a T3 thread. |
|
|
232
236
|
| `steer` | `"on"` | The stop checks, as `"on"`, `"shadow"`, or `"off"`. |
|
|
233
237
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-t3-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi extension adapter for t3-pstack inside T3 Code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-extension",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"@types/node": "^26.6.5",
|
|
54
54
|
"oxfmt": "^0.74.0",
|
|
55
55
|
"oxlint": "^1.89.0",
|
|
56
|
+
"testcontainers": "^12.2.1",
|
|
56
57
|
"typebox": "1.3.27",
|
|
57
58
|
"typescript": "^7.0.2",
|
|
58
59
|
"ultracite": "^8.0.1"
|
package/src/bridge/config.ts
CHANGED
|
@@ -17,8 +17,14 @@ const SteerSchema = Type.Union([
|
|
|
17
17
|
Type.Literal("shadow"),
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
|
+
const ChildSandboxSchema = Type.Union([
|
|
21
|
+
Type.Literal("on"),
|
|
22
|
+
Type.Literal("off"),
|
|
23
|
+
]);
|
|
24
|
+
|
|
20
25
|
export interface Config {
|
|
21
26
|
readonly bashTimeoutSeconds: number | undefined;
|
|
27
|
+
readonly childSandbox: Static<typeof ChildSandboxSchema> | undefined;
|
|
22
28
|
readonly mcpUrl: string | undefined;
|
|
23
29
|
readonly steer: Static<typeof SteerSchema> | undefined;
|
|
24
30
|
}
|
|
@@ -30,6 +36,7 @@ interface Loaded {
|
|
|
30
36
|
|
|
31
37
|
const EMPTY: Config = {
|
|
32
38
|
bashTimeoutSeconds: undefined,
|
|
39
|
+
childSandbox: undefined,
|
|
33
40
|
mcpUrl: undefined,
|
|
34
41
|
steer: undefined,
|
|
35
42
|
};
|
|
@@ -80,6 +87,7 @@ const parse = (text: string): Loaded => {
|
|
|
80
87
|
"bashTimeoutSeconds",
|
|
81
88
|
Type.Number({ exclusiveMinimum: 0 })
|
|
82
89
|
),
|
|
90
|
+
childSandbox: field("childSandbox", ChildSandboxSchema),
|
|
83
91
|
mcpUrl: field("mcpUrl", Type.String({ minLength: 1 })),
|
|
84
92
|
steer: field("steer", SteerSchema),
|
|
85
93
|
},
|
package/src/pstack/child.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
@@ -17,33 +17,29 @@ import { ownThreadId } from "../bridge/thread.ts";
|
|
|
17
17
|
import { sessionBrief } from "./brief.ts";
|
|
18
18
|
import type { Brief } from "./brief.ts";
|
|
19
19
|
import { policyPath } from "./resources.ts";
|
|
20
|
+
import {
|
|
21
|
+
chooseSandbox,
|
|
22
|
+
real,
|
|
23
|
+
sandboxLabel,
|
|
24
|
+
unavailableReason,
|
|
25
|
+
wrap,
|
|
26
|
+
} from "./sandbox.ts";
|
|
27
|
+
import type { Sandbox } from "./sandbox.ts";
|
|
20
28
|
|
|
21
29
|
// Rules a delegated child cannot talk its way around. A session whose first
|
|
22
|
-
// message is a pstack brief gets them: writes only inside its workdir (a
|
|
23
|
-
// sandbox around every shell command) and only to its
|
|
24
|
-
// edit), its time and disk limits, and a report line
|
|
25
|
-
// commands it runs itself, not from what the model says.
|
|
30
|
+
// message is a pstack brief gets them: writes only inside its workdir (a
|
|
31
|
+
// sandbox around every shell command, or no shell at all) and only to its
|
|
32
|
+
// listed files (write and edit), its time and disk limits, and a report line
|
|
33
|
+
// the code writes from commands it runs itself, not from what the model says.
|
|
26
34
|
|
|
27
35
|
const run = promisify(execFile);
|
|
28
36
|
|
|
29
|
-
const SANDBOX = "/usr/bin/sandbox-exec";
|
|
30
|
-
|
|
31
37
|
const DU_TIMEOUT_MS = 15_000;
|
|
32
38
|
|
|
33
39
|
const SCRATCH = ".scratch/";
|
|
34
40
|
|
|
35
41
|
const WRITE_TOOLS: ReadonlySet<string> = new Set(["edit", "write"]);
|
|
36
42
|
|
|
37
|
-
const quote = (text: string): string => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
38
|
-
|
|
39
|
-
const real = (target: string): string => {
|
|
40
|
-
try {
|
|
41
|
-
return realpathSync(target);
|
|
42
|
-
} catch {
|
|
43
|
-
return target;
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
|
|
47
43
|
// Where a write to target lands once every symlink on its path is followed.
|
|
48
44
|
// Undefined for a dangling link, whose landing spot cannot be known.
|
|
49
45
|
const landing = (target: string): string | undefined => {
|
|
@@ -68,50 +64,6 @@ const landing = (target: string): string | undefined => {
|
|
|
68
64
|
: path.join(base, path.basename(target));
|
|
69
65
|
};
|
|
70
66
|
|
|
71
|
-
// Tool caches and installs stay writable, so uv, npm and mise work.
|
|
72
|
-
const writableRoots = (workdir: string, gitDir: string | undefined) => {
|
|
73
|
-
const home = homedir();
|
|
74
|
-
|
|
75
|
-
return [
|
|
76
|
-
workdir,
|
|
77
|
-
gitDir,
|
|
78
|
-
path.join(home, ".cache"),
|
|
79
|
-
path.join(home, "Library", "Caches"),
|
|
80
|
-
path.join(home, ".npm"),
|
|
81
|
-
path.join(home, ".local", "share", "uv"),
|
|
82
|
-
path.join(home, ".local", "share", "mise"),
|
|
83
|
-
"/private/var/folders",
|
|
84
|
-
"/dev",
|
|
85
|
-
]
|
|
86
|
-
.filter((root): root is string => root !== undefined)
|
|
87
|
-
.flatMap((root) => [root, real(root)]);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
// Hooks and config in the git dir run later, outside the sandbox, when the
|
|
91
|
-
// parent commits or merges the child's work, so they stay read-only.
|
|
92
|
-
const gitDenies = (gitDir: string | undefined): string[] =>
|
|
93
|
-
gitDir === undefined
|
|
94
|
-
? []
|
|
95
|
-
: [gitDir, real(gitDir)].flatMap((dir) => [
|
|
96
|
-
`(subpath ${JSON.stringify(path.join(dir, "hooks"))})`,
|
|
97
|
-
`(literal ${JSON.stringify(path.join(dir, "config"))})`,
|
|
98
|
-
`(literal ${JSON.stringify(path.join(dir, "config.lock"))})`,
|
|
99
|
-
]);
|
|
100
|
-
|
|
101
|
-
const sandboxProfile = (
|
|
102
|
-
roots: readonly string[],
|
|
103
|
-
denies: readonly string[]
|
|
104
|
-
): string =>
|
|
105
|
-
[
|
|
106
|
-
"(version 1)",
|
|
107
|
-
"(allow default)",
|
|
108
|
-
"(deny file-write*)",
|
|
109
|
-
`(allow file-write* ${[...new Set(roots)].map((root) => `(subpath ${JSON.stringify(root)})`).join(" ")})`,
|
|
110
|
-
...(denies.length === 0
|
|
111
|
-
? []
|
|
112
|
-
: [`(deny file-write* ${[...new Set(denies)].join(" ")})`]),
|
|
113
|
-
].join("");
|
|
114
|
-
|
|
115
67
|
const globToRegExp = (glob: string): RegExp => {
|
|
116
68
|
const source = glob
|
|
117
69
|
.replace(/^\.\//u, "")
|
|
@@ -228,11 +180,12 @@ interface Report {
|
|
|
228
180
|
readonly diskMb: number | undefined;
|
|
229
181
|
readonly minutes: number;
|
|
230
182
|
readonly outside: readonly string[];
|
|
183
|
+
readonly sandbox: Sandbox | undefined;
|
|
231
184
|
readonly status: string;
|
|
232
185
|
}
|
|
233
186
|
|
|
234
187
|
const reportBlock = (report: Report): string => {
|
|
235
|
-
const { accepted, brief, diskMb, minutes, outside } = report;
|
|
188
|
+
const { accepted, brief, diskMb, minutes, outside, sandbox } = report;
|
|
236
189
|
const tail = accepted.tail.trim().split("\n").slice(-10).join("\n");
|
|
237
190
|
|
|
238
191
|
const before =
|
|
@@ -248,6 +201,7 @@ const reportBlock = (report: Report): string => {
|
|
|
248
201
|
`FILES: ${report.changed} changed; outside the list: ${outside.length === 0 ? "none" : outside.join(", ")}`,
|
|
249
202
|
`DISK: ${diskMb === undefined ? "unknown" : `+${diskMb.toFixed(1)} MB`} in the workdir (limit ${brief.diskMb ?? "?"} MB)`,
|
|
250
203
|
`TIME: ${minutes.toFixed(1)} of ${brief.minutes ?? "?"} min`,
|
|
204
|
+
`SANDBOX: ${sandboxLabel(sandbox)}`,
|
|
251
205
|
].join("\n");
|
|
252
206
|
};
|
|
253
207
|
|
|
@@ -260,6 +214,7 @@ interface ChildState {
|
|
|
260
214
|
gitDir: string | undefined;
|
|
261
215
|
greenAtStart: boolean;
|
|
262
216
|
ready: boolean;
|
|
217
|
+
sandbox: Sandbox | undefined;
|
|
263
218
|
}
|
|
264
219
|
|
|
265
220
|
export const registerChild = (pi: ExtensionAPI): void => {
|
|
@@ -272,24 +227,29 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
272
227
|
gitDir: undefined,
|
|
273
228
|
greenAtStart: false,
|
|
274
229
|
ready: false,
|
|
230
|
+
sandbox: undefined,
|
|
275
231
|
};
|
|
276
232
|
|
|
277
|
-
const sandboxed = (command: string, workdir: string): string =>
|
|
278
|
-
existsSync(SANDBOX)
|
|
279
|
-
? `${SANDBOX} -p ${quote(sandboxProfile(writableRoots(workdir, state.gitDir), gitDenies(state.gitDir)))} /bin/bash -c ${quote(command)}`
|
|
280
|
-
: command;
|
|
281
|
-
|
|
282
233
|
const runAcceptance = async (
|
|
283
234
|
command: string,
|
|
284
235
|
workdir: string,
|
|
285
236
|
seconds: number
|
|
286
237
|
): Promise<{ exit: number; tail: string }> => {
|
|
238
|
+
const wrapped = wrap(state.sandbox, command, workdir, state.gitDir);
|
|
239
|
+
|
|
240
|
+
if (wrapped === undefined) {
|
|
241
|
+
return {
|
|
242
|
+
exit: 126,
|
|
243
|
+
tail: `not run: no sandbox (${unavailableReason(state.sandbox)})`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
287
247
|
try {
|
|
288
|
-
const { stdout, stderr } = await run(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
);
|
|
248
|
+
const { stdout, stderr } = await run("/bin/bash", ["-c", wrapped], {
|
|
249
|
+
cwd: workdir,
|
|
250
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
251
|
+
timeout: seconds * 1000,
|
|
252
|
+
});
|
|
293
253
|
|
|
294
254
|
return { exit: 0, tail: `${stdout}${stderr}` };
|
|
295
255
|
} catch (error) {
|
|
@@ -321,6 +281,8 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
321
281
|
state.gitDir = undefined;
|
|
322
282
|
}
|
|
323
283
|
|
|
284
|
+
state.sandbox = await chooseSandbox(state.gitDir);
|
|
285
|
+
|
|
324
286
|
state.baselineKb = await duKb(ctx.cwd);
|
|
325
287
|
|
|
326
288
|
const redBeforeGreen = readRedBeforeGreen();
|
|
@@ -388,12 +350,21 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
388
350
|
const { input } = event;
|
|
389
351
|
|
|
390
352
|
if (event.toolName === "bash" && Value.Check(BashInputSchema, input)) {
|
|
353
|
+
const wrapped = wrap(state.sandbox, input.command, ctx.cwd, state.gitDir);
|
|
354
|
+
|
|
355
|
+
if (wrapped === undefined) {
|
|
356
|
+
return {
|
|
357
|
+
block: true,
|
|
358
|
+
reason: `No sandbox can run for this shell (${unavailableReason(state.sandbox)}), so shell commands are blocked. The user can install \`bubblewrap\`, set kernel.apparmor_restrict_unprivileged_userns=0, or set "childSandbox": "off" in ~/.config/pi-t3/config.json. Use no more shell commands. Report STATUS: BLOCKED (no sandbox).`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
391
362
|
const limit = Math.max(1, Math.ceil(remaining));
|
|
392
363
|
|
|
393
364
|
input.timeout = Value.Check(Type.Number(), input.timeout)
|
|
394
365
|
? Math.min(input.timeout, limit)
|
|
395
366
|
: limit;
|
|
396
|
-
input.command =
|
|
367
|
+
input.command = wrapped;
|
|
397
368
|
|
|
398
369
|
return;
|
|
399
370
|
}
|
|
@@ -497,7 +468,10 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
497
468
|
const diskOver = state.diskOver || overLimit(diskMb, brief.diskMb);
|
|
498
469
|
|
|
499
470
|
const status = reportStatus(
|
|
500
|
-
state.greenAtStart ||
|
|
471
|
+
state.greenAtStart ||
|
|
472
|
+
overTime ||
|
|
473
|
+
diskOver ||
|
|
474
|
+
unavailableReason(state.sandbox) !== undefined,
|
|
501
475
|
accepted,
|
|
502
476
|
outside
|
|
503
477
|
);
|
|
@@ -510,6 +484,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
510
484
|
diskMb,
|
|
511
485
|
minutes,
|
|
512
486
|
outside,
|
|
487
|
+
sandbox: state.sandbox,
|
|
513
488
|
status,
|
|
514
489
|
});
|
|
515
490
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
8
|
+
import { Value } from "typebox/value";
|
|
9
|
+
|
|
10
|
+
import { readConfig } from "../bridge/config.ts";
|
|
11
|
+
|
|
12
|
+
// Which sandbox a delegated child's shell commands run in, and how a command
|
|
13
|
+
// is wrapped for it. The policy (which paths a command may write, which stay
|
|
14
|
+
// read-only inside them) is the same on every OS. Each backend turns it into
|
|
15
|
+
// a wrapped command string.
|
|
16
|
+
|
|
17
|
+
const run = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
const SANDBOX = "/usr/bin/sandbox-exec";
|
|
20
|
+
|
|
21
|
+
// Only root can write these, unlike the mise and npm directories on PATH,
|
|
22
|
+
// which a child can write, so a child cannot swap in a bwrap of its own.
|
|
23
|
+
const BWRAP_PATHS = ["/usr/bin/bwrap", "/usr/local/bin/bwrap"];
|
|
24
|
+
|
|
25
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
const ProbeFailureSchema = Type.Object({
|
|
28
|
+
code: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
|
29
|
+
message: Type.Optional(Type.String()),
|
|
30
|
+
stderr: Type.Optional(Type.String()),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type Sandbox =
|
|
34
|
+
| { kind: "bubblewrap"; bwrap: string }
|
|
35
|
+
| { kind: "off" }
|
|
36
|
+
| { kind: "sandbox-exec" }
|
|
37
|
+
| { kind: "unavailable"; reason: string };
|
|
38
|
+
|
|
39
|
+
const quote = (text: string): string => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
40
|
+
|
|
41
|
+
export const real = (target: string): string => {
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(target);
|
|
44
|
+
} catch {
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Tool caches and installs stay writable, so uv, npm and mise work.
|
|
50
|
+
const cacheRoots = (): string[] => {
|
|
51
|
+
const home = homedir();
|
|
52
|
+
|
|
53
|
+
return [
|
|
54
|
+
path.join(home, ".cache"),
|
|
55
|
+
path.join(home, ".npm"),
|
|
56
|
+
path.join(home, ".local", "share", "uv"),
|
|
57
|
+
path.join(home, ".local", "share", "mise"),
|
|
58
|
+
];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const writableRoots = (workdir: string, gitDir: string | undefined) =>
|
|
62
|
+
[
|
|
63
|
+
workdir,
|
|
64
|
+
gitDir,
|
|
65
|
+
...cacheRoots(),
|
|
66
|
+
path.join(homedir(), "Library", "Caches"),
|
|
67
|
+
"/private/var/folders",
|
|
68
|
+
tmpdir(),
|
|
69
|
+
"/dev",
|
|
70
|
+
]
|
|
71
|
+
.filter((root): root is string => root !== undefined)
|
|
72
|
+
.flatMap((root) => [root, real(root)]);
|
|
73
|
+
|
|
74
|
+
// Hooks and config in the git dir run later, outside the sandbox, when the
|
|
75
|
+
// parent commits or merges the child's work, so they stay read-only.
|
|
76
|
+
const gitDenies = (gitDir: string | undefined): string[] =>
|
|
77
|
+
gitDir === undefined
|
|
78
|
+
? []
|
|
79
|
+
: [gitDir, real(gitDir)].flatMap((dir) => [
|
|
80
|
+
`(subpath ${JSON.stringify(path.join(dir, "hooks"))})`,
|
|
81
|
+
`(literal ${JSON.stringify(path.join(dir, "config"))})`,
|
|
82
|
+
`(literal ${JSON.stringify(path.join(dir, "config.lock"))})`,
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const sandboxProfile = (
|
|
86
|
+
roots: readonly string[],
|
|
87
|
+
denies: readonly string[]
|
|
88
|
+
): string =>
|
|
89
|
+
[
|
|
90
|
+
"(version 1)",
|
|
91
|
+
"(allow default)",
|
|
92
|
+
"(deny file-write*)",
|
|
93
|
+
`(allow file-write* ${[...new Set(roots)].map((root) => `(subpath ${JSON.stringify(root)})`).join(" ")})`,
|
|
94
|
+
...(denies.length === 0
|
|
95
|
+
? []
|
|
96
|
+
: [`(deny file-write* ${[...new Set(denies)].join(" ")})`]),
|
|
97
|
+
].join("");
|
|
98
|
+
|
|
99
|
+
// bwrap creates a missing bind target on the host, so only paths that exist
|
|
100
|
+
// are bound, each once, where it really is.
|
|
101
|
+
const existing = (paths: readonly string[]): string[] => [
|
|
102
|
+
...new Set(
|
|
103
|
+
paths.flatMap((target) => (existsSync(target) ? [real(target)] : []))
|
|
104
|
+
),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// A read-only config also stops `git config`: its rename of config.lock over
|
|
108
|
+
// config fails on the mount, so config.lock needs no bind of its own.
|
|
109
|
+
const bwrapArgs = (
|
|
110
|
+
bwrap: string,
|
|
111
|
+
command: string,
|
|
112
|
+
workdir: string,
|
|
113
|
+
gitDir: string | undefined
|
|
114
|
+
): string[] => [
|
|
115
|
+
bwrap,
|
|
116
|
+
"--ro-bind",
|
|
117
|
+
"/",
|
|
118
|
+
"/",
|
|
119
|
+
"--dev-bind",
|
|
120
|
+
"/dev",
|
|
121
|
+
"/dev",
|
|
122
|
+
"--proc",
|
|
123
|
+
"/proc",
|
|
124
|
+
...existing(writableRoots(workdir, gitDir)).flatMap((root) =>
|
|
125
|
+
root === "/dev" ? [] : ["--bind", root, root]
|
|
126
|
+
),
|
|
127
|
+
...existing(
|
|
128
|
+
gitDir === undefined
|
|
129
|
+
? []
|
|
130
|
+
: [path.join(gitDir, "hooks"), path.join(gitDir, "config")]
|
|
131
|
+
).flatMap((deny) => ["--ro-bind", deny, deny]),
|
|
132
|
+
"--",
|
|
133
|
+
"/bin/bash",
|
|
134
|
+
"-c",
|
|
135
|
+
command,
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
// One run of bwrap tells whether it is installed and may make the user
|
|
139
|
+
// namespace it needs. Docker's default seccomp, Ubuntu 24.04's AppArmor
|
|
140
|
+
// default, and WSL1 all stop it here.
|
|
141
|
+
const probeBwrap = async (bwrap: string): Promise<string | undefined> => {
|
|
142
|
+
try {
|
|
143
|
+
await run(bwrap, ["--ro-bind", "/", "/", "--", "/bin/true"], {
|
|
144
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return undefined;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const failed = Value.Check(ProbeFailureSchema, error) ? error : {};
|
|
150
|
+
|
|
151
|
+
return `bwrap cannot start: ${(failed.stderr || failed.message || "no output").trim()}`;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// A missing root can't be bound, and a missing hooks directory could be
|
|
156
|
+
// created by the child, so both are made on the host first. They are empty
|
|
157
|
+
// directories, so git and the tools see no change.
|
|
158
|
+
const prepareBinds = (gitDir: string | undefined): void => {
|
|
159
|
+
for (const root of cacheRoots()) {
|
|
160
|
+
mkdirSync(root, { recursive: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (gitDir !== undefined) {
|
|
164
|
+
mkdirSync(path.join(gitDir, "hooks"), { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Run once per child, before its first command. "childSandbox": "off" in the
|
|
169
|
+
// config wins on every platform.
|
|
170
|
+
export const chooseSandbox = async (
|
|
171
|
+
gitDir: string | undefined
|
|
172
|
+
): Promise<Sandbox> => {
|
|
173
|
+
if (readConfig().childSandbox === "off") {
|
|
174
|
+
return { kind: "off" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (process.platform === "darwin" && existsSync(SANDBOX)) {
|
|
178
|
+
return { kind: "sandbox-exec" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (process.platform !== "linux") {
|
|
182
|
+
return {
|
|
183
|
+
kind: "unavailable",
|
|
184
|
+
reason: `no sandbox for ${process.platform}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const bwrap = BWRAP_PATHS.find((candidate) => existsSync(candidate));
|
|
189
|
+
|
|
190
|
+
if (bwrap === undefined) {
|
|
191
|
+
return {
|
|
192
|
+
kind: "unavailable",
|
|
193
|
+
reason: `bwrap is not installed in ${BWRAP_PATHS.join(" or ")}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const problem = await probeBwrap(bwrap);
|
|
198
|
+
|
|
199
|
+
if (problem !== undefined) {
|
|
200
|
+
return { kind: "unavailable", reason: problem };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
prepareBinds(gitDir);
|
|
204
|
+
|
|
205
|
+
return { bwrap, kind: "bubblewrap" };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Undefined when no sandbox runs: the caller must not run the command.
|
|
209
|
+
export const wrap = (
|
|
210
|
+
sandbox: Sandbox | undefined,
|
|
211
|
+
command: string,
|
|
212
|
+
workdir: string,
|
|
213
|
+
gitDir: string | undefined
|
|
214
|
+
): string | undefined => {
|
|
215
|
+
switch (sandbox?.kind) {
|
|
216
|
+
case "sandbox-exec": {
|
|
217
|
+
return `${SANDBOX} -p ${quote(sandboxProfile(writableRoots(workdir, gitDir), gitDenies(gitDir)))} /bin/bash -c ${quote(command)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case "bubblewrap": {
|
|
221
|
+
return bwrapArgs(sandbox.bwrap, command, workdir, gitDir)
|
|
222
|
+
.map(quote)
|
|
223
|
+
.join(" ");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
case "off": {
|
|
227
|
+
return command;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
default: {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Why no sandbox runs, or undefined when one does (or "off" was chosen).
|
|
237
|
+
export const unavailableReason = (
|
|
238
|
+
sandbox: Sandbox | undefined
|
|
239
|
+
): string | undefined => {
|
|
240
|
+
if (sandbox === undefined) {
|
|
241
|
+
return "not chosen yet";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return sandbox.kind === "unavailable" ? sandbox.reason : undefined;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export const sandboxLabel = (sandbox: Sandbox | undefined): string => {
|
|
248
|
+
const reason = unavailableReason(sandbox);
|
|
249
|
+
|
|
250
|
+
return reason === undefined
|
|
251
|
+
? (sandbox?.kind ?? "")
|
|
252
|
+
: `unavailable (${reason})`;
|
|
253
|
+
};
|