@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.
- package/README.md +4 -2
- package/package.json +4 -6
- package/src/bwrap/core.ts +279 -0
- package/src/bwrap/index.ts +44 -829
- package/src/bwrap/runtime.ts +296 -0
- package/src/claude-code/common.ts +29 -0
- package/src/claude-code/files.ts +320 -0
- package/src/claude-code/index.ts +15 -0
- package/src/claude-code/search.ts +218 -0
- package/src/claude-code/session-tools.ts +201 -0
- package/src/claude-code/shell.ts +107 -0
- package/src/{opencode-edit.ts → opencode/edit.ts} +2 -2
- package/src/opencode/index.ts +43 -0
- package/src/{opencode-todo.ts → opencode/todo.ts} +1 -1
- package/src/{opencode-write.ts → opencode/write.ts} +1 -1
- package/src/session-name.ts +2 -5
- package/src/spawn-agent.ts +3 -3
- package/src/workspace-guard.ts +1 -1
- package/src/lib/jsonc.ts +0 -67
- /package/src/{opencode-edit-engine.ts → opencode/edit-engine.ts} +0 -0
- /package/src/{question.ts → opencode/question.ts} +0 -0
- /package/src/{opencode-read.ts → opencode/read.ts} +0 -0
package/src/bwrap/index.ts
CHANGED
|
@@ -1,588 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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("&", "&")
|
|
472
|
-
.replaceAll("<", "<")
|
|
473
|
-
.replaceAll(">", ">")
|
|
474
|
-
.replaceAll('"', """);
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
}
|