@trim21/personal-pi-extensions 0.0.216 → 0.0.222

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.
@@ -1,588 +1,38 @@
1
- /**
2
- * bwrap Sandbox Extension
3
- *
4
- * Wraps all bash commands in bubblewrap (bwrap) for OS-level sandboxing.
5
- * No external dependencies — uses only Node.js built-ins.
6
- *
7
- * System requirements: bwrap (bubblewrap) must be installed.
8
- * - Debian/Ubuntu: apt install bubblewrap
9
- * - Arch: pacman -S bubblewrap
10
- * - Fedora: dnf install bubblewrap
11
- *
12
- * ## Modes
13
- *
14
- * Four modes, switchable at runtime:
15
- *
16
- * allow-all No sandbox. Network allowed. All commands run natively.
17
- * workspace-write Sandbox enabled, network blocked. Project dir + /tmp writable.
18
- * Model can request full access via bash tool parameter.
19
- * allow-net Sandbox enabled, network allowed. Project dir + /tmp writable.
20
- * readonly Sandbox enabled, network blocked, no writable paths.
21
- *
22
- * ## Escalation
23
- *
24
- * The bash tool is re-registered with `request_full_access` and
25
- * `request_full_access_reason` parameters. When full access is needed,
26
- * the model must explain why (e.g., network required, writing outside workspace).
27
- *
28
- * Models should try sandbox mode first when unsure. If the command fails due to
29
- * sandbox restrictions, retry with full access and provide the failure reason.
30
- *
31
- * Config files (merged, project takes precedence):
32
- * - ~/.pi/agent/extensions/bwrap.json (global)
33
- * - .pi/bwrap.json (project-local)
34
- *
35
- * Example .pi/bwrap.json:
36
- * ```json
37
- * {
38
- * "mode": "workspace-write",
39
- * "writablePaths": [".", "/tmp"],
40
- * "tmpfsPaths": [],
41
- * "extraArgs": []
42
- * }
43
- * ```
44
- *
45
- * Commands:
46
- * /bwrap Show current mode and paths
47
- * /bwrap-allow-all Full access, sandbox off
48
- * /bwrap-workspace-write Sandbox on, workspace writable
49
- * /bwrap-allow-net Sandbox on, network on, workspace writable
50
- * /bwrap-readonly Sandbox on, no writes
51
- *
52
- * Usage:
53
- * pi -e ./bwrap
54
- * pi -e ./bwrap --no-bwrap
55
- */
56
-
57
- import type { ChildProcess } from "node:child_process";
58
- import { spawn } from "node:child_process";
59
- import { constants } from "node:fs";
60
- import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
61
- import { access as fsAccess } from "node:fs/promises";
62
- import { delimiter, join } from "node:path";
63
- import { fileURLToPath } from "node:url";
64
-
65
- import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
66
- import { type BashOperations, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
67
- import { type TObject, Type } from "typebox";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createBashTool } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
68
4
  import { Value } from "typebox/value";
69
5
 
70
- import { type CommandSpec, parseCommand } from "../lib/cli.js";
71
- import { expandHome } from "../lib/path.js";
72
-
73
- const SANDBOX_PROMPT = `
74
- ## Command Execution
75
- You are running inside a sandbox.
76
-
77
- Three sandbox modes exist:
78
- - allow-all: sandbox off, network on, full access
79
- - readonly: sandbox on, network off, nothing writable
80
- - workspace-write: sandbox on, network off, only workspace and /tmp writable
81
- - allow-net: sandbox on, network on, only workspace and /tmp writable
82
-
83
- In workspace-write, allow-net and readonly modes, the bash tool has a
84
- \`request_full_access\` boolean parameter.
85
- Set it to true to request execution outside the sandbox.
86
- The user must approve.
87
-
88
- When requesting full access, you MUST also provide
89
- a \`request_full_access_reason\` string explaining why:
90
- - What specific operation requires escaping the sandbox
91
- - e.g. "needs network to install npm packages",
92
- "needs to write to /etc/hosts which is outside the workspace"
93
-
94
- In addition to root files system, .git, .pi, and .agent directories inside workspace
95
- are still read-only even in workspace-write mode.
96
- Git operations that change git status (add, commit, push, etc.)
97
- require request_full_access: true.
98
-
99
- the \`request_full_access\` is only needed for:
100
- - Writing to paths outside the configured writable directories,
101
- - Operations requiring network access when the current mode blocks it
102
- (workspace-write or readonly; allow-all and allow-net already have network).
103
- Writing inside the workspace or /tmp, or reading any file, does not require escalation,
104
- for example, the simple \`ls\`, \`cat\`, \`find\` or \`grep\` and git command that only read from .git directory but not change .git directory and other read only commands.
105
- **If the command is readonly operator, do not use \`request_full_access\`**
106
-
107
- **Strategy for uncertain cases**: if you are not sure whether a command will
108
- work inside the sandbox, run it WITHOUT full access first. If it fails with
109
- "Read-only file system", "Permission denied", "Network is unreachable", or
110
- "Could not resolve host", then retry with \`request_full_access: true\`
111
- and set \`request_full_access_reason\` to describe the failure.
112
- `;
113
-
114
- const HEADLESS_SANDBOX_PROMPT = `
115
- ## Command Execution (headless sandbox)
116
-
117
- You are running inside a read-only sandbox without an interactive UI.
118
-
119
- - The bash tool is read-only: no filesystem writes, no network access, and
120
- \`request_full_access\` is denied. Do not pass \`request_full_access\`.
121
- - Use the write/edit tools for file changes inside your workspace.
122
- - Writes outside the workspace are rejected because no UI is available for
123
- approval.
124
- - If a command truly requires network or system-level access, explain that it
125
- cannot be approved in this session.
126
- `;
127
-
128
- const PROTECTED_DIRS = [".git", ".pi", ".agent"];
129
-
130
- const bwrapPath = findDefaultBwrap();
131
-
132
- function findDefaultBwrap(): string {
133
- const pathEnv = process.env.PATH ?? "";
134
- for (const dir of pathEnv.split(delimiter)) {
135
- const p = join(dir, "bwrap");
136
- if (existsSync(p)) return p;
137
- }
138
-
139
- const candidates = ["/usr/bin/bwrap", "/usr/local/bin/bwrap", "/run/current-system/sw/bin/bwrap"];
140
- for (const p of candidates) {
141
- if (existsSync(p)) return p;
142
- }
143
-
144
- throw new Error(
145
- "bwrap (bubblewrap) not found in PATH. Install it:\n" +
146
- " apt install bubblewrap (Debian/Ubuntu)\n" +
147
- " pacman -S bubblewrap (Arch)\n" +
148
- " dnf install bubblewrap (Fedora)",
149
- );
150
- }
151
-
152
- function findBwrap(override?: string): string {
153
- return override ?? bwrapPath;
154
- }
155
-
156
- type BwrapMode = "allow-all" | "workspace-write" | "allow-net" | "readonly";
157
-
158
- export interface BwrapConfig {
159
- mode: BwrapMode;
160
- bwrapPath?: string;
161
- writablePaths?: string[];
162
- extraWritablePaths: string[];
163
- tmpfsPaths?: string[];
164
- extraArgs?: string[];
165
- }
166
-
167
- export interface ResolvedBwrap {
168
- mode: BwrapMode;
169
- bwrapEnabled: boolean;
170
- network: boolean;
171
- bwrapPath?: string;
172
- writablePaths: string[];
173
- extraWritablePaths: string[];
174
- tmpfsPaths: string[];
175
- extraArgs: string[];
176
- }
177
-
178
- export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
179
- const base = {
180
- mode: config.mode,
181
- bwrapPath: config.bwrapPath,
182
- writablePaths: config.writablePaths ?? [".", "/tmp"],
183
- extraWritablePaths: config.extraWritablePaths,
184
- tmpfsPaths: config.tmpfsPaths ?? [],
185
- extraArgs: config.extraArgs ?? [],
186
- };
187
- switch (config.mode) {
188
- case "allow-all": {
189
- return { ...base, bwrapEnabled: false, network: true };
190
- }
191
- case "workspace-write": {
192
- return { ...base, bwrapEnabled: true, network: false };
193
- }
194
- case "allow-net": {
195
- return { ...base, bwrapEnabled: true, network: true };
196
- }
197
- case "readonly": {
198
- return { ...base, bwrapEnabled: true, network: false, writablePaths: [] };
199
- }
200
- }
201
- }
202
-
203
- /**
204
- * Headless sessions are forced read-only regardless of config: no writable
205
- * paths (including configured extraWritablePaths), no network, and no
206
- * user-supplied extra args that could add writable mounts.
207
- */
208
- export function resolveHeadlessBwrap(config: BwrapConfig): ResolvedBwrap {
209
- return resolveBwrap({
210
- ...config,
211
- mode: "readonly",
212
- writablePaths: [],
213
- extraWritablePaths: [],
214
- tmpfsPaths: [],
215
- extraArgs: [],
216
- });
217
- }
218
-
219
- const DEFAULT_CONFIG: BwrapConfig = {
220
- mode: "workspace-write",
221
- writablePaths: [".", "/tmp"],
222
- extraWritablePaths: [],
223
- tmpfsPaths: [],
224
- extraArgs: [],
225
- };
226
-
227
- function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapConfig {
228
- return {
229
- mode: overrides.mode ?? base.mode,
230
- bwrapPath: overrides.bwrapPath ?? base.bwrapPath,
231
- writablePaths: overrides.writablePaths ?? base.writablePaths,
232
- extraWritablePaths: [...base.extraWritablePaths, ...(overrides.extraWritablePaths ?? [])],
233
- tmpfsPaths: overrides.tmpfsPaths ?? base.tmpfsPaths,
234
- extraArgs: overrides.extraArgs ?? base.extraArgs,
235
- };
236
- }
237
-
238
- function resolvePath(p: string, cwd: string): string {
239
- const expanded = expandHome(p);
240
- if (expanded === ".") return cwd;
241
- return expanded;
242
- }
243
-
244
- function loadConfig(cwd: string): BwrapConfig {
245
- const globalConfigPath = join(getAgentDir(), "extensions", "bwrap.json");
246
- const projectConfigPath = join(cwd, ".pi", "bwrap.json");
247
-
248
- const globalConfig: Partial<BwrapConfig> = {};
249
- const projectConfig: Partial<BwrapConfig> = {};
250
-
251
- for (const [path, target] of [
252
- [globalConfigPath, globalConfig],
253
- [projectConfigPath, projectConfig],
254
- ] as const) {
255
- if (existsSync(path)) {
256
- try {
257
- Object.assign(target, JSON.parse(readFileSync(path, "utf8")));
258
- } catch (error) {
259
- // eslint-disable-next-line no-console -- config parse warnings go to stderr
260
- console.error(`Warning: Could not parse ${path}: ${String(error)}`);
261
- }
262
- }
263
- }
264
-
265
- return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
266
- }
267
-
268
- function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): string[] {
269
- // Process-isolation flags placed before filesystem mounts.
270
- // --new-session Escape the parent TTY (no Ctrl-C leakage)
271
- // --die-with-parent Auto-SIGTERM child when the bwrap parent exits
272
- // --unshare-user User namespace (root-inside-ns ≠ host root)
273
- // --unshare-pid PID namespace (kill(-1) confined to sandbox)
274
- const args: string[] = ["--new-session", "--die-with-parent", "--unshare-user", "--unshare-pid"];
275
-
276
- for (const path of resolved.writablePaths) {
277
- const r = resolvePath(path, cwd);
278
- args.push("--bind", r, r);
279
- }
280
- for (const path of resolved.extraWritablePaths) {
281
- const r = resolvePath(path, cwd);
282
- args.push("--bind", r, r);
283
- }
284
- for (const path of resolved.tmpfsPaths) {
285
- const r = resolvePath(path, cwd);
286
- args.push("--tmpfs", r);
287
- }
288
-
289
- if (!resolved.network) {
290
- args.push("--unshare-net");
291
- }
292
-
293
- // Ro-bind protected dirs inside the workspace to override writable parent mounts
294
- for (const name of PROTECTED_DIRS) {
295
- const abs = join(cwd, name);
296
- if (existsSync(abs)) {
297
- args.push("--ro-bind", abs, abs);
298
- }
299
- }
300
-
301
- args.push(...resolved.extraArgs);
302
- return args;
303
- }
304
-
305
- // ── Lazy seccomp filter cache ────────────────────────────────────────
306
- // BPF bytecode is deterministic per architecture — shipped as static
307
- // .bpf files alongside the extension (src/bwrap/seccomp-<arch>.bpf).
308
- // At runtime we pick the right file and open it per-exec.
309
-
310
- const SECCOMP_BPF_FILE: string = (() => {
311
- const dir = fileURLToPath(new URL(".", import.meta.url));
312
- if (process.arch === "x64") return join(dir, "seccomp-x86_64.bpf");
313
- if (process.arch === "arm64") return join(dir, "seccomp-aarch64.bpf");
314
- return "";
315
- })();
316
-
317
- function getSeccompFd(): number | undefined {
318
- if (!SECCOMP_BPF_FILE) return undefined;
319
- try {
320
- return openSync(SECCOMP_BPF_FILE, "r");
321
- } catch {
322
- return undefined;
323
- }
324
- }
325
-
326
- function createBwrapBashOps(resolved: ResolvedBwrap): BashOperations {
327
- return {
328
- async exec(command, cwd: string, { onData, signal, timeout }) {
329
- const bwrapArgs = buildBwrapArgs(resolved, cwd);
330
- try {
331
- await fsAccess(cwd, constants.F_OK);
332
- } catch {
333
- throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
334
- }
335
- if (signal?.aborted) {
336
- throw new Error("aborted");
337
- }
338
-
339
- // ── seccomp: block AF_UNIX + network syscalls ───────────────
340
- // bwrap --unshare-net handles IP; seccomp closes the UNIX socket
341
- // gap (Docker CLI, mysqld, etc.). Filter is generated once.
342
- const seccompFd = resolved.network ? undefined : getSeccompFd();
343
-
344
- const baseArgs: string[] = [
345
- "--ro-bind",
346
- "/",
347
- "/",
348
- ...bwrapArgs,
349
- "--dev",
350
- "/dev",
351
- "--proc",
352
- "/proc",
353
- ];
354
-
355
- // Two spawn paths so TypeScript can infer the correct child type.
356
- const child: ChildProcess =
357
- seccompFd === undefined
358
- ? spawn(findBwrap(resolved.bwrapPath), [...baseArgs, "--", "bash", "-c", command], {
359
- cwd,
360
- detached: true,
361
- stdio: ["ignore", "pipe", "pipe"],
362
- env: process.env,
363
- })
364
- : spawn(
365
- findBwrap(resolved.bwrapPath),
366
- [...baseArgs, "--seccomp", "3", "--", "bash", "-c", command],
367
- {
368
- cwd,
369
- detached: true,
370
- stdio: ["ignore", "pipe", "pipe", seccompFd],
371
- env: process.env,
372
- },
373
- );
374
-
375
- return new Promise((resolve, reject) => {
376
- let timedOut = false;
377
- let timeoutHandle: NodeJS.Timeout | undefined;
378
-
379
- if (timeout !== undefined && timeout > 0) {
380
- timeoutHandle = setTimeout(() => {
381
- timedOut = true;
382
- if (child.pid) {
383
- try {
384
- process.kill(-child.pid, "SIGKILL");
385
- } catch {
386
- child.kill("SIGKILL");
387
- }
388
- }
389
- }, timeout * 1000);
390
- }
391
-
392
- child.stdout?.on("data", onData);
393
- child.stderr?.on("data", onData);
394
-
395
- child.on("error", (err) => {
396
- if (timeoutHandle) clearTimeout(timeoutHandle);
397
- reject(err);
398
- });
399
-
400
- const onAbort = () => {
401
- if (child.pid) {
402
- try {
403
- process.kill(-child.pid, "SIGKILL");
404
- } catch {
405
- child.kill("SIGKILL");
406
- }
407
- }
408
- };
409
-
410
- signal?.addEventListener("abort", onAbort, { once: true });
411
-
412
- // ── Signal forwarding ──────────────────────────────────────
413
- // With --new-session + detached, terminal signals (Ctrl-C) only
414
- // reach the parent. Forward SIGHUP/SIGINT/SIGTERM to the bwrap
415
- // child so the sandboxed command can react before the parent
416
- // (potentially) exits. --die-with-parent already covers the
417
- // case where the parent actually dies.
418
- const forwardedSignals: NodeJS.Signals[] = ["SIGHUP", "SIGINT", "SIGTERM"];
419
- const signalForwarders: (() => void)[] = [];
420
-
421
- for (const sig of forwardedSignals) {
422
- const handler = () => {
423
- if (child.pid) {
424
- try {
425
- process.kill(-child.pid, sig);
426
- } catch {
427
- child.kill(sig);
428
- }
429
- }
430
- // Remove ourselves and re-raise so the default handler runs.
431
- process.removeListener(sig, handler);
432
- process.kill(process.pid, sig);
433
- };
434
- process.on(sig, handler);
435
- signalForwarders.push(() => process.removeListener(sig, handler));
436
- }
437
-
438
- child.on("close", (code) => {
439
- if (timeoutHandle) clearTimeout(timeoutHandle);
440
- signal?.removeEventListener("abort", onAbort);
441
-
442
- // Remove signal forwarders now that the child is gone.
443
- for (const unforward of signalForwarders) {
444
- unforward();
445
- }
446
-
447
- // Close the per-exec seccomp fd (temp file is reused).
448
- if (seccompFd !== undefined) {
449
- try {
450
- closeSync(seccompFd);
451
- } catch {
452
- /* ignore */
453
- }
454
- }
455
-
456
- if (signal?.aborted) {
457
- reject(new Error("aborted"));
458
- } else if (timedOut) {
459
- reject(new Error(`timeout:${timeout}`));
460
- } else {
461
- resolve({ exitCode: code });
462
- }
463
- });
464
- });
465
- },
466
- };
467
- }
468
-
469
- function escapeHtml(text: string): string {
470
- return text
471
- .replaceAll("&", "&amp;")
472
- .replaceAll("<", "&lt;")
473
- .replaceAll(">", "&gt;")
474
- .replaceAll('"', "&quot;");
475
- }
476
-
477
- /**
478
- * Count the longest consecutive backtick run in a string.
479
- */
480
- function maxConsecutiveBackticks(text: string): number {
481
- let maxCount = 0;
482
- let currentCount = 0;
483
- for (const ch of text) {
484
- if (ch === "`") {
485
- currentCount++;
486
- if (currentCount > maxCount) maxCount = currentCount;
487
- } else {
488
- currentCount = 0;
489
- }
490
- }
491
- return maxCount;
492
- }
493
-
494
- /**
495
- * Wrap code in fenced code blocks for literal plain-text rendering.
496
- * Uses N+1 backticks for the fence where N is the longest consecutive
497
- * backtick sequence in the code, so no escaping is needed.
498
- */
499
- function fenceCodeBlock(code: string): string {
500
- const fenceLen = Math.max(3, maxConsecutiveBackticks(code) + 1);
501
- const fence = "`".repeat(fenceLen);
502
- return `${fence}\n${code}\n${fence}`;
503
- }
504
-
505
- function notifyMode(
506
- ctx: { ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } },
507
- mode: BwrapMode,
508
- ) {
509
- const labels: Record<BwrapMode, string> = {
510
- "allow-all": "allow-all: sandbox off, network on",
511
- "workspace-write": "workspace-write: sandbox on, network off",
512
- "allow-net": "allow-net: sandbox on, network on, workspace writable",
513
- readonly: "readonly: sandbox on, network off, read-only fs",
514
- };
515
- ctx.ui.notify(labels[mode], "info");
516
- }
517
-
518
- export type EscalationDecision = { kind: "dialog" } | { kind: "deny"; reason: string };
519
-
520
- /**
521
- * Escalation (`request_full_access`) policy:
522
- * - Headless sessions are denied because there is no user to approve.
523
- * - Sessions with UI require the user approval dialog.
524
- */
525
- export function resolveEscalation(opts: { hasUI: boolean }): EscalationDecision {
526
- if (!opts.hasUI) {
527
- return {
528
- kind: "deny",
529
- reason:
530
- "request_full_access requires an interactive session with user approval; no UI is available in this session.",
531
- };
532
- }
533
- return { kind: "dialog" };
534
- }
535
-
536
- const sandboxedBashSchema = Type.Object({
537
- command: Type.String({ description: "Bash command to execute" }),
538
- timeout: Type.Optional(
539
- Type.Number({
540
- description: "Timeout in seconds (optional, no default timeout)",
541
- }),
542
- ),
543
- request_full_access: Type.Optional(
544
- Type.Boolean({
545
- description:
546
- "Set to true to run the command without sandbox, the command will get full fs write permission and network access. The user will review this command and user must approve this. Do not set this if your command doesn't write any file and doesn't need network access.",
547
- }),
548
- ),
549
- request_full_access_reason: Type.Optional(
550
- Type.String({
551
- description:
552
- "Required when request_full_access is true. Explain why the command needs full access outside the sandbox (e.g. 'needs network for npm install', 'must write to /etc/config outside workspace').",
553
- }),
554
- ),
555
- });
556
-
557
- export default function bwrapExtension(pi: ExtensionAPI) {
558
- pi.registerFlag("no-bwrap", {
559
- description: "Disable bwrap sandboxing for bash commands",
560
- type: "boolean",
561
- default: false,
562
- });
563
-
564
- const localCwd = process.cwd();
565
- const localBash = createBashTool(localCwd);
566
-
567
- let resolved: ResolvedBwrap | null = null;
568
-
569
- function getResolved(hasUI: boolean): ResolvedBwrap {
570
- // Without an approval path, ignore config and force bash read-only.
571
- if (!hasUI) {
572
- return resolveHeadlessBwrap(loadConfig(localCwd));
573
- }
574
- if (!resolved) {
575
- resolved = resolveBwrap(loadConfig(localCwd));
576
- }
577
- return resolved;
578
- }
579
-
580
- function setMode(mode: BwrapMode) {
581
- const config = loadConfig(localCwd);
582
- config.mode = mode;
583
- resolved = resolveBwrap(config);
584
- }
585
-
6
+ import { bwrapRuntime } from "./runtime.js";
7
+
8
+ export {
9
+ type BwrapConfig,
10
+ resolveBwrap,
11
+ type ResolvedBwrap,
12
+ resolveHeadlessBwrap,
13
+ } from "./core.js";
14
+ export { type EscalationDecision, resolveEscalation } from "./runtime.js";
15
+
16
+ const sandboxedBashSchema = Type.Object(
17
+ {
18
+ command: Type.String({ description: "Bash command to execute" }),
19
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })),
20
+ request_full_access: Type.Optional(
21
+ Type.Boolean({
22
+ description:
23
+ "Set true to request unsandboxed execution. The bwrap runtime will ask the user for approval.",
24
+ }),
25
+ ),
26
+ request_full_access_reason: Type.Optional(
27
+ Type.String({ description: "Explain why unsandboxed execution is required." }),
28
+ ),
29
+ },
30
+ { additionalProperties: false },
31
+ );
32
+
33
+ export default function bwrapExtension(pi: ExtensionAPI): void {
34
+ bwrapRuntime.setup(pi);
35
+ const localBash = createBashTool(process.cwd());
586
36
  pi.registerTool({
587
37
  name: localBash.name,
588
38
  label: "bash (bwrap)",
@@ -590,254 +40,19 @@ export default function bwrapExtension(pi: ExtensionAPI) {
590
40
  localBash.description +
591
41
  "\n\nSet request_full_access to true to request unsandboxed execution.",
592
42
  parameters: sandboxedBashSchema,
593
- prepareArguments: (args) => {
594
- return Value.Parse(sandboxedBashSchema, args);
595
- },
43
+ prepareArguments: (args) => Value.Parse(sandboxedBashSchema, args),
596
44
  executionMode: localBash.executionMode,
597
- async execute(id, params, signal, onUpdate, ctx) {
598
- const hasUI = ctx?.hasUI ?? false;
599
- const r = getResolved(hasUI);
600
-
601
- if (!r.bwrapEnabled) {
602
- return localBash.execute(id, params, signal, onUpdate);
603
- }
604
-
605
- const escalate = params.request_full_access === true;
606
-
607
- if (escalate) {
608
- const policy = resolveEscalation({ hasUI });
609
- if (policy.kind === "deny") {
610
- throw new Error(policy.reason);
611
- }
612
-
613
- if (ctx?.hasUI) {
614
- const reason = params.request_full_access_reason;
615
- const reasonText = reason
616
- ? `\n\nReason: ${escapeHtml(reason)}`
617
- : "\n\n(No reason provided by model)";
618
- const codeBlock = fenceCodeBlock(params.command);
619
- const desc = `Allow this command to run without sandbox?\n---\n${reasonText}\n---\n${codeBlock}`;
620
-
621
- let choice: string | undefined;
622
- while (!choice) {
623
- choice = await ctx.ui.select(desc, ["Approve once", "Block", "Block with reason"]);
624
- if (choice === undefined) {
625
- ctx.abort();
626
- throw new Error("User denied the command execution.");
627
- }
628
- if (choice === "Block with reason") {
629
- const feedback = await ctx.ui.input("Why was this denied?");
630
- if (feedback === undefined) {
631
- choice = undefined; // cancelled input, retry select
632
- continue;
633
- }
634
- throw new Error(
635
- feedback
636
- ? `User denied unsandboxed execution: ${feedback}`
637
- : "User denied unsandboxed execution.",
638
- );
639
- }
640
- }
641
- if (choice !== "Approve once") {
642
- throw new Error("User denied unsandboxed execution.");
643
- }
644
- }
645
- return localBash.execute(id, params, signal, onUpdate);
646
- }
647
-
648
- const sandboxedBash = createBashTool(localCwd, {
649
- operations: createBwrapBashOps(r),
45
+ execute(id, params, signal, onUpdate, ctx) {
46
+ return bwrapRuntime.execute({
47
+ toolCallId: id,
48
+ command: params.command,
49
+ timeout: params.timeout,
50
+ requestFullAccess: params.request_full_access,
51
+ requestFullAccessReason: params.request_full_access_reason,
52
+ signal,
53
+ onUpdate,
54
+ ctx,
650
55
  });
651
- return sandboxedBash.execute(id, params, signal, onUpdate);
652
56
  },
653
57
  });
654
-
655
- pi.on("session_start", (_event, ctx) => {
656
- const noBwrap = pi.getFlag("no-bwrap") === true;
657
-
658
- // Headless sessions are always sandboxed read-only because they cannot
659
- // approve disabling the sandbox.
660
- if (noBwrap && ctx.hasUI) {
661
- resolved = null;
662
- ctx.ui.notify("bwrap sandbox disabled via --no-bwrap", "warning");
663
- return;
664
- }
665
-
666
- if (!process.env.HOME) {
667
- ctx.ui.notify("bwrap requires HOME environment variable", "error");
668
- return;
669
- }
670
-
671
- if (process.platform !== "linux") {
672
- ctx.ui.notify("bwrap sandbox requires Linux", "warning");
673
- return;
674
- }
675
-
676
- const config = loadConfig(ctx.cwd);
677
- resolved = ctx.hasUI ? resolveBwrap(config) : resolveHeadlessBwrap(config);
678
-
679
- if (resolved.bwrapEnabled) {
680
- try {
681
- findBwrap(resolved.bwrapPath);
682
- } catch (error) {
683
- resolved = null;
684
- ctx.ui.notify(error instanceof Error ? error.message : "bwrap not found", "error");
685
- return;
686
- }
687
- }
688
-
689
- const r = resolved;
690
-
691
- if (!r.bwrapEnabled) {
692
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${r.mode}`));
693
- ctx.ui.notify(`bwrap mode: ${r.mode}`, "info");
694
- return;
695
- }
696
-
697
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${r.mode}`));
698
- ctx.ui.notify(`bwrap initialized (${r.mode})`, "info");
699
- });
700
-
701
- pi.on("session_shutdown", () => {
702
- resolved = null;
703
- });
704
-
705
- pi.on("before_agent_start", (event, ctx) => {
706
- const r = getResolved(ctx.hasUI);
707
-
708
- return {
709
- systemPrompt: ctx.hasUI
710
- ? event.systemPrompt + "\n\n" + SANDBOX_PROMPT + `\n\nCurrent mode: **${r.mode}**\n`
711
- : event.systemPrompt + "\n\n" + HEADLESS_SANDBOX_PROMPT,
712
- };
713
- });
714
-
715
- const BWRAP_SPEC = {
716
- name: "bwrap",
717
- usage: "",
718
- description: "Show bwrap sandbox configuration",
719
- flags: Type.Object({}),
720
- arity: { max: 0 },
721
- };
722
-
723
- const BWRAP_ALLOW_ALL_SPEC = {
724
- name: "bwrap-allow-all",
725
- usage: "",
726
- description: "Disable bwrap sandbox, full access",
727
- flags: Type.Object({}),
728
- arity: { max: 0 },
729
- };
730
-
731
- const BWRAP_WORKSPACE_WRITE_SPEC = {
732
- name: "bwrap-workspace-write",
733
- usage: "",
734
- description: "Sandbox on, network off, workspace writable",
735
- flags: Type.Object({}),
736
- arity: { max: 0 },
737
- };
738
-
739
- const BWRAP_ALLOW_NET_SPEC = {
740
- name: "bwrap-allow-net",
741
- usage: "",
742
- description: "Sandbox on, network on, workspace writable",
743
- flags: Type.Object({}),
744
- arity: { max: 0 },
745
- };
746
-
747
- const BWRAP_READONLY_SPEC = {
748
- name: "bwrap-readonly",
749
- usage: "",
750
- description: "Sandbox on, network off, no writes",
751
- flags: Type.Object({}),
752
- arity: { max: 0 },
753
- };
754
-
755
- /** Parse a /bwrap command; on help/error the text is sent to the session, otherwise run() executes. */
756
- function runBwrapCommand(
757
- spec: CommandSpec<TObject>,
758
- args: string,
759
- ctx: ExtensionCommandContext,
760
- run: (ctx: ExtensionCommandContext) => void | Promise<void>,
761
- ): Promise<void> {
762
- const parsed = parseCommand(spec, args);
763
- if (parsed.kind !== "ok") {
764
- pi.sendMessage({ customType: "info", content: parsed.text, display: true });
765
- return Promise.resolve();
766
- }
767
- return Promise.resolve(run(ctx));
768
- }
769
-
770
- pi.registerCommand("bwrap", {
771
- description: BWRAP_SPEC.description,
772
- handler: (args, ctx) =>
773
- runBwrapCommand(BWRAP_SPEC, args, ctx, (c) => {
774
- const r = getResolved(c.hasUI);
775
- if (!r.bwrapEnabled) {
776
- c.ui.notify(`bwrap disabled (mode: ${r.mode})`, "info");
777
- return;
778
- }
779
-
780
- const net = r.network ? "net" : "no-net";
781
- const w = r.writablePaths.map((p) => resolvePath(p, localCwd));
782
- const t = r.tmpfsPaths.map((p) => resolvePath(p, localCwd));
783
-
784
- c.ui.notify(
785
- `bwrap ${r.mode} ${net} write:[${w.join(", ")}] tmpfs:[${t.join(", ") || "-"}]`,
786
- "info",
787
- );
788
- }),
789
- });
790
-
791
- function switchMode(
792
- mode: BwrapMode,
793
- ctx: {
794
- ui: {
795
- notify: (m: string, t?: "info" | "warning" | "error") => void;
796
- theme: Theme;
797
- setStatus: (k: string, t: string | undefined) => void;
798
- };
799
- hasUI: boolean;
800
- },
801
- ) {
802
- if (!ctx.hasUI) {
803
- ctx.ui.notify("bwrap mode cannot be changed without an interactive UI", "warning");
804
- return;
805
- }
806
- setMode(mode);
807
-
808
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${mode}`));
809
-
810
- notifyMode(ctx, mode);
811
- pi.sendMessage({
812
- customType: "info",
813
- content: `Bwrap sandbox mode changed to "${mode}".`,
814
- display: true,
815
- });
816
- }
817
-
818
- pi.registerCommand("bwrap-allow-all", {
819
- description: BWRAP_ALLOW_ALL_SPEC.description,
820
- handler: (args, ctx) =>
821
- runBwrapCommand(BWRAP_ALLOW_ALL_SPEC, args, ctx, (c) => switchMode("allow-all", c)),
822
- });
823
-
824
- pi.registerCommand("bwrap-workspace-write", {
825
- description: BWRAP_WORKSPACE_WRITE_SPEC.description,
826
- handler: (args, ctx) =>
827
- runBwrapCommand(BWRAP_WORKSPACE_WRITE_SPEC, args, ctx, (c) =>
828
- switchMode("workspace-write", c),
829
- ),
830
- });
831
-
832
- pi.registerCommand("bwrap-allow-net", {
833
- description: BWRAP_ALLOW_NET_SPEC.description,
834
- handler: (args, ctx) =>
835
- runBwrapCommand(BWRAP_ALLOW_NET_SPEC, args, ctx, (c) => switchMode("allow-net", c)),
836
- });
837
-
838
- pi.registerCommand("bwrap-readonly", {
839
- description: BWRAP_READONLY_SPEC.description,
840
- handler: (args, ctx) =>
841
- runBwrapCommand(BWRAP_READONLY_SPEC, args, ctx, (c) => switchMode("readonly", c)),
842
- });
843
58
  }