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 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
- The child sandbox uses the macOS `sandbox-exec` command, so the shell sandbox for delegated children works on macOS only.
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.2.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"
@@ -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
  },
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
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 macOS
23
- // sandbox around every shell command) and only to its listed files (write and
24
- // edit), its time and disk limits, and a report line the code writes from
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
- "/bin/bash",
290
- ["-c", sandboxed(command, workdir)],
291
- { cwd: workdir, maxBuffer: 16 * 1024 * 1024, timeout: seconds * 1000 }
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 = sandboxed(input.command, ctx.cwd);
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 || overTime || diskOver,
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
+ };