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