pum-agent 0.2.0-beta.4 → 0.2.2-beta.1

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/src/main.tsx CHANGED
@@ -42,6 +42,8 @@ import {
42
42
  } from "./triggers/process";
43
43
  import type { StartupOptions } from "./cli";
44
44
  import { installSelectionClipboard } from "./clipboard";
45
+ import { TerminalTitleController } from "./terminal-title";
46
+ import { SandboxController } from "./sandbox";
45
47
 
46
48
  export async function start(options: StartupOptions): Promise<void> {
47
49
  mkdirSync(AGENT_DIR, { recursive: true });
@@ -53,6 +55,12 @@ export async function start(options: StartupOptions): Promise<void> {
53
55
  const loginRequired = options.login || (await modelRuntime.getAvailable()).length === 0;
54
56
 
55
57
  const settings = loadSettings();
58
+ const sandboxController = new SandboxController({
59
+ mode: settings.sandboxMode ?? "auto",
60
+ agentDir: AGENT_DIR,
61
+ });
62
+ const sandboxWarning = await sandboxController.startupWarning(settings.checkMode);
63
+ const sandboxExtension = sandboxController.extension();
56
64
  setWritingStyle(settings.writingStyle);
57
65
  setExplanationStrength(settings.explanationStrength);
58
66
  const questionnaireManager = new QuestionnaireManager();
@@ -129,6 +137,7 @@ export async function start(options: StartupOptions): Promise<void> {
129
137
  childExtensionFactories: [
130
138
  writingStyleExtension,
131
139
  explanationStrengthExtension,
140
+ sandboxExtension,
132
141
  ],
133
142
  childExtensionFactoriesForAgent: [(agentId) => createCheckModeExtension(modelRuntime, undefined, {
134
143
  coordinator: checkApprovalCoordinator,
@@ -154,6 +163,7 @@ export async function start(options: StartupOptions): Promise<void> {
154
163
  writingStyleExtension,
155
164
  explanationStrengthExtension,
156
165
  mainCheckModeExtension,
166
+ sandboxExtension,
157
167
  applyPatchExtension,
158
168
  questionnaireManager.extension({ id: "main", name: "main" }),
159
169
  subagentExtension,
@@ -189,6 +199,7 @@ export async function start(options: StartupOptions): Promise<void> {
189
199
  if (sessionRuntime.modelFallbackMessage) console.error(sessionRuntime.modelFallbackMessage);
190
200
 
191
201
  const renderer = await createCliRenderer({ exitOnCtrlC: false });
202
+ const terminalTitle = new TerminalTitleController((title) => renderer.setTerminalTitle(title));
192
203
  const selectionClipboard = installSelectionClipboard(renderer);
193
204
  const root = createRoot(renderer);
194
205
  const shutdown = createShutdown({
@@ -197,6 +208,7 @@ export async function start(options: StartupOptions): Promise<void> {
197
208
  root.unmount();
198
209
  },
199
210
  cleanup: () => {
211
+ terminalTitle.clear();
200
212
  selectionClipboard.dispose();
201
213
  cleanupPendingImages();
202
214
  },
@@ -233,6 +245,10 @@ export async function start(options: StartupOptions): Promise<void> {
233
245
  questionnaireManager={questionnaireManager}
234
246
  spawnPreviewManager={spawnPreviewManager}
235
247
  messageCacheController={messageCacheController}
248
+ terminalTitle={terminalTitle}
249
+ startupWarnings={sandboxWarning ? [sandboxWarning] : []}
250
+ onSandboxModeChange={(mode) => sandboxController.setMode(mode)}
251
+ sandboxWarningSource={sandboxController}
236
252
  loginRequired={loginRequired}
237
253
  checkApprovalCoordinator={checkApprovalCoordinator}
238
254
  checkApprovalStore={checkApprovalStore}
package/src/platform.ts CHANGED
@@ -64,6 +64,23 @@ export function isPathInside(
64
64
  return relative !== "" && relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative);
65
65
  }
66
66
 
67
+ export function pathIdentity(
68
+ path: string,
69
+ platform: RuntimePlatform = process.platform,
70
+ ): string {
71
+ const canonical = pathApi(platform).resolve(path);
72
+ return platform === "win32" ? canonical.toLowerCase() : canonical;
73
+ }
74
+
75
+ export function isPathInsideOrSame(
76
+ parent: string,
77
+ candidate: string,
78
+ platform: RuntimePlatform = process.platform,
79
+ ): boolean {
80
+ return pathIdentity(parent, platform) === pathIdentity(candidate, platform)
81
+ || isPathInside(parent, candidate, platform);
82
+ }
83
+
67
84
  export async function canonicalPathIdentity(
68
85
  path: string,
69
86
  platform: RuntimePlatform = process.platform,
@@ -74,6 +91,28 @@ export async function canonicalPathIdentity(
74
91
  return platform === "win32" ? canonical.toLowerCase() : canonical;
75
92
  }
76
93
 
94
+ export async function canonicalPathIdentityAllowMissing(
95
+ path: string,
96
+ platform: RuntimePlatform = process.platform,
97
+ resolvePath: (path: string) => Promise<string> = realpath,
98
+ ): Promise<string> {
99
+ const paths = pathApi(platform);
100
+ const absolute = paths.resolve(path);
101
+ const missing: string[] = [];
102
+ let existing = absolute;
103
+ while (true) {
104
+ try {
105
+ const canonical = await resolvePath(existing);
106
+ return pathIdentity(paths.resolve(canonical, ...missing.reverse()), platform);
107
+ } catch (error) {
108
+ const parent = paths.dirname(existing);
109
+ if (parent === existing || existing === paths.parse(existing).root) throw error;
110
+ missing.push(paths.basename(existing));
111
+ existing = parent;
112
+ }
113
+ }
114
+ }
115
+
77
116
  export async function pathsHaveSameIdentity(
78
117
  first: string,
79
118
  second: string,
@@ -0,0 +1,207 @@
1
+ import {
2
+ createBashTool,
3
+ getShellConfig,
4
+ SettingsManager,
5
+ type BashOperations,
6
+ type InlineExtension,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { mkdtemp, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+ import { AGENT_DIR } from "../config";
13
+ import { analyzeCheckPolicy } from "../check-policy";
14
+ import { getCheckModeConfig } from "../check-mode";
15
+ import { buildSandboxPolicy, decideSandboxMode } from "../sandbox-policy";
16
+ import type { CheckModeProfile } from "../settings";
17
+ import type { SandboxBackend, SandboxCapability, SandboxMode } from "./types";
18
+ import { createBubblewrapBackend } from "./linux";
19
+ import { createWindowsSandboxBackend } from "./windows";
20
+
21
+ export type SandboxControllerOptions = {
22
+ backend?: SandboxBackend;
23
+ platform?: NodeJS.Platform;
24
+ mode?: SandboxMode;
25
+ agentDir?: string;
26
+ };
27
+
28
+ function platformBackend(platform: NodeJS.Platform): SandboxBackend | undefined {
29
+ if (platform === "linux") return createBubblewrapBackend({ platform });
30
+ if (platform === "win32") return createWindowsSandboxBackend({ platform });
31
+ return undefined;
32
+ }
33
+
34
+ function unsupportedCapability(platform: NodeJS.Platform): SandboxCapability {
35
+ return {
36
+ state: "unavailable",
37
+ backend: platform === "win32" ? "mxc" : "bubblewrap",
38
+ reason: `Native Bash sandboxing is not supported on ${platform}`,
39
+ };
40
+ }
41
+
42
+ function findExecutable(executable: string, platform: NodeJS.Platform): string {
43
+ if (isAbsolute(executable)) return resolve(executable);
44
+ const command = platform === "win32" ? "where.exe" : "which";
45
+ const result = spawnSync(command, [executable], {
46
+ encoding: "utf8",
47
+ timeout: 5_000,
48
+ windowsHide: true,
49
+ });
50
+ const found = result.status === 0
51
+ ? result.stdout.trim().split(/\r?\n/u).find(Boolean)
52
+ : undefined;
53
+ if (!found || !isAbsolute(found)) throw new Error(`Sandbox shell executable could not be resolved: ${executable}`);
54
+ return resolve(found);
55
+ }
56
+
57
+ export class SandboxController {
58
+ readonly #backend?: SandboxBackend;
59
+ readonly #platform: NodeJS.Platform;
60
+ readonly #agentDir: string;
61
+ #mode: SandboxMode;
62
+ #probe?: Promise<SandboxCapability>;
63
+ readonly #settings = new Map<string, SettingsManager>();
64
+ readonly #warningListeners = new Set<(warning: string) => void>();
65
+ #warningEmitted = false;
66
+
67
+ constructor(options: SandboxControllerOptions = {}) {
68
+ this.#platform = options.platform ?? process.platform;
69
+ this.#backend = options.backend ?? platformBackend(this.#platform);
70
+ this.#mode = options.mode ?? "auto";
71
+ this.#agentDir = options.agentDir ?? AGENT_DIR;
72
+ }
73
+
74
+ setMode(mode: SandboxMode): void {
75
+ this.#mode = mode;
76
+ }
77
+
78
+ get mode(): SandboxMode {
79
+ return this.#mode;
80
+ }
81
+
82
+ subscribeWarnings(listener: (warning: string) => void): () => void {
83
+ this.#warningListeners.add(listener);
84
+ return () => this.#warningListeners.delete(listener);
85
+ }
86
+
87
+ #emitWarning(warning: string): void {
88
+ if (this.#warningEmitted) return;
89
+ this.#warningEmitted = true;
90
+ for (const listener of this.#warningListeners) listener(warning);
91
+ }
92
+
93
+ probe(): Promise<SandboxCapability> {
94
+ return this.#probe ??= this.#backend?.probe()
95
+ ?? Promise.resolve(unsupportedCapability(this.#platform));
96
+ }
97
+
98
+ async startupWarning(checkMode: CheckModeProfile): Promise<string | undefined> {
99
+ if (checkMode === "off") return undefined;
100
+ if (this.#mode === "off") return undefined;
101
+ const capability = await this.probe();
102
+ const decision = decideSandboxMode(this.#mode, capability);
103
+ if (decision.warning) {
104
+ this.#warningEmitted = true;
105
+ return decision.warning;
106
+ }
107
+ if (decision.action === "block") {
108
+ this.#warningEmitted = true;
109
+ return `${decision.reason}. Checked Bash commands will be blocked.`;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ extension(): InlineExtension {
115
+ const base = createBashTool(process.cwd());
116
+ const controller = this;
117
+ return {
118
+ name: "pum-native-bash-sandbox",
119
+ factory: (pi) => {
120
+ pi.registerTool({
121
+ ...base,
122
+ description: `${base.description} PUM applies native OS sandboxing when Check mode and Sandbox settings require it.`,
123
+ execute: async (id, params, signal, onUpdate, ctx) => {
124
+ const cwd = ctx.cwd;
125
+ let settings = controller.#settings.get(cwd);
126
+ if (!settings) {
127
+ settings = SettingsManager.create(cwd, controller.#agentDir);
128
+ controller.#settings.set(cwd, settings);
129
+ }
130
+ const shellPath = settings.getShellPath();
131
+ const commandPrefix = settings.getShellCommandPrefix();
132
+ const check = getCheckModeConfig();
133
+ if (check.profile === "off" || controller.#mode === "off") {
134
+ const local = createBashTool(cwd, { shellPath, commandPrefix });
135
+ return (local.execute as any)(id, params, signal, onUpdate, ctx);
136
+ }
137
+
138
+ const capability = await controller.probe();
139
+ const decision = decideSandboxMode(controller.#mode, capability);
140
+ if (decision.action === "block") throw new Error(decision.reason);
141
+ if (decision.action === "direct") {
142
+ if (decision.warning) controller.#emitWarning(decision.warning);
143
+ const local = createBashTool(cwd, { shellPath, commandPrefix });
144
+ return (local.execute as any)(id, params, signal, onUpdate, ctx);
145
+ }
146
+ if (!controller.#backend) throw new Error("Sandbox backend is unavailable");
147
+
148
+ const shell = getShellConfig(shellPath);
149
+ const executable = findExecutable(shell.shell, controller.#platform);
150
+ const operations: BashOperations = {
151
+ exec: async (command, executionCwd, options) => {
152
+ const executionCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
153
+ const result = analyzeCheckPolicy({
154
+ command: executionCommand,
155
+ cwd: executionCwd,
156
+ profile: check.profile as Exclude<typeof check.profile, "off">,
157
+ allowedPaths: check.additionalPaths,
158
+ protectedPaths: [controller.#agentDir],
159
+ });
160
+ if (result.decision === "block") {
161
+ throw new Error(`Sandbox policy hard block: ${result.reason}`);
162
+ }
163
+
164
+ const privateTemp = await mkdtemp(join(tmpdir(), "pum-sandbox-"));
165
+ const commandFromStdin = shell.commandTransport === "stdin";
166
+ const args = commandFromStdin ? [...shell.args] : [...shell.args, executionCommand];
167
+ try {
168
+ const policy = buildSandboxPolicy({
169
+ command,
170
+ executionCommand,
171
+ cwd: executionCwd,
172
+ additionalRoots: check.additionalPaths,
173
+ result,
174
+ executable,
175
+ args,
176
+ stdin: commandFromStdin,
177
+ privateTemp,
178
+ environment: options.env,
179
+ pumConfigRoot: controller.#agentDir,
180
+ platform: controller.#platform,
181
+ });
182
+ const handle = controller.#backend!.spawn(policy, {
183
+ onStdout: options.onData,
184
+ onStderr: options.onData,
185
+ signal: options.signal,
186
+ timeoutSeconds: options.timeout,
187
+ stdin: commandFromStdin ? Buffer.from(executionCommand) : undefined,
188
+ });
189
+ const completed = await handle.completed;
190
+ return { exitCode: completed.exitCode };
191
+ } finally {
192
+ await rm(privateTemp, { recursive: true, force: true }).catch(() => {});
193
+ }
194
+ },
195
+ };
196
+ const sandboxed = createBashTool(cwd, { operations });
197
+ return (sandboxed.execute as any)(id, params, signal, onUpdate, ctx);
198
+ },
199
+ });
200
+ },
201
+ };
202
+ }
203
+ }
204
+
205
+ export * from "./types";
206
+ export { createBubblewrapBackend, probeBubblewrap, buildBubblewrapArgv } from "./linux";
207
+ export { createWindowsSandboxBackend, probeWindowsSandbox, buildWindowsMxcConfig } from "./windows";
@@ -0,0 +1,362 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { posix } from "node:path";
4
+ import type {
5
+ SandboxBackend,
6
+ SandboxCapability,
7
+ SandboxPolicy,
8
+ SandboxProcessHandle,
9
+ SandboxProcessOptions,
10
+ } from "./types";
11
+
12
+ const MAX_TIMEOUT_MS = 2_147_483_647;
13
+ const DEFAULT_KILL_GRACE_MS = 250;
14
+ const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
15
+ const ENVIRONMENT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+
17
+ type BubblewrapMount = {
18
+ source: string;
19
+ target: string;
20
+ mode: "read-only" | "read-write";
21
+ };
22
+
23
+ export interface BubblewrapSpawnRequest {
24
+ executable: string;
25
+ args: readonly string[];
26
+ cwd: string;
27
+ env: NodeJS.ProcessEnv;
28
+ stdin?: Uint8Array;
29
+ onStdout: (chunk: Buffer) => void;
30
+ onStderr: (chunk: Buffer) => void;
31
+ }
32
+
33
+ export interface BubblewrapProcessResult {
34
+ exitCode: number | null;
35
+ signal: NodeJS.Signals | null;
36
+ }
37
+
38
+ export interface BubblewrapProcessHandle {
39
+ pid?: number;
40
+ completed: Promise<BubblewrapProcessResult>;
41
+ kill: (signal: NodeJS.Signals) => void;
42
+ }
43
+
44
+ export interface BubblewrapProcessAdapter {
45
+ spawn: (request: BubblewrapSpawnRequest) => BubblewrapProcessHandle;
46
+ }
47
+
48
+ export interface BubblewrapProbeOptions {
49
+ platform?: NodeJS.Platform;
50
+ executable?: string;
51
+ timeoutMs?: number;
52
+ processAdapter?: BubblewrapProcessAdapter;
53
+ }
54
+
55
+ export interface BubblewrapBackendOptions {
56
+ executable?: string;
57
+ platform?: NodeJS.Platform;
58
+ killGraceMs?: number;
59
+ processAdapter?: BubblewrapProcessAdapter;
60
+ probeTimeoutMs?: number;
61
+ systemMounts?: readonly string[];
62
+ pathKind?: (path: string) => "file" | "directory" | undefined;
63
+ }
64
+
65
+ export class NodeBubblewrapProcessAdapter implements BubblewrapProcessAdapter {
66
+ spawn(request: BubblewrapSpawnRequest): BubblewrapProcessHandle {
67
+ const child = spawn(request.executable, [...request.args], {
68
+ cwd: request.cwd,
69
+ env: request.env,
70
+ detached: true,
71
+ shell: false,
72
+ windowsHide: true,
73
+ stdio: [request.stdin ? "pipe" : "ignore", "pipe", "pipe"],
74
+ });
75
+ if (request.stdin) {
76
+ child.stdin?.on("error", () => {});
77
+ child.stdin?.end(request.stdin);
78
+ }
79
+ child.stdout?.on("data", request.onStdout);
80
+ child.stderr?.on("data", request.onStderr);
81
+
82
+ const completed = new Promise<BubblewrapProcessResult>((resolvePromise, reject) => {
83
+ child.once("error", reject);
84
+ child.once("close", (exitCode, signal) => resolvePromise({ exitCode, signal }));
85
+ });
86
+
87
+ return {
88
+ pid: child.pid,
89
+ completed,
90
+ kill(signal) {
91
+ if (!child.pid) return;
92
+ try {
93
+ process.kill(-child.pid, signal);
94
+ } catch {
95
+ child.kill(signal);
96
+ }
97
+ },
98
+ };
99
+ }
100
+ }
101
+
102
+ function assertNoNul(label: string, value: string): void {
103
+ if (value.includes("\0")) throw new Error(`${label} contains NUL`);
104
+ }
105
+
106
+ function normalizeAbsolutePath(label: string, value: string): string {
107
+ assertNoNul(label, value);
108
+ if (!posix.isAbsolute(value)) throw new Error(`${label} must be an absolute path: ${value}`);
109
+ return posix.normalize(value);
110
+ }
111
+
112
+ function isWithin(root: string, value: string): boolean {
113
+ const child = posix.relative(root, value);
114
+ return child === "" || (!child.startsWith("../") && child !== ".." && !posix.isAbsolute(child));
115
+ }
116
+
117
+ function defaultSystemMounts(): string[] {
118
+ return ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/nix"].filter(existsSync);
119
+ }
120
+
121
+ function sandboxEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
122
+ const result: NodeJS.ProcessEnv = {};
123
+ for (const [key, value] of Object.entries(environment)) {
124
+ if (!ENVIRONMENT_KEY.test(key)) throw new Error(`Invalid environment variable name: ${key}`);
125
+ if (value !== undefined) {
126
+ assertNoNul(`Environment variable ${key}`, value);
127
+ result[key] = value;
128
+ }
129
+ }
130
+ return result;
131
+ }
132
+
133
+ function addMount(
134
+ mounts: BubblewrapMount[],
135
+ targetIndexes: Map<string, number>,
136
+ mount: Omit<BubblewrapMount, "target"> & { target?: string },
137
+ ): void {
138
+ const source = normalizeAbsolutePath("Mount source", mount.source);
139
+ const target = normalizeAbsolutePath("Mount target", mount.target ?? mount.source);
140
+ const normalized = { source, target, mode: mount.mode };
141
+ const prior = targetIndexes.get(target);
142
+ if (prior === undefined) {
143
+ targetIndexes.set(target, mounts.length);
144
+ mounts.push(normalized);
145
+ } else {
146
+ mounts[prior] = normalized;
147
+ }
148
+ }
149
+
150
+ function policyMounts(policy: SandboxPolicy, systemMounts?: readonly string[]): BubblewrapMount[] {
151
+ const mounts: BubblewrapMount[] = [];
152
+ const targetIndexes = new Map<string, number>();
153
+ for (const path of systemMounts ?? defaultSystemMounts()) {
154
+ addMount(mounts, targetIndexes, { source: path, mode: "read-only" });
155
+ }
156
+ for (const path of policy.readOnlyPaths) {
157
+ addMount(mounts, targetIndexes, { source: path, mode: "read-only" });
158
+ }
159
+ for (const path of policy.readWritePaths) {
160
+ addMount(mounts, targetIndexes, { source: path, mode: "read-write" });
161
+ }
162
+ return mounts;
163
+ }
164
+
165
+ function defaultPathKind(path: string): "file" | "directory" | undefined {
166
+ try {
167
+ const stat = statSync(path);
168
+ return stat.isDirectory() ? "directory" : "file";
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+
174
+ function deniedPathArgs(
175
+ paths: readonly string[],
176
+ pathKind: (path: string) => "file" | "directory" | undefined,
177
+ ): string[] {
178
+ const args: string[] = [];
179
+ const seen = new Set<string>();
180
+ for (const rawPath of paths) {
181
+ const path = normalizeAbsolutePath("Denied path", rawPath);
182
+ if (seen.has(path)) continue;
183
+ seen.add(path);
184
+ const kind = pathKind(path);
185
+ if (kind === "directory") args.push("--tmpfs", path);
186
+ else args.push("--ro-bind", "/dev/null", path);
187
+ }
188
+ return args;
189
+ }
190
+
191
+ /** Build the complete Bubblewrap argv from an authoritative sandbox policy. */
192
+ export function buildBubblewrapArgv(
193
+ policy: SandboxPolicy,
194
+ options: Pick<BubblewrapBackendOptions, "systemMounts" | "pathKind"> = {},
195
+ ): string[] {
196
+ if (policy.version !== 1) throw new Error(`Unsupported sandbox policy version: ${policy.version}`);
197
+ assertNoNul("Exact command", policy.exactCommand);
198
+ const cwd = normalizeAbsolutePath("Working directory", policy.cwd);
199
+ const executable = normalizeAbsolutePath("Command executable", policy.executable);
200
+ for (const argument of policy.args) assertNoNul("Command argument", argument);
201
+ const privateTemp = normalizeAbsolutePath("Private temp", policy.privateTemp);
202
+ const mounts = policyMounts(policy, options.systemMounts);
203
+ if (!mounts.some((mount) => isWithin(mount.source, cwd))) {
204
+ throw new Error(`Working directory is not visible in the sandbox: ${cwd}`);
205
+ }
206
+
207
+ const args = [
208
+ "--die-with-parent",
209
+ "--new-session",
210
+ "--unshare-user",
211
+ "--unshare-pid",
212
+ "--unshare-ipc",
213
+ "--unshare-uts",
214
+ "--cap-drop", "ALL",
215
+ "--proc", "/proc",
216
+ "--dev", "/dev",
217
+ ];
218
+ if (policy.network === "deny") args.push("--unshare-net");
219
+ for (const mount of mounts) {
220
+ args.push(mount.mode === "read-only" ? "--ro-bind" : "--bind", mount.source, mount.target);
221
+ }
222
+ // Keep private and denied mounts last so earlier allow mounts cannot expose them.
223
+ args.push("--tmpfs", privateTemp);
224
+ args.push(...deniedPathArgs(policy.deniedPaths, options.pathKind ?? defaultPathKind));
225
+ args.push("--chdir", cwd, "--", executable, ...policy.args);
226
+ return args;
227
+ }
228
+
229
+ function timeoutMilliseconds(timeoutSeconds: number | undefined): number | undefined {
230
+ if (timeoutSeconds === undefined) return undefined;
231
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
232
+ throw new Error("Invalid timeout: must be a finite number of seconds");
233
+ }
234
+ const milliseconds = timeoutSeconds * 1000;
235
+ if (milliseconds > MAX_TIMEOUT_MS) throw new Error("Invalid timeout: value is too large");
236
+ return milliseconds;
237
+ }
238
+
239
+ export async function probeBubblewrap(options: BubblewrapProbeOptions = {}): Promise<SandboxCapability> {
240
+ const executable = options.executable ?? "bwrap";
241
+ if ((options.platform ?? process.platform) !== "linux") {
242
+ return { state: "unavailable", backend: "bubblewrap", reason: "Bubblewrap requires Linux" };
243
+ }
244
+
245
+ const adapter = options.processAdapter ?? new NodeBubblewrapProcessAdapter();
246
+ const probeExecutable = existsSync("/bin/true") ? "/bin/true" : "/usr/bin/true";
247
+ const probeMountArgs = defaultSystemMounts().flatMap((path) => ["--ro-bind", path, path]);
248
+ const output: Buffer[] = [];
249
+ const child = adapter.spawn({
250
+ executable,
251
+ cwd: "/",
252
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin" },
253
+ args: [
254
+ "--die-with-parent",
255
+ "--new-session",
256
+ "--unshare-user",
257
+ "--unshare-pid",
258
+ "--unshare-ipc",
259
+ "--unshare-uts",
260
+ "--unshare-net",
261
+ "--cap-drop", "ALL",
262
+ "--proc", "/proc",
263
+ "--dev", "/dev",
264
+ ...probeMountArgs,
265
+ "--", probeExecutable,
266
+ ],
267
+ onStdout: (chunk) => output.push(chunk),
268
+ onStderr: (chunk) => output.push(chunk),
269
+ });
270
+ try {
271
+ const handle = managedSandboxHandle(child, {
272
+ onStdout: () => {},
273
+ onStderr: () => {},
274
+ timeoutSeconds: (options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS) / 1000,
275
+ }, DEFAULT_KILL_GRACE_MS);
276
+ const result = await handle.completed;
277
+ if (result.exitCode === 0) return { state: "enforced", backend: "bubblewrap" };
278
+ const detail = Buffer.concat(output).toString("utf8").trim();
279
+ return {
280
+ state: "unavailable",
281
+ backend: "bubblewrap",
282
+ reason: detail || `Bubblewrap probe exited with code ${result.exitCode}`,
283
+ };
284
+ } catch (error) {
285
+ const reason = error instanceof Error ? error.message : String(error);
286
+ return {
287
+ state: reason.includes("ENOENT") ? "unavailable" : "error",
288
+ backend: "bubblewrap",
289
+ reason,
290
+ };
291
+ }
292
+ }
293
+
294
+ function managedSandboxHandle(
295
+ child: BubblewrapProcessHandle,
296
+ options: SandboxProcessOptions,
297
+ killGraceMs: number,
298
+ ): SandboxProcessHandle {
299
+ let stoppedBy: "abort" | "timeout" | undefined;
300
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
301
+ let killHandle: ReturnType<typeof setTimeout> | undefined;
302
+
303
+ const stop = (reason: "abort" | "timeout") => {
304
+ if (stoppedBy) return;
305
+ stoppedBy = reason;
306
+ child.kill("SIGTERM");
307
+ killHandle = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
308
+ };
309
+ const onAbort = () => stop("abort");
310
+ options.signal?.addEventListener("abort", onAbort, { once: true });
311
+ const timeoutMs = timeoutMilliseconds(options.timeoutSeconds);
312
+ if (timeoutMs !== undefined) timeoutHandle = setTimeout(() => stop("timeout"), timeoutMs);
313
+
314
+ const completed = child.completed.then((result) => {
315
+ if (stoppedBy === "abort" || options.signal?.aborted) throw new Error("aborted");
316
+ if (stoppedBy === "timeout") throw new Error(`timeout:${options.timeoutSeconds}`);
317
+ return { exitCode: result.exitCode, signal: result.signal };
318
+ }).finally(() => {
319
+ if (timeoutHandle) clearTimeout(timeoutHandle);
320
+ if (killHandle) clearTimeout(killHandle);
321
+ options.signal?.removeEventListener("abort", onAbort);
322
+ });
323
+
324
+ return { completed, kill: () => stop("abort") };
325
+ }
326
+
327
+ /** Create the reusable Linux Bubblewrap backend for the native sandbox layer. */
328
+ export function createBubblewrapBackend(options: BubblewrapBackendOptions = {}): SandboxBackend {
329
+ const executable = options.executable ?? "bwrap";
330
+ const adapter = options.processAdapter ?? new NodeBubblewrapProcessAdapter();
331
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
332
+ if (!Number.isFinite(killGraceMs) || killGraceMs < 0) {
333
+ throw new Error("Kill grace must be a non-negative number");
334
+ }
335
+
336
+ return {
337
+ id: "bubblewrap",
338
+ probe: () => probeBubblewrap({
339
+ executable,
340
+ platform: options.platform,
341
+ timeoutMs: options.probeTimeoutMs,
342
+ processAdapter: adapter,
343
+ }),
344
+ spawn(policy, processOptions) {
345
+ if (processOptions.signal?.aborted) throw new Error("aborted");
346
+ const args = buildBubblewrapArgv(policy, {
347
+ systemMounts: options.systemMounts,
348
+ pathKind: options.pathKind,
349
+ });
350
+ const child = adapter.spawn({
351
+ executable,
352
+ args,
353
+ cwd: policy.cwd,
354
+ env: sandboxEnvironment(policy.environment),
355
+ stdin: processOptions.stdin,
356
+ onStdout: (chunk) => processOptions.onStdout(chunk),
357
+ onStderr: (chunk) => processOptions.onStderr(chunk),
358
+ });
359
+ return managedSandboxHandle(child, processOptions, killGraceMs);
360
+ },
361
+ };
362
+ }