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.
@@ -0,0 +1,62 @@
1
+ import type { CheckPathAccessMode } from "../check-policy";
2
+
3
+ export type SandboxMode = "auto" | "require" | "off";
4
+ export type SandboxCapabilityState = "enforced" | "unavailable" | "error";
5
+ export type SandboxBackendId = "bubblewrap" | "mxc";
6
+ export type SandboxNetwork = "deny" | "host";
7
+
8
+ export type SandboxCapability = {
9
+ state: SandboxCapabilityState;
10
+ backend: SandboxBackendId;
11
+ reason?: string;
12
+ };
13
+
14
+ export type SandboxPolicyAccess = {
15
+ resolvedPath: string;
16
+ mode: CheckPathAccessMode;
17
+ source: "operand" | "redirection" | "executable";
18
+ stage: number;
19
+ external: boolean;
20
+ };
21
+
22
+ /** Canonical policy derived from the approved command and authoritative PUM state. */
23
+ export type SandboxPolicy = {
24
+ version: 1;
25
+ exactCommand: string;
26
+ cwd: string;
27
+ readOnlyPaths: string[];
28
+ readWritePaths: string[];
29
+ deniedPaths: string[];
30
+ privateTemp: string;
31
+ environment: Record<string, string>;
32
+ executable: string;
33
+ args: string[];
34
+ network: SandboxNetwork;
35
+ rationale: string;
36
+ accesses: SandboxPolicyAccess[];
37
+ networkCommands?: string[];
38
+ };
39
+
40
+ export type SandboxProcessExit = {
41
+ exitCode: number | null;
42
+ signal: string | null;
43
+ };
44
+
45
+ export type SandboxProcessOptions = {
46
+ onStdout(chunk: Uint8Array): void;
47
+ onStderr(chunk: Uint8Array): void;
48
+ signal?: AbortSignal;
49
+ timeoutSeconds?: number;
50
+ stdin?: Uint8Array;
51
+ };
52
+
53
+ export type SandboxProcessHandle = {
54
+ completed: Promise<SandboxProcessExit>;
55
+ kill(): void;
56
+ };
57
+
58
+ export interface SandboxBackend {
59
+ readonly id: SandboxBackendId;
60
+ probe(): Promise<SandboxCapability>;
61
+ spawn(policy: SandboxPolicy, options: SandboxProcessOptions): SandboxProcessHandle;
62
+ }
@@ -0,0 +1,327 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { delimiter, join, win32 } from "node:path";
3
+ import type {
4
+ ContainerConfig,
5
+ PlatformSupport,
6
+ SandboxPolicy as MxcPolicy,
7
+ } from "@microsoft/mxc-sdk";
8
+ import type {
9
+ SandboxBackend,
10
+ SandboxCapability,
11
+ SandboxPolicy,
12
+ SandboxProcessHandle,
13
+ SandboxProcessOptions,
14
+ } from "./types.js";
15
+
16
+ type MxcSdk = typeof import("@microsoft/mxc-sdk");
17
+ type MxcSdkLoader = () => Promise<MxcSdk>;
18
+
19
+ const REQUIRED_UI_CAPABILITIES = [
20
+ "canBlockClipboardRead",
21
+ "canBlockClipboardWrite",
22
+ "canBlockInputInjection",
23
+ "canBlockInputMethodChanges",
24
+ "canBlockExternalUiObjects",
25
+ "canBlockGlobalUiNamespace",
26
+ "canBlockDesktopSwitching",
27
+ "canBlockLogoffOrShutdown",
28
+ "canBlockSystemParameterChanges",
29
+ "canBlockDisplaySettingsChanges",
30
+ ] as const;
31
+
32
+ const loadMxcSdk: MxcSdkLoader = async () => {
33
+ // MXC 0.7 resolves `whoami /user` through a shell during module import.
34
+ // Prefer the native Windows binary so a Git/MSYS PATH does not select GNU whoami.
35
+ const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
36
+ const previous = process.env[pathKey];
37
+ const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
38
+ if (systemRoot) process.env[pathKey] = [join(systemRoot, "System32"), previous].filter(Boolean).join(delimiter);
39
+ try {
40
+ return await import("@microsoft/mxc-sdk");
41
+ } finally {
42
+ if (previous === undefined) delete process.env[pathKey];
43
+ else process.env[pathKey] = previous;
44
+ }
45
+ };
46
+
47
+ function errorMessage(error: unknown): string {
48
+ return error instanceof Error ? error.message : String(error);
49
+ }
50
+
51
+ function unavailable(reason: string): SandboxCapability {
52
+ return { state: "unavailable", backend: "mxc", reason };
53
+ }
54
+
55
+ function failed(reason: string): SandboxCapability {
56
+ return { state: "error", backend: "mxc", reason };
57
+ }
58
+
59
+ function validateNativeSupport(support: PlatformSupport): SandboxCapability {
60
+ if (!support.isSupported || !support.availableMethods.includes("processcontainer")) {
61
+ return unavailable(support.reason || "MXC ProcessContainer is not available");
62
+ }
63
+ if (!support.isolationTier) {
64
+ return unavailable("The MXC native ProcessContainer probe did not return an isolation tier");
65
+ }
66
+ if (support.isolationTier !== "base-container") {
67
+ return unavailable(
68
+ `MXC BaseContainer/CreateProcessInSandbox is unavailable (reported tier: ${support.isolationTier}). ` +
69
+ "PUM does not use the AppContainer DACL fallback because it can change host ACLs.",
70
+ );
71
+ }
72
+ const ui = support.uiCapabilities;
73
+ if (!ui) {
74
+ return unavailable("The MXC native probe did not return UI restriction capabilities");
75
+ }
76
+ const missing = REQUIRED_UI_CAPABILITIES.filter((capability) => !ui[capability]);
77
+ if (missing.length > 0) {
78
+ return unavailable(`MXC cannot enforce required UI restrictions: ${missing.join(", ")}`);
79
+ }
80
+ return { state: "enforced", backend: "mxc" };
81
+ }
82
+
83
+ /**
84
+ * Quote one argv element for the Windows CreateProcess command-line grammar.
85
+ * This is the inverse convention used by CommandLineToArgvW and the Microsoft C runtime.
86
+ */
87
+ export function quoteWindowsArgument(argument: string): string {
88
+ if (argument.length > 0 && !/[\s"]/u.test(argument)) return argument;
89
+
90
+ let quoted = '"';
91
+ let backslashes = 0;
92
+ for (const character of argument) {
93
+ if (character === "\\") {
94
+ backslashes += 1;
95
+ continue;
96
+ }
97
+ if (character === '"') {
98
+ quoted += "\\".repeat(backslashes * 2 + 1);
99
+ quoted += '"';
100
+ backslashes = 0;
101
+ continue;
102
+ }
103
+ quoted += "\\".repeat(backslashes);
104
+ quoted += character;
105
+ backslashes = 0;
106
+ }
107
+ quoted += "\\".repeat(backslashes * 2);
108
+ return `${quoted}"`;
109
+ }
110
+
111
+ /** MXC currently accepts only one command-line string, not executable and argv separately. */
112
+ export function quoteWindowsCommandLine(executable: string, args: readonly string[]): string {
113
+ if (!executable) throw new Error("Sandbox executable is required");
114
+ return [quoteWindowsArgument(executable), ...args.map(quoteWindowsArgument)].join(" ");
115
+ }
116
+
117
+ function uniqueWindowsPaths(paths: readonly string[]): string[] {
118
+ const seen = new Set<string>();
119
+ const result: string[] = [];
120
+ for (const path of paths) {
121
+ const identity = path.replace(/[\\/]+$/u, "").toLowerCase();
122
+ if (seen.has(identity)) continue;
123
+ seen.add(identity);
124
+ result.push(path);
125
+ }
126
+ return result;
127
+ }
128
+
129
+ function windowsRuntimePaths(executable: string): string[] {
130
+ const executableDirectory = win32.dirname(executable);
131
+ return win32.basename(executableDirectory).toLowerCase() === "bin"
132
+ ? [win32.dirname(executableDirectory)]
133
+ : [executableDirectory];
134
+ }
135
+
136
+ function sandboxEnvironment(policy: SandboxPolicy): string[] {
137
+ const entries = Object.entries(policy.environment).filter(
138
+ ([name]) => name.toUpperCase() !== "TEMP" && name.toUpperCase() !== "TMP",
139
+ );
140
+ entries.push(["TEMP", policy.privateTemp], ["TMP", policy.privateTemp]);
141
+ return entries.map(([name, value]) => `${name}=${value}`);
142
+ }
143
+
144
+ /** Build the exact stable MXC 0.7 ProcessContainer configuration used for a run. */
145
+ export function buildWindowsMxcConfig(
146
+ sdk: Pick<MxcSdk, "createConfigFromPolicy">,
147
+ policy: SandboxPolicy,
148
+ timeoutSeconds?: number,
149
+ ): ContainerConfig {
150
+ const timeoutMs = timeoutSeconds === undefined ? 0 : Math.ceil(timeoutSeconds * 1000);
151
+ if (timeoutSeconds !== undefined && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
152
+ throw new Error("Invalid timeout: must be a finite number of seconds");
153
+ }
154
+ if (timeoutMs > 2_147_483_647) {
155
+ throw new Error("Invalid timeout: maximum is 2147483.647 seconds");
156
+ }
157
+
158
+ const mxcPolicy: MxcPolicy = {
159
+ version: "0.7.0-alpha",
160
+ filesystem: {
161
+ readonlyPaths: uniqueWindowsPaths([...policy.readOnlyPaths, ...windowsRuntimePaths(policy.executable)]),
162
+ readwritePaths: uniqueWindowsPaths([...policy.readWritePaths, policy.privateTemp]),
163
+ deniedPaths: uniqueWindowsPaths(policy.deniedPaths),
164
+ clearPolicyOnExit: true,
165
+ },
166
+ network: {
167
+ allowOutbound: policy.network === "host",
168
+ allowLocalNetwork: policy.network === "host",
169
+ },
170
+ ui: {
171
+ allowWindows: false,
172
+ clipboard: "none",
173
+ allowInputInjection: false,
174
+ },
175
+ timeoutMs,
176
+ };
177
+
178
+ // SDK 0.7 declares concrete backends, but its implementation builds Windows
179
+ // ProcessContainer only through the abstract "process" intent.
180
+ const config = sdk.createConfigFromPolicy(mxcPolicy, "process");
181
+ config.process = {
182
+ commandLine: quoteWindowsCommandLine(policy.executable, policy.args),
183
+ cwd: policy.cwd,
184
+ env: sandboxEnvironment(policy),
185
+ timeout: timeoutMs,
186
+ };
187
+ config.lifecycle = { destroyOnExit: true, preservePolicy: false };
188
+ config.processContainer = {
189
+ ...(config.processContainer ?? {}),
190
+ leastPrivilege: true,
191
+ capabilities:
192
+ policy.network === "host" ? ["internetClient", "privateNetworkClientServer"] : [],
193
+ ui: {
194
+ isolation: "container",
195
+ desktopSystemControl: false,
196
+ systemSettings: "none",
197
+ ime: false,
198
+ },
199
+ };
200
+ if (config.network) config.network.enforcementMode = "capabilities";
201
+ return config;
202
+ }
203
+
204
+ export async function probeWindowsSandbox(
205
+ loader: MxcSdkLoader = loadMxcSdk,
206
+ platform: NodeJS.Platform = process.platform,
207
+ ): Promise<SandboxCapability> {
208
+ if (platform !== "win32") return unavailable("MXC ProcessContainer requires Windows");
209
+ let sdk: MxcSdk;
210
+ try {
211
+ sdk = await loader();
212
+ } catch (error) {
213
+ return unavailable(`Optional dependency @microsoft/mxc-sdk is unavailable: ${errorMessage(error)}`);
214
+ }
215
+ try {
216
+ return validateNativeSupport(sdk.getPlatformSupport());
217
+ } catch (error) {
218
+ return failed(`MXC native availability probe failed: ${errorMessage(error)}`);
219
+ }
220
+ }
221
+
222
+ function startSandboxProcess(
223
+ sdk: MxcSdk,
224
+ policy: SandboxPolicy,
225
+ options: SandboxProcessOptions,
226
+ onChild: (child: ChildProcess) => void,
227
+ ): Promise<{ exitCode: number | null; signal: string | null }> {
228
+ return new Promise((resolve, reject) => {
229
+ let child: ChildProcess;
230
+ try {
231
+ const config = buildWindowsMxcConfig(sdk, policy, options.timeoutSeconds);
232
+ child = sdk.spawnSandboxFromConfig(config, { usePty: false }, policy.cwd) as ChildProcess;
233
+ onChild(child);
234
+ } catch (error) {
235
+ reject(error);
236
+ return;
237
+ }
238
+
239
+ let timedOut = false;
240
+ let stderrTail = "";
241
+ let timeout: ReturnType<typeof setTimeout> | undefined;
242
+ const kill = () => child.kill();
243
+ const onAbort = () => kill();
244
+ if (options.timeoutSeconds !== undefined) {
245
+ timeout = setTimeout(() => {
246
+ timedOut = true;
247
+ kill();
248
+ // MXC enforces process.timeout inside the sandbox and tears down its
249
+ // Windows Job Object. This watchdog is only for a stuck executor.
250
+ }, options.timeoutSeconds * 1000 + 5_000);
251
+ }
252
+ if (options.signal) {
253
+ if (options.signal.aborted) onAbort();
254
+ else options.signal.addEventListener("abort", onAbort, { once: true });
255
+ }
256
+
257
+ child.stdout?.on("data", (chunk: Buffer) => options.onStdout(chunk));
258
+ child.stderr?.on("data", (chunk: Buffer) => {
259
+ options.onStderr(chunk);
260
+ stderrTail = `${stderrTail}${chunk.toString("utf8")}`.slice(-8_192);
261
+ });
262
+ child.once("error", reject);
263
+ child.once("close", (exitCode, signal) => {
264
+ if (timeout) clearTimeout(timeout);
265
+ options.signal?.removeEventListener("abort", onAbort);
266
+ if (options.signal?.aborted) {
267
+ reject(new Error("aborted"));
268
+ return;
269
+ }
270
+ if (
271
+ timedOut ||
272
+ (exitCode !== 0 && /(?:script|process|command) timed out after \d+ms/iu.test(stderrTail))
273
+ ) {
274
+ reject(new Error(`timeout:${options.timeoutSeconds}`));
275
+ return;
276
+ }
277
+ resolve({ exitCode, signal });
278
+ });
279
+
280
+ child.stdin?.on("error", () => {});
281
+ child.stdin?.end(options.stdin ? Buffer.from(options.stdin) : undefined);
282
+ });
283
+ }
284
+
285
+ export class MxcSandboxBackend implements SandboxBackend {
286
+ readonly id = "mxc" as const;
287
+ readonly #loader: MxcSdkLoader;
288
+ readonly #platform: NodeJS.Platform;
289
+
290
+ constructor(options: { loader?: MxcSdkLoader; platform?: NodeJS.Platform } = {}) {
291
+ this.#loader = options.loader ?? loadMxcSdk;
292
+ this.#platform = options.platform ?? process.platform;
293
+ }
294
+
295
+ probe(): Promise<SandboxCapability> {
296
+ return probeWindowsSandbox(this.#loader, this.#platform);
297
+ }
298
+
299
+ spawn(policy: SandboxPolicy, options: SandboxProcessOptions): SandboxProcessHandle {
300
+ let child: ChildProcess | undefined;
301
+ let killRequested = false;
302
+ const completed = (async () => {
303
+ if (this.#platform !== "win32") throw new Error("MXC ProcessContainer requires Windows");
304
+ if (options.signal?.aborted) throw new Error("aborted");
305
+ const sdk = await this.#loader();
306
+ const capability = validateNativeSupport(sdk.getPlatformSupport());
307
+ if (capability.state !== "enforced") throw new Error(capability.reason);
308
+ return startSandboxProcess(sdk, policy, options, (spawned) => {
309
+ child = spawned;
310
+ if (killRequested) child.kill();
311
+ });
312
+ })();
313
+ return {
314
+ completed,
315
+ kill: () => {
316
+ killRequested = true;
317
+ child?.kill();
318
+ },
319
+ };
320
+ }
321
+ }
322
+
323
+ export function createWindowsSandboxBackend(
324
+ options: { loader?: MxcSdkLoader; platform?: NodeJS.Platform } = {},
325
+ ): SandboxBackend {
326
+ return new MxcSandboxBackend(options);
327
+ }
@@ -0,0 +1,204 @@
1
+ import { homedir } from "node:os";
2
+ import { posix, win32 } from "node:path";
3
+ import { AGENT_DIR } from "./config";
4
+ import type { CheckPolicyResult } from "./check-policy";
5
+ import { isPathInsideOrSame, pathIdentity, type RuntimePlatform } from "./platform";
6
+ import type {
7
+ SandboxCapability,
8
+ SandboxMode,
9
+ SandboxPolicy,
10
+ SandboxPolicyAccess,
11
+ } from "./sandbox/types";
12
+
13
+ export type BuildSandboxPolicyOptions = {
14
+ /** Exact command accepted by Check mode and any required user approval. */
15
+ command: string;
16
+ /** Authoritative configured prefix plus command actually passed to the shell. */
17
+ executionCommand?: string;
18
+ /** Authoritative project working directory. */
19
+ cwd: string;
20
+ /** Canonical Check mode roots for the launch project. */
21
+ additionalRoots?: readonly string[];
22
+ /** Deterministic Check mode result for the exact command. */
23
+ result: CheckPolicyResult;
24
+ /** Resolved shell executable. A backend must not select a shell. */
25
+ executable: string;
26
+ /** Complete shell arguments. For stdin-transport shells the command is supplied separately. */
27
+ args: readonly string[];
28
+ /** The resolved shell receives the exact command on stdin instead of argv. */
29
+ stdin?: boolean;
30
+ /** Private temporary directory prepared by the controller. */
31
+ privateTemp: string;
32
+ environment?: Readonly<Record<string, string | undefined>>;
33
+ pumConfigRoot?: string;
34
+ home?: string;
35
+ platform?: RuntimePlatform;
36
+ };
37
+
38
+ const SENSITIVE_ENVIRONMENT = /(?:^|_)(?:API_?KEY|AUTH|BEARER|COOKIE|CREDENTIALS?|PASS(?:WORD)?|PRIVATE_?KEY|SECRET|SESSION|TOKEN)(?:_|$)/i;
39
+ const INJECTION_ENVIRONMENT = new Set([
40
+ "BASH_ENV", "BUN_OPTIONS", "ENV", "GIT_CONFIG_COUNT", "NODE_OPTIONS", "PERL5OPT",
41
+ "PROMPT_COMMAND", "PYTHONINSPECT", "PYTHONPATH", "RUBYOPT", "SHELLOPTS", "ZDOTDIR",
42
+ ]);
43
+
44
+ export function isSandboxEnvironmentVariableDenied(name: string): boolean {
45
+ const upper = name.toUpperCase();
46
+ return SENSITIVE_ENVIRONMENT.test(upper)
47
+ || INJECTION_ENVIRONMENT.has(upper)
48
+ || upper.startsWith("AWS_")
49
+ || upper.startsWith("AZURE_")
50
+ || upper.startsWith("GOOGLE_")
51
+ || upper.startsWith("GITHUB_")
52
+ || upper.startsWith("NPM_")
53
+ || upper.startsWith("PUM_")
54
+ || upper.startsWith("PI_SESSION_");
55
+ }
56
+
57
+ /** Remove credentials, PUM/session metadata, and runtime injection variables. */
58
+ export function sanitizeSandboxEnvironment(
59
+ environment: Readonly<Record<string, string | undefined>>,
60
+ ): Record<string, string> {
61
+ return Object.fromEntries(Object.entries(environment)
62
+ .filter(([name, value]) => value !== undefined
63
+ && !name.includes("\0")
64
+ && !isSandboxEnvironmentVariableDenied(name))
65
+ .map(([name, value]) => [name, value!] as const)
66
+ .sort(([first], [second]) => first.localeCompare(second)));
67
+ }
68
+
69
+ function pathApi(path: string, platform: RuntimePlatform): typeof posix {
70
+ return platform === "win32" || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\")
71
+ ? win32
72
+ : posix;
73
+ }
74
+
75
+ function canonicalPath(path: string, cwd: string, platform: RuntimePlatform): string {
76
+ const paths = pathApi(cwd, platform);
77
+ const absolute = paths.isAbsolute(path) ? path : paths.resolve(cwd, path);
78
+ return pathIdentity(absolute, platform);
79
+ }
80
+
81
+ function uniquePaths(paths: readonly string[], platform: RuntimePlatform): string[] {
82
+ return [...new Set(paths.map((path) => pathIdentity(path, platform)))]
83
+ .sort((first, second) => first.localeCompare(second));
84
+ }
85
+
86
+ /** Remove narrower paths when a root with the same permission already contains them. */
87
+ function collapseSamePermissionRoots(paths: readonly string[], platform: RuntimePlatform): string[] {
88
+ const unique = uniquePaths(paths, platform);
89
+ return unique.filter((candidate) => !unique.some((parent) => (
90
+ parent !== candidate && isPathInsideOrSame(parent, candidate, platform)
91
+ )));
92
+ }
93
+
94
+ function deniedCredentialPaths(roots: readonly string[], home: string, platform: RuntimePlatform): string[] {
95
+ const directoryNames = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker"];
96
+ const fileNames = [
97
+ ".env", ".git-credentials", ".npmrc", ".pypirc", ".netrc", "auth.json", "credentials.json",
98
+ ];
99
+ const denied: string[] = [];
100
+ for (const root of [...roots, home]) {
101
+ const paths = pathApi(root, platform);
102
+ denied.push(...directoryNames.map((name) => paths.join(root, name)));
103
+ denied.push(...fileNames.map((name) => paths.join(root, name)));
104
+ }
105
+ if (platform !== "win32") {
106
+ denied.push("/root", "/etc/shadow", "/etc/gshadow", "/etc/sudoers", "/etc/sudoers.d", "/etc/ssh");
107
+ }
108
+ return uniquePaths(denied, platform);
109
+ }
110
+
111
+ function canonicalAccesses(
112
+ result: CheckPolicyResult,
113
+ cwd: string,
114
+ platform: RuntimePlatform,
115
+ ): SandboxPolicyAccess[] {
116
+ return result.accesses.map(({ resolvedPath, mode, source, stage, external }) => ({
117
+ resolvedPath: canonicalPath(resolvedPath, cwd, platform),
118
+ mode,
119
+ source,
120
+ stage,
121
+ external,
122
+ }));
123
+ }
124
+
125
+ /** Build a backend-neutral policy from authoritative inputs and deterministic Check mode output. */
126
+ export function buildSandboxPolicy(options: BuildSandboxPolicyOptions): SandboxPolicy {
127
+ const platform = options.platform ?? process.platform;
128
+ if (!options.command || options.command.includes("\0")) throw new Error("Sandbox command is invalid");
129
+ const executionCommand = options.executionCommand ?? options.command;
130
+ if (options.result.exactCommand !== executionCommand) throw new Error("Sandbox command does not match the Check mode analysis");
131
+ if (!options.executable || options.executable.includes("\0")) throw new Error("Sandbox executable is invalid");
132
+ if (!pathApi(options.cwd, platform).isAbsolute(options.executable)) throw new Error("Sandbox executable must be resolved");
133
+ if (options.args.some((argument) => argument.includes("\0"))) throw new Error("Sandbox arguments are invalid");
134
+ if (!options.stdin && !options.args.includes(executionCommand)) {
135
+ throw new Error("Sandbox arguments must contain the exact command");
136
+ }
137
+ if (!options.result.analysis.complete || options.result.analysis.truncated || !options.result.analysis.syntaxBalanced) {
138
+ throw new Error("Sandbox policy requires complete Check mode analysis");
139
+ }
140
+ if (options.result.decision === "block") {
141
+ throw new Error(`Sandbox policy cannot grant a blocked command: ${options.result.reason}`);
142
+ }
143
+
144
+ const cwd = canonicalPath(options.cwd, options.cwd, platform);
145
+ const readWritePaths = collapseSamePermissionRoots([
146
+ cwd,
147
+ ...(options.additionalRoots ?? []).map((root) => canonicalPath(root, cwd, platform)),
148
+ ], platform);
149
+ const accesses = canonicalAccesses(options.result, cwd, platform);
150
+ const readOnlyPaths = collapseSamePermissionRoots(accesses
151
+ .filter((access) => access.external && access.mode === "read")
152
+ .map((access) => access.resolvedPath)
153
+ .filter((path) => !readWritePaths.some((root) => isPathInsideOrSame(root, path, platform))), platform);
154
+ const deniedPaths = uniquePaths([
155
+ canonicalPath(options.pumConfigRoot ?? AGENT_DIR, cwd, platform),
156
+ ...deniedCredentialPaths(readWritePaths, options.home ?? homedir(), platform),
157
+ ], platform);
158
+
159
+ const privateTemp = canonicalPath(options.privateTemp, cwd, platform);
160
+ const environment = sanitizeSandboxEnvironment(options.environment ?? process.env);
161
+ environment.TEMP = privateTemp;
162
+ environment.TMP = privateTemp;
163
+ if (platform !== "win32") environment.TMPDIR = privateTemp;
164
+
165
+ return {
166
+ version: 1,
167
+ exactCommand: options.command,
168
+ cwd,
169
+ readOnlyPaths,
170
+ readWritePaths,
171
+ deniedPaths,
172
+ privateTemp,
173
+ environment,
174
+ executable: canonicalPath(options.executable, cwd, platform),
175
+ args: [...options.args],
176
+ network: options.result.network.access === "host" ? "host" : "deny",
177
+ rationale: options.result.reason,
178
+ accesses,
179
+ networkCommands: [...options.result.network.commands],
180
+ };
181
+ }
182
+
183
+ export type SandboxModeDecision = {
184
+ action: "direct" | "sandbox" | "block";
185
+ warning?: string;
186
+ reason?: string;
187
+ };
188
+
189
+ /** Resolve one capability probe. The caller displays an automatic fallback warning once. */
190
+ export function decideSandboxMode(
191
+ mode: SandboxMode,
192
+ capability: SandboxCapability,
193
+ ): SandboxModeDecision {
194
+ if (mode === "off") return { action: "direct" };
195
+ if (capability.state === "enforced") return { action: "sandbox" };
196
+ const reason = capability.reason ?? `sandbox backend ${capability.backend} is ${capability.state}`;
197
+ if (mode === "require") {
198
+ return { action: "block", reason: `Sandbox enforcement is required: ${reason}` };
199
+ }
200
+ return {
201
+ action: "direct",
202
+ warning: `Sandbox enforcement is unavailable. PUM will use deterministic Check mode only. ${reason}`,
203
+ };
204
+ }
@@ -14,6 +14,7 @@ export type SettingRowId =
14
14
  | "writingStyle"
15
15
  | "explanationStrength"
16
16
  | "checkMode"
17
+ | "sandboxMode"
17
18
  | "checkModel"
18
19
  | "checkPaths"
19
20
  | "clearCheckApprovals"
@@ -44,6 +45,7 @@ export const SETTINGS_ROWS: readonly SettingRow[] = [
44
45
  { id: "explanationStrength", label: "Explanations", category: "Agent", keywords: "progress updates output none simple detailed rationale", description: "Choose how much regular output explains the agent plan, actions, decisions, and results." },
45
46
  { id: "webSearch", label: "Web search", category: "Agent", keywords: "internet hosted codex provider", description: "Allow hosted web search on supported Codex providers. Other providers are unchanged." },
46
47
  { id: "checkMode", label: "Check mode", category: "Safety", keywords: "profile strict balanced ask verify tools hard block approval bash edit patch", description: "Choose off, strict, balanced, or ask. Hard security rules apply to every active profile." },
48
+ { id: "sandboxMode", label: "Sandbox", category: "Safety", keywords: "os isolation auto require off enforcement fallback bash", description: "Choose automatic fallback, required OS enforcement, or no OS sandbox for Bash commands." },
47
49
  { id: "checkModel", label: "Check model", category: "Safety", keywords: "verifier tools safety structured verdict model", description: "Select the separate verifier model used for ambiguous Check mode calls." },
48
50
  { id: "checkPaths", label: "Allowed paths", category: "Safety", keywords: "additional directories roots boundary command", description: "Use /check-path to manage extra directory roots allowed by Check mode for this project." },
49
51
  { id: "clearCheckApprovals", label: "Clear approvals", category: "Safety", keywords: "remove reset exact project approvals ask", description: "Remove exact Check mode approvals stored for this project." },
package/src/settings.ts CHANGED
@@ -8,11 +8,13 @@ import {
8
8
  isExplanationStrength,
9
9
  type ExplanationStrength,
10
10
  } from "./explanation-strength";
11
+ import type { SandboxMode } from "./sandbox/types";
11
12
 
12
13
  export const WORKING_RULE_ANIMATION_MODES = ["off", "input-only", "coordinated"] as const;
13
14
  export type WorkingRuleAnimationMode = (typeof WORKING_RULE_ANIMATION_MODES)[number];
14
15
  export const CHECK_MODE_PROFILES = ["off", "strict", "balanced", "ask"] as const;
15
16
  export type CheckModeProfile = (typeof CHECK_MODE_PROFILES)[number];
17
+ export const SANDBOX_MODES = ["auto", "require", "off"] as const;
16
18
  export const MIN_ACTIVE_SUBAGENTS = 1;
17
19
  export const MAX_ACTIVE_SUBAGENTS = 25;
18
20
  export const DEFAULT_MAX_ACTIVE_SUBAGENTS = 10;
@@ -63,6 +65,10 @@ export function isCheckModeProfile(value: unknown): value is CheckModeProfile {
63
65
  return CHECK_MODE_PROFILES.includes(value as CheckModeProfile);
64
66
  }
65
67
 
68
+ export function isSandboxMode(value: unknown): value is SandboxMode {
69
+ return SANDBOX_MODES.includes(value as SandboxMode);
70
+ }
71
+
66
72
  export function isWorkingRuleAnimationMode(value: unknown): value is WorkingRuleAnimationMode {
67
73
  return WORKING_RULE_ANIMATION_MODES.includes(value as WorkingRuleAnimationMode);
68
74
  }
@@ -83,6 +89,8 @@ export type PumSettings = {
83
89
  explanationStrength: ExplanationStrength;
84
90
  checkMode: CheckModeProfile;
85
91
  checkModel: string;
92
+ /** OS sandbox enforcement. Legacy settings omit this field and migrate to auto. */
93
+ sandboxMode?: SandboxMode;
86
94
  /** Additional canonical directory roots allowed by Check mode, keyed by launch project. */
87
95
  checkPaths?: CheckPathsByProject;
88
96
  maxActiveSubagents: number;
@@ -100,6 +108,7 @@ const DEFAULTS: PumSettings = {
100
108
  explanationStrength: "simple",
101
109
  checkMode: "off",
102
110
  checkModel: DEFAULT_CHECK_MODEL,
111
+ sandboxMode: "auto",
103
112
  checkPaths: {},
104
113
  maxActiveSubagents: DEFAULT_MAX_ACTIVE_SUBAGENTS,
105
114
  };
@@ -128,6 +137,7 @@ export function normalizeSettings(parsed: unknown): PumSettings {
128
137
  typeof merged.checkModel === "string" && merged.checkModel.includes("/")
129
138
  ? merged.checkModel
130
139
  : DEFAULTS.checkModel,
140
+ sandboxMode: isSandboxMode(merged.sandboxMode) ? merged.sandboxMode : DEFAULTS.sandboxMode,
131
141
  checkPaths: normalizeCheckPathsByProject(merged.checkPaths),
132
142
  maxActiveSubagents: normalizeMaxActiveSubagents(merged.maxActiveSubagents),
133
143
  };