@agentswf/sandbox 0.0.0-stage → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import {
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ open,
8
+ readdir,
9
+ realpath,
10
+ rm,
11
+ stat,
12
+ symlink,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { basename, dirname, join } from "node:path";
17
+ import type { SrtEnvironment } from "@agentswf/contract/workflow";
18
+ import { protectedPaths } from "../git";
19
+ import { LaunchedGroups } from "../groups";
20
+ import { onceUnlessFailed } from "../once";
21
+ import { panePrelude } from "../pane";
22
+ import { contains, repositoryOf } from "../resolve";
23
+ import type {
24
+ OpenedSandbox,
25
+ PaneTerminal,
26
+ ResolvedSandbox,
27
+ SandboxContext,
28
+ SandboxedCommand,
29
+ SandboxProvider,
30
+ } from "../seam";
31
+ import { removeSecrets, shellQuote, writeSecrets } from "../secrets";
32
+ import {
33
+ agentProfile,
34
+ baseProfile,
35
+ checkProfile,
36
+ DENIED,
37
+ type SrtHost,
38
+ type SrtSettings,
39
+ } from "./profile";
40
+
41
+ export type SrtOptions = SrtHost & {
42
+ /** What runs srt: node and srt's `cli.js`, by real paths, as an agent's `PATH` may lack both. */
43
+ command: readonly string[];
44
+ /** An agent's `PATH` before the system's: the toolchain's `bin` directories and the like. */
45
+ path: readonly string[];
46
+ /** Runs the probe on the first open; a test of profiles alone turns it off. */
47
+ probe?: boolean;
48
+ };
49
+
50
+ /** How long `srt --version` may take before srt is taken to be missing. */
51
+ const VERSION_MS = 10_000;
52
+
53
+ /** The system's own directories, last on every agent's `PATH`; srt needs `bash` there (H6). */
54
+ const SYSTEM_PATH = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
55
+
56
+ /** Where an agent's short directory is made: `/tmp` by its real path, which srt matches. */
57
+ const SHORT_ROOT = "/private/tmp";
58
+
59
+ /**
60
+ * Anthropic's sandbox-runtime on this machine. A sandbox is a base profile; each admitted agent
61
+ * gets a profile file built from it, and every process it runs is `srt -s {file} -- {command}`,
62
+ * with exactly the environment the provider sets.
63
+ */
64
+ export function createSrtProvider(options: SrtOptions): SandboxProvider<SrtEnvironment> {
65
+ // Only a probe that passed is kept: a transient failure is tried again at the next open.
66
+ const probed = onceUnlessFailed((context: SandboxContext) => probe(options, context));
67
+ return {
68
+ environment(raw) {
69
+ if (
70
+ typeof raw !== "object" ||
71
+ raw === null ||
72
+ Array.isArray(raw) ||
73
+ Object.keys(raw).length > 0
74
+ ) {
75
+ throw new Error("srt takes no settings: name it as srt: {}");
76
+ }
77
+ return {};
78
+ },
79
+ async open(spec, context) {
80
+ // srt applies every allow, then every deny: a `write` inside a `read` inside a `write`
81
+ // would be denied, narrower than the spec says.
82
+ for (const path of spec.write) {
83
+ const read = spec.read.find((root) => root !== path && contains(root, path));
84
+ if (read && spec.write.some((root) => root !== read && contains(root, read))) {
85
+ throw new Error(
86
+ `srt cannot make ${path} writable inside ${read}, read-only inside a write`,
87
+ );
88
+ }
89
+ }
90
+ const temp = join(context.directory, "tmp");
91
+ const base = baseProfile(spec, context, options, temp, await protectedPaths(spec));
92
+ checkProfile(base, options, context, spec);
93
+ if (options.probe !== false) await probed(context);
94
+ await mkdir(join(temp, "npm"), { recursive: true, mode: 0o700 });
95
+ await mkdir(join(context.directory, "profiles"), { recursive: true, mode: 0o700 });
96
+ return openSandbox(spec, context, options, base, temp);
97
+ },
98
+ };
99
+ }
100
+
101
+ function openSandbox(
102
+ spec: ResolvedSandbox<unknown>,
103
+ context: SandboxContext,
104
+ options: SrtOptions,
105
+ base: SrtSettings,
106
+ temp: string,
107
+ ): OpenedSandbox {
108
+ const profiles = join(context.directory, "profiles");
109
+ const launched: LaunchedGroups[] = [];
110
+ /** Outside the sandbox's directory, so its close removes them by name. */
111
+ const shorts: string[] = [];
112
+ return {
113
+ record: { toolchain: [...options.toolchain] },
114
+ panes: true,
115
+ async admit(agent) {
116
+ if (!contains(context.directory, agent.home)) {
117
+ throw new Error(`srt: ${agent.home} is not one of this sandbox's homes`);
118
+ }
119
+ // `mkdtemp` makes it 0700, by a name no co-tenant can guess ahead.
120
+ const short = agent.harness.shortDirectory
121
+ ? await mkdtemp(join(SHORT_ROOT, "awf-"))
122
+ : undefined;
123
+ if (short) shorts.push(short);
124
+ for (const shared of agent.harness.sharedWrites ?? []) await sharedDirectory(shared);
125
+ const settings = agentProfile(base, agent, short);
126
+ checkProfile(settings, options, context, spec);
127
+ const profile = join(profiles, `${randomUUID()}.json`);
128
+ await writeFile(profile, JSON.stringify(settings, null, 2), { mode: 0o600 });
129
+ // git refuses an unreadable `~/.gitconfig`; this one is empty (X4). Made anew, never through
130
+ // a link a co-tenant planted in the home.
131
+ const gitconfig = join(agent.home, "gitconfig");
132
+ await (
133
+ await open(
134
+ gitconfig,
135
+ constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_WRONLY,
136
+ 0o600,
137
+ )
138
+ ).close();
139
+ // The agent's whole environment: nothing of the engine's reaches it (X20).
140
+ const environment: Record<string, string> = {
141
+ HOME: options.home,
142
+ PATH: [...options.path, ...SYSTEM_PATH].join(":"),
143
+ CLAUDE_CODE_TMPDIR: temp,
144
+ // The `wf result <<'WF_JSON'` every prompt asks for needs a heredoc's temp file. zsh
145
+ // writes it under `TMPPREFIX`, `/tmp/zsh` by default, which is denied; macOS's bash 3.2
146
+ // writes it in `/var/tmp` whatever `TMPDIR` says, which is denied too (X22). So the
147
+ // harnesses' shell is zsh, its heredocs in the sandbox's temp.
148
+ SHELL: "/bin/zsh",
149
+ TMPPREFIX: join(temp, "zsh"),
150
+ GIT_CONFIG_GLOBAL: gitconfig,
151
+ npm_config_cache: join(temp, "npm"),
152
+ ...agent.harness.env,
153
+ ...(agent.harness.shortDirectory && short ? { [agent.harness.shortDirectory]: short } : {}),
154
+ };
155
+ const agentId = randomUUID();
156
+ const groups = new LaunchedGroups(join(context.directory, "pids", agentId));
157
+ launched.push(groups);
158
+ let released = false;
159
+ const unread: string[] = [];
160
+ return {
161
+ launch(root): SandboxedCommand {
162
+ if (released) throw new Error("srt: this agent was released");
163
+ const [first, ...rest] = root.argv;
164
+ // `~/.local/bin` and its links are denied, so the harness runs by its real path; `--`
165
+ // keeps srt from reading the harness's flags as its own (Task 0).
166
+ const command = first === agent.harness.command ? agent.harness.executable : first!;
167
+ const wrapped = groups.wrap([...options.command, "-s", profile, "--", command, ...rest]);
168
+ return {
169
+ ...root,
170
+ argv: wrapped.argv,
171
+ env: { ...environment, ...agent.harness.secrets, ...root.env },
172
+ group: true,
173
+ // Killing the group ends srt and everything it started (X1); this forgets it.
174
+ reap: () => groups.kill(wrapped.pidFile),
175
+ };
176
+ },
177
+ async pane(): Promise<PaneTerminal> {
178
+ if (released) throw new Error("srt: this agent was released");
179
+ // The harness by its name, which a person could type: a link in a directory of its own.
180
+ const bin = join(context.directory, "bin", agentId);
181
+ await mkdir(bin, { recursive: true, mode: 0o700 });
182
+ await symlink(agent.harness.executable, join(bin, agent.harness.command)).catch(
183
+ (error: NodeJS.ErrnoException) => {
184
+ if (error.code !== "EEXIST") throw error;
185
+ },
186
+ );
187
+ const paneProfile = join(profiles, `${agentId}-pane.json`);
188
+ await writeFile(paneProfile, JSON.stringify({ ...settings, allowPty: true }, null, 2), {
189
+ mode: 0o600,
190
+ });
191
+ const secretsFile = await writeSecrets(context, randomUUID(), agent.harness.secrets);
192
+ unread.push(secretsFile);
193
+ return {
194
+ herdr: "run",
195
+ // zsh, as srt's bash drops `PS1`.
196
+ ...panePrelude({
197
+ environment: {
198
+ ...environment,
199
+ PATH: [bin, ...options.path, ...SYSTEM_PATH].join(":"),
200
+ },
201
+ pidFile: await groups.pidFile(),
202
+ secretsFile,
203
+ through: [...options.command, "-s", paneProfile, "--"],
204
+ }),
205
+ harness: agent.harness.command,
206
+ };
207
+ },
208
+ async release() {
209
+ released = true;
210
+ await groups.killAll();
211
+ if (short) await rm(short, { recursive: true, force: true });
212
+ // A pane whose shell never took its prelude.
213
+ await Promise.all(unread.map((path) => rm(path, { force: true })));
214
+ },
215
+ };
216
+ },
217
+ async close() {
218
+ await Promise.all(launched.map((groups) => groups.killAll()));
219
+ await Promise.all([
220
+ rm(join(context.directory, "pids"), { recursive: true, force: true }),
221
+ rm(profiles, { recursive: true, force: true }),
222
+ rm(temp, { recursive: true, force: true }),
223
+ rm(join(context.directory, "bin"), { recursive: true, force: true }),
224
+ ...shorts.map((short) => rm(short, { recursive: true, force: true })),
225
+ removeSecrets(context),
226
+ ]);
227
+ },
228
+ };
229
+ }
230
+
231
+ /** A host the probe reaches for, from the host and from inside. */
232
+ const PROBE_URL = "https://registry.npmjs.org";
233
+
234
+ /**
235
+ * The first open in a process proves srt confines on this machine: a canary in the run root is
236
+ * unreadable, and a domain not allowed is unreachable, where the host reaches it at all (offline,
237
+ * a refusal proves nothing, and the network half is skipped). Bound by the sandbox's deadline.
238
+ */
239
+ async function probe(options: SrtOptions, context: SandboxContext): Promise<void> {
240
+ const canary = join(context.runRoot, `.srt-probe-${randomUUID()}`);
241
+ const token = randomUUID();
242
+ const profile = join(context.directory, `.srt-probe-${randomUUID()}.json`);
243
+ const settings = baseProfile(
244
+ {
245
+ key: "probe",
246
+ cwd: context.directory,
247
+ read: [],
248
+ write: [],
249
+ network: [],
250
+ hidden: [],
251
+ gitdirs: [],
252
+ environment: {},
253
+ },
254
+ context,
255
+ options,
256
+ join(context.directory, "tmp"),
257
+ [],
258
+ );
259
+ await writeFile(canary, token, { mode: 0o600 });
260
+ await writeFile(profile, JSON.stringify(settings), { mode: 0o600 });
261
+ const reachable =
262
+ (await Bun.spawn(["/usr/bin/curl", "-s", "-m", "5", "-o", "/dev/null", PROBE_URL]).exited) ===
263
+ 0;
264
+ try {
265
+ const child = Bun.spawn({
266
+ cmd: [
267
+ ...options.command,
268
+ "-s",
269
+ profile,
270
+ "--",
271
+ "/bin/sh",
272
+ "-c",
273
+ `echo ran; cat ${shellQuote(canary)} 2>/dev/null; ${reachable ? `/usr/bin/curl -s -m 5 -o /dev/null ${PROBE_URL} && echo reached` : "true"}`,
274
+ ],
275
+ cwd: context.directory,
276
+ env: { PATH: SYSTEM_PATH.join(":"), HOME: options.home },
277
+ stdout: "pipe",
278
+ stderr: "pipe",
279
+ });
280
+ const timer = setTimeout(
281
+ () => child.kill("SIGKILL"),
282
+ // A timer overflows past 2^31 - 1 ms, and would fire at once.
283
+ Math.min(Math.max(0, context.deadline.unixMilliseconds - Date.now()), 2_147_483_647),
284
+ );
285
+ const [output, stderr] = await Promise.all([
286
+ new Response(child.stdout).text(),
287
+ new Response(child.stderr).text(),
288
+ child.exited,
289
+ ]).finally(() => clearTimeout(timer));
290
+ // srt failing to start is no proof of confinement either.
291
+ if (!output.startsWith("ran")) {
292
+ throw new Error(`srt's probe did not run: ${stderr.trim().slice(0, 300)}`);
293
+ }
294
+ if (output.includes(token) || output.includes("reached")) {
295
+ throw new Error(
296
+ "srt does not confine on this machine: its probe read a denied file or domain",
297
+ );
298
+ }
299
+ } finally {
300
+ await Promise.all([rm(canary, { force: true }), rm(profile, { force: true })]);
301
+ }
302
+ }
303
+
304
+ /**
305
+ * srt as installed on this machine: 1.0.0 or later on `PATH`. `undefined` when absent. The
306
+ * toolchain is what `PATH`'s executables under `~` need: their `bin` directories, and each one's
307
+ * install tree (X19): a node package's root, a `bin/` beside a `lib/` (a node or a Python venv),
308
+ * or the executable alone. Never a whole parent of `bin`: `~/.local` holds `state/` and
309
+ * credentials, and `~/.orbstack` an SSH key.
310
+ */
311
+ export async function findSrt(
312
+ environment: Readonly<Record<string, string | undefined>>,
313
+ harnessState: readonly string[],
314
+ ): Promise<SrtOptions | undefined> {
315
+ const home = await realpath(environment.HOME ?? homedir());
316
+ const state = await Promise.all(harnessState.map((root) => realpath(root).catch(() => root)));
317
+ const path = (environment.PATH ?? "").split(":").filter(Boolean);
318
+ const cli = await firstExecutable("srt", path);
319
+ const node = await firstExecutable("node", path);
320
+ if (!cli || !node) return undefined;
321
+ const version = Bun.spawnSync({
322
+ cmd: [node, cli, "--version"],
323
+ stdout: "pipe",
324
+ stderr: "pipe",
325
+ timeout: VERSION_MS,
326
+ });
327
+ const [major] = version.stdout.toString().trim().split(".").map(Number);
328
+ if (version.exitCode !== 0 || major === undefined || !(major >= 1)) return undefined;
329
+ const real = [
330
+ ...new Set(
331
+ (await Promise.all(path.map((entry) => realpath(entry).catch(() => undefined)))).filter(
332
+ (entry): entry is string => entry !== undefined,
333
+ ),
334
+ ),
335
+ ];
336
+ const overlapsState = (root: string) =>
337
+ state.some((entry) => contains(root, entry) || contains(entry, root));
338
+ const bins = real.filter(
339
+ (entry) => contains(home, entry) && entry !== home && !overlapsState(entry),
340
+ );
341
+ const trees = new Set<string>();
342
+ for (const bin of bins) {
343
+ for (const name of await readdir(bin).catch(() => [])) {
344
+ const target = await realpath(join(bin, name)).catch(() => undefined);
345
+ // Outside `~` it is readable anyway; inside a repository it is that repository's source,
346
+ // a link on `PATH` to a script under development, not an install.
347
+ if (
348
+ !target ||
349
+ !contains(home, target) ||
350
+ (await repositoryOf(target, home).catch(() => true))
351
+ )
352
+ continue;
353
+ const tree = await installTree(target);
354
+ if (tree !== home && !contains(tree, home) && !overlapsState(tree)) trees.add(tree);
355
+ }
356
+ }
357
+ // The rest of `PATH` that a sandbox can read: outside `~` and the temp directories.
358
+ const outside = real.filter((entry) => !DENIED.some((root) => contains(root, entry)));
359
+ const xcrunCache = await findXcrunCache();
360
+ return {
361
+ command: [node, cli],
362
+ home,
363
+ harnessState: state,
364
+ ...(xcrunCache ? { xcrunCache } : {}),
365
+ toolchain: outermost([...bins, ...trees]),
366
+ path: [...bins, ...outside].filter((entry) => !SYSTEM_PATH.includes(entry)),
367
+ };
368
+ }
369
+
370
+ /**
371
+ * `path` as a directory of this user's, 0700, made where it is missing: never through a link, nor
372
+ * one another user made, as anyone may write `/tmp`.
373
+ */
374
+ async function sharedDirectory(path: string): Promise<void> {
375
+ await mkdir(path, { recursive: true, mode: 0o700 });
376
+ for (let at = path; at !== SHORT_ROOT && at !== dirname(at); at = dirname(at)) {
377
+ const found = await lstat(at);
378
+ if (!found.isDirectory() || found.uid !== process.getuid?.()) {
379
+ throw new Error(`srt: ${at} is not a directory of this user's`);
380
+ }
381
+ }
382
+ }
383
+
384
+ /** The operator's `xcrun` cache, where macOS keeps one: `DARWIN_USER_TEMP_DIR/xcrun_db`. */
385
+ async function findXcrunCache(): Promise<string | undefined> {
386
+ if (process.platform !== "darwin") return undefined;
387
+ const temp = Bun.spawnSync({ cmd: ["getconf", "DARWIN_USER_TEMP_DIR"], stdout: "pipe" });
388
+ const directory = temp.stdout.toString().trim();
389
+ if (temp.exitCode !== 0 || !directory) return undefined;
390
+ const cache = await realpath(join(directory, "xcrun_db")).catch(() => undefined);
391
+ return cache && (await stat(cache)).isFile() ? cache : undefined;
392
+ }
393
+
394
+ /** Each path once, and none inside another. */
395
+ function outermost(paths: readonly string[]): string[] {
396
+ const unique = [...new Set(paths)];
397
+ return unique.filter((path) => !unique.some((other) => other !== path && contains(other, path)));
398
+ }
399
+
400
+ /** What an executable needs to run besides itself, by where it lies. */
401
+ async function installTree(executable: string): Promise<string> {
402
+ const parts = executable.split("/");
403
+ const modules = parts.lastIndexOf("node_modules");
404
+ if (modules >= 0 && modules + 1 < parts.length) {
405
+ const scoped = parts[modules + 1]!.startsWith("@");
406
+ return parts.slice(0, modules + (scoped ? 3 : 2)).join("/");
407
+ }
408
+ const parent = dirname(executable);
409
+ if (
410
+ basename(parent) === "bin" &&
411
+ (await stat(join(dirname(parent), "lib")).catch(() => undefined))
412
+ ) {
413
+ return dirname(parent);
414
+ }
415
+ return executable;
416
+ }
417
+
418
+ async function firstExecutable(name: string, path: readonly string[]): Promise<string | undefined> {
419
+ for (const directory of path) {
420
+ const candidate = join(directory, name);
421
+ const found = await stat(candidate).catch(() => undefined);
422
+ if (found?.isFile() && (found.mode & 0o111) !== 0) return realpath(candidate);
423
+ }
424
+ return undefined;
425
+ }
@@ -0,0 +1,191 @@
1
+ import { dirname, join } from "node:path";
2
+ import { contains, KEY_DIRECTORIES } from "../resolve";
3
+ import type { AgentContext, ResolvedSandbox, SandboxContext } from "../seam";
4
+
5
+ /** srt's settings file, as much of it as a sandbox uses. */
6
+ export type SrtSettings = {
7
+ enableWeakerNetworkIsolation: true;
8
+ /** A pane's only: zsh cannot take the terminal without it, and nothing is detected (H6). */
9
+ allowPty?: true;
10
+ network: {
11
+ allowedDomains: string[];
12
+ deniedDomains: string[];
13
+ allowUnixSockets: string[];
14
+ };
15
+ filesystem: {
16
+ denyRead: string[];
17
+ allowRead: string[];
18
+ allowWrite: string[];
19
+ denyWrite: string[];
20
+ };
21
+ };
22
+
23
+ /** What srt needs to know of the host, found once when the provider is made. */
24
+ export type SrtHost = {
25
+ /** The operator's home, real. */
26
+ home: string;
27
+ /** The operator's harness state, which no allowed path may overlap. */
28
+ harnessState: readonly string[];
29
+ /** What the project's tools under `~` need (X19): their `bin` directories and install trees. */
30
+ toolchain: readonly string[];
31
+ /**
32
+ * macOS's `xcrun` cache, in the operator's temp directory, which `/usr/bin/git` reads on every
33
+ * call: without it each call starts `xcodebuild`, 1.2 s instead of 0.1 s (story 019). It holds
34
+ * tool paths only, and is read, never written.
35
+ */
36
+ xcrunCache?: string;
37
+ };
38
+
39
+ /** Regions no agent reads, besides `~`, before a sandbox re-allows its own paths inside them. */
40
+ export const DENIED = [
41
+ "/Users",
42
+ "/home",
43
+ "/Volumes",
44
+ "/tmp",
45
+ "/private/tmp",
46
+ "/private/var/folders",
47
+ "/private/var/tmp",
48
+ ];
49
+
50
+ /**
51
+ * A sandbox's base: reach and the toolchain readable, `write`, homes and its temp writable, and
52
+ * nothing of the network but `network`. `enableWeakerNetworkIsolation` is the one trustd lookup
53
+ * codex's TLS needs (Task 0); `allowLocalBinding` stays off.
54
+ */
55
+ export function baseProfile(
56
+ spec: ResolvedSandbox<unknown>,
57
+ context: SandboxContext,
58
+ host: SrtHost,
59
+ temp: string,
60
+ /** What the host runs or follows, which no agent writes: `protectedPaths`. */
61
+ protectedGit: readonly string[],
62
+ ): SrtSettings {
63
+ const gitdirs = spec.gitdirs.map((gitdir) => gitdir.path);
64
+ return {
65
+ enableWeakerNetworkIsolation: true,
66
+ network: { allowedDomains: [...spec.network], deniedDomains: [], allowUnixSockets: [] },
67
+ filesystem: {
68
+ denyRead: unique([host.home, ...DENIED, context.runRoot, ...spec.hidden]),
69
+ allowRead: unique([
70
+ spec.cwd,
71
+ ...spec.read,
72
+ ...spec.write,
73
+ ...gitdirs,
74
+ context.directory,
75
+ ...host.toolchain,
76
+ ...(host.xcrunCache ? [host.xcrunCache] : []),
77
+ ]),
78
+ allowWrite: unique([
79
+ ...spec.write,
80
+ ...spec.gitdirs.filter((gitdir) => gitdir.writable).map((gitdir) => gitdir.path),
81
+ join(context.directory, "homes"),
82
+ temp,
83
+ ]),
84
+ // What is hidden, which srt denies though an allowed path holds it: it applies a deny nested
85
+ // in an allowed path after the allow. What the host's git runs or follows, and a `read` path
86
+ // inside a `write` one, which the more specific path makes read-only.
87
+ denyWrite: unique([
88
+ ...spec.hidden,
89
+ ...protectedGit,
90
+ ...spec.read.filter((path) =>
91
+ spec.write.some((root) => root !== path && contains(root, path)),
92
+ ),
93
+ ]),
94
+ },
95
+ };
96
+ }
97
+
98
+ /**
99
+ * One agent's profile: the base, its harness's model domains and install tree, its executable,
100
+ * and its door: the launcher's directory readable and never writable, and its own socket alone.
101
+ */
102
+ export function agentProfile(
103
+ base: SrtSettings,
104
+ agent: AgentContext,
105
+ /** The agent's short directory, which it alone reads, writes and binds sockets in. */
106
+ short?: string,
107
+ ): SrtSettings {
108
+ const writes = [...(short ? [short] : []), ...(agent.harness.sharedWrites ?? [])];
109
+ // A harness that names a shared directory through `/tmp`, as cursor does, follows that link,
110
+ // which srt denies with the rest of `/tmp`; the link alone opens nothing behind it (story 019).
111
+ const tmpLink = (agent.harness.sharedWrites ?? []).some((path) => contains("/private/tmp", path))
112
+ ? ["/tmp"]
113
+ : [];
114
+ const launcherDirectory = dirname(agent.door.launcher);
115
+ return {
116
+ ...base,
117
+ network: {
118
+ ...base.network,
119
+ allowedDomains: unique([...base.network.allowedDomains, ...agent.harness.domains]),
120
+ allowUnixSockets: [agent.door.endpoint, ...(short ? [short] : [])],
121
+ },
122
+ filesystem: {
123
+ ...base.filesystem,
124
+ allowRead: unique([
125
+ ...base.filesystem.allowRead,
126
+ ...agent.harness.reads,
127
+ agent.harness.executable,
128
+ ...agent.door.reads,
129
+ launcherDirectory,
130
+ ...writes,
131
+ ...tmpLink,
132
+ ]),
133
+ allowWrite: unique([...base.filesystem.allowWrite, ...writes]),
134
+ denyWrite: unique([...base.filesystem.denyWrite, launcherDirectory]),
135
+ },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * The pure check every profile passes before srt sees it, against the spec it was made from: `~`
141
+ * and the run root denied, each `hidden` path denied for reads and writes, and no allowed path exposing `~` or the run root,
142
+ * reaching into the run root outside this sandbox's directory, or overlapping the operator's
143
+ * harness state. A harness's install tree may lie inside its state (codex's `~/.codex/packages`),
144
+ * never contain it. An allowed path may hold the run root only when it is hidden.
145
+ */
146
+ export function checkProfile(
147
+ settings: SrtSettings,
148
+ host: SrtHost,
149
+ context: SandboxContext,
150
+ { hidden }: Pick<ResolvedSandbox<unknown>, "hidden">,
151
+ ): void {
152
+ const { denyRead, allowRead, allowWrite, denyWrite } = settings.filesystem;
153
+ for (const denied of [host.home, context.runRoot]) {
154
+ if (!denyRead.includes(denied)) throw new Error(`srt profile does not deny ${denied}`);
155
+ }
156
+ for (const path of hidden) {
157
+ if (!denyRead.includes(path) || !denyWrite.includes(path)) {
158
+ throw new Error(`srt profile does not hide ${path}`);
159
+ }
160
+ // srt's deny, applied after every allow, would hide this sandbox's own directory with it.
161
+ if (contains(path, context.directory)) {
162
+ throw new Error(`srt profile would hide its own directory ${context.directory} in ${path}`);
163
+ }
164
+ }
165
+ for (const path of [...allowRead, ...allowWrite]) {
166
+ if (contains(path, host.home)) throw new Error(`srt profile would expose ~ through ${path}`);
167
+ if (
168
+ path === context.runRoot ||
169
+ (contains(path, context.runRoot) && !hidden.includes(context.runRoot))
170
+ ) {
171
+ throw new Error(`srt profile would expose the run root through ${path}`);
172
+ }
173
+ if (contains(context.runRoot, path) && !contains(context.directory, path)) {
174
+ throw new Error(`srt profile reaches into the run root at ${path}`);
175
+ }
176
+ const state = host.harnessState.find((root) => contains(path, root));
177
+ if (state) throw new Error(`srt profile would expose harness state ${state} through ${path}`);
178
+ const keys = KEY_DIRECTORIES.map((name) => join(host.home, name)).find(
179
+ (root) => contains(path, root) || contains(root, path),
180
+ );
181
+ if (keys) throw new Error(`srt profile would expose ${keys} through ${path}`);
182
+ }
183
+ for (const path of allowWrite) {
184
+ const state = host.harnessState.find((root) => contains(root, path));
185
+ if (state) throw new Error(`srt profile would let ${path} in harness state be written`);
186
+ }
187
+ }
188
+
189
+ function unique(values: readonly string[]): string[] {
190
+ return [...new Set(values)];
191
+ }