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/README.md +43 -8
- package/package.json +3 -2
- package/src/app.tsx +49 -8
- package/src/check-mode.ts +4 -1
- package/src/check-mutation.ts +28 -16
- package/src/check-paths.ts +16 -12
- package/src/check-policy.ts +423 -28
- package/src/main.tsx +16 -0
- package/src/platform.ts +39 -0
- package/src/sandbox/index.ts +207 -0
- package/src/sandbox/linux.ts +362 -0
- package/src/sandbox/types.ts +62 -0
- package/src/sandbox/windows.ts +327 -0
- package/src/sandbox-policy.ts +204 -0
- package/src/settings-popup.tsx +2 -0
- package/src/settings.ts +10 -0
- package/src/terminal-title.ts +41 -0
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
|
+
}
|