toolroll 0.9.69 → 0.9.71
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/CHANGELOG.md +67 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +184 -115
- package/dist/agent-fence.js +418 -535
- package/dist/agentconfig.js +6 -1
- package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
- package/dist/builder.js +24 -4
- package/dist/check-access.d.ts +6 -9
- package/dist/check-access.js +8 -11
- package/dist/coding-provider.d.ts +14 -0
- package/dist/coding-provider.js +33 -1
- package/dist/coding-workspace.d.ts +15 -0
- package/dist/coding-workspace.js +9 -2
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +4 -0
- package/dist/egress.d.ts +2 -0
- package/dist/egress.js +13 -4
- package/dist/exec.d.ts +17 -3
- package/dist/exec.js +54 -17
- package/dist/flow-actions.d.ts +1 -0
- package/dist/flow-actions.js +3 -2
- package/dist/flow-code.d.ts +31 -2
- package/dist/flow-code.js +57 -5
- package/dist/flow-steps.d.ts +4 -0
- package/dist/flow-steps.js +9 -5
- package/dist/flow-triggers.d.ts +4 -0
- package/dist/flow-triggers.js +8 -3
- package/dist/invoke.d.ts +25 -8
- package/dist/invoke.js +115 -115
- package/dist/operate.js +46 -0
- package/dist/project-delete-ui.d.ts +7 -6
- package/dist/project-delete-ui.js +37 -3
- package/dist/project-sandbox.d.ts +59 -0
- package/dist/project-sandbox.js +83 -0
- package/dist/project-tools.d.ts +29 -5
- package/dist/project-tools.js +43 -7
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -0
- package/dist/sandbox-launch.d.ts +1 -0
- package/dist/sandbox-launch.js +148 -0
- package/dist/sandbox-runtime.d.ts +109 -0
- package/dist/sandbox-runtime.js +281 -0
- package/dist/scope.d.ts +28 -0
- package/dist/scope.js +25 -5
- package/dist/scout-browser-lock.d.ts +69 -0
- package/dist/scout-browser-lock.js +69 -0
- package/dist/scout-browser.d.ts +60 -1
- package/dist/scout-browser.js +200 -4
- package/dist/scout.d.ts +9 -4
- package/dist/scout.js +14 -4
- package/dist/serve.js +3 -1
- package/dist/server/render-tasks.js +4 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +27 -0
- package/dist/store.d.ts +31 -1
- package/dist/store.js +83 -4
- package/dist/subagent-tools.d.ts +1 -0
- package/dist/subagent-tools.js +7 -5
- package/dist/tool-broker.d.ts +2 -1
- package/dist/tool-broker.js +2 -2
- package/dist/tool-launcher.js +10 -16
- package/package.json +2 -1
- package/scripts/provider-canary.mjs +5 -6
- package/dist/egress-bridge.d.ts +0 -17
- package/dist/egress-bridge.js +0 -111
package/dist/agent-fence.js
CHANGED
|
@@ -1,60 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The agent fence: what no build, plan, scout or repair agent may read or
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* to approve its own work. The fence closes that at the operating system,
|
|
8
|
-
* not in the model's instructions:
|
|
2
|
+
* The agent fence: what no build, plan, scout or repair agent may read or write, whatever its permission mode. Agents
|
|
3
|
+
* run as the operator's own OS user, so without it an agent could read the approver's remembered login
|
|
4
|
+
* (`up-login.txt`), runner and coordinator tokens, provider keys, project tool secrets, other runs' evidence, or the
|
|
5
|
+
* live database — and use them to approve its own work. The fence closes that at the operating system, not in the
|
|
6
|
+
* model's instructions.
|
|
9
7
|
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* - Claude everywhere also gets Read/Edit deny rules for its own file tools.
|
|
8
|
+
* The sandbox itself is Anthropic's sandbox-runtime (sandbox-runtime.ts): every macOS Seatbelt profile and every Linux
|
|
9
|
+
* bubblewrap + seccomp invocation is generated by its code, with its baseline, its mandatory write denies (shell
|
|
10
|
+
* profiles, .git/hooks, .vscode, .mcp.json and the rest) and its limits. This module decides only the few static rules
|
|
11
|
+
* Toolroll passes it (srtTermsOf): Toolroll's own state and database denied to read and write; the build's own
|
|
12
|
+
* checkout writable; other tasks' checkouts read, never written; the project's read-deny list; under policy v1 (an
|
|
13
|
+
* approval sealed before 0.9.70) the home configuration denied by name; a sign-ins check's exact files read again; the
|
|
14
|
+
* run's proxy as its only way out when its network is confined. On macOS every launch also gets a pseudo-terminal of its
|
|
15
|
+
* own and no other terminal, and only a build's agent (and a check with sign-ins) reaches the keychain
|
|
16
|
+
* (sandbox-runtime.ts seatbeltRulesFor, static rules).
|
|
20
17
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* Codex (every platform) fences its own agent's commands with its own sandbox: a permission profile with the same
|
|
19
|
+
* denies (codexFenceArgv). Claude also gets Read/Edit deny rules for its own file tools.
|
|
23
20
|
*
|
|
24
|
-
* A build's fence also holds its task boundary (`taskBoundary`): home
|
|
25
|
-
*
|
|
26
|
-
* its task's folders and nowhere else it could do harm.
|
|
27
|
-
*
|
|
28
|
-
* Beside what it denies, a fence grants a little back (`FenceGrants`): other
|
|
29
|
-
* tasks' git metadata is read but never written and their checkouts can be
|
|
30
|
-
* looked up, so `git gc` and `git worktree prune` see live siblings and keep
|
|
31
|
-
* them; the shared dependencies this checkout links are read and run, never
|
|
32
|
-
* written. Every plane checkout pool is denied whole, this build's own checkout given back
|
|
33
|
-
* (`CheckoutPool`), so another project's worktree is out of reach (Codex covers projects present at launch).
|
|
34
|
-
* On macOS the agent also can't read another process's arguments
|
|
35
|
-
* and environment (`kern.procargs`) or inspect a process outside its own
|
|
36
|
-
* sandbox, so a tool server's or Toolroll's own secrets don't leak through
|
|
37
|
-
* a process listing, and every other run's launch folder in the system temp
|
|
38
|
-
* folder is denied, even one made after this launch began. Nor can it get
|
|
39
|
-
* another process's task port (a debugger's way into its memory), or
|
|
40
|
-
* rename or remove a folder of the user's above a fenced path or the temp
|
|
41
|
-
* folder, which would reach the fenced path under a new name.
|
|
21
|
+
* A build's fence also holds its task boundary (`taskBoundary`): under v1 the home configuration and the repositories
|
|
22
|
+
* beside its own; under v2 only the primary checkout write-denied.
|
|
42
23
|
*/
|
|
43
24
|
import { INSTANCE_DIR } from "./console-instance.js";
|
|
44
25
|
import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
|
|
45
26
|
import { spawnSync } from "node:child_process";
|
|
46
27
|
import { createHash } from "node:crypto";
|
|
47
|
-
import {
|
|
28
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { createRequire } from "node:module";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
48
31
|
import { homedir, tmpdir } from "node:os";
|
|
49
32
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
50
33
|
import { readMonitoring } from "./monitoring-settings.js";
|
|
51
34
|
import { existingFolders } from "./names.js";
|
|
52
35
|
import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
/** Where a Linux sign-ins check's copies of its sign-in files live while it runs (check-access.ts): one folder per launch,
|
|
56
|
-
* `c-<pid>-…`, beside the database; fenced from every build by name, like the bridges. */
|
|
57
|
-
export const SIGN_IN_COPIES_DIR = "sign-in-copies";
|
|
36
|
+
import { SEATBELT_PROBE, sandboxRuntimeProblem, srtSpawn } from "./sandbox-runtime.js";
|
|
37
|
+
import { getDefaultWritePaths } from "@anthropic-ai/sandbox-runtime";
|
|
58
38
|
/** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
|
|
59
39
|
const SENSITIVE_NAME = /(^up-login\.txt$|token|secret|login|password|credential|vapid|keys?\b|\.pem$|\.key$|^backups$|^evidence$|^remote$|^sign-in\.json$|^monitoring\.json$)/i;
|
|
60
40
|
function real(path) {
|
|
@@ -137,59 +117,21 @@ export function agentFence(options) {
|
|
|
137
117
|
const fence = new Set();
|
|
138
118
|
/** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
|
|
139
119
|
let runFolder = null;
|
|
140
|
-
/** The bridges' folder, likewise. */
|
|
141
|
-
let bridgeFolder = null;
|
|
142
|
-
/** The sign-in copies folder, likewise. */
|
|
143
|
-
let copiesFolder = null;
|
|
144
120
|
const home = real(options.home ?? homedir());
|
|
145
121
|
const worktree = options.worktree === null ? null : real(options.worktree);
|
|
146
122
|
if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
|
|
147
123
|
const database = real(options.databaseFile);
|
|
148
124
|
const state = dirname(database);
|
|
125
|
+
// By name, there or not (a journal made later is denied too wherever a fence denies by name: Seatbelt and Codex).
|
|
149
126
|
for (const suffix of ["", "-wal", "-shm", "-journal"])
|
|
150
|
-
|
|
151
|
-
fence.add(database + suffix);
|
|
127
|
+
fence.add(database + suffix);
|
|
152
128
|
// The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
|
|
153
129
|
// build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
|
|
154
130
|
// build starts if it isn't there yet, so a build can never be the one to create it — and the fence always has a
|
|
155
|
-
// real folder to cover (a Linux bind needs one).
|
|
156
131
|
// Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
|
|
157
132
|
// points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
|
|
158
133
|
// If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
|
|
159
134
|
// rather than run unfenced. The name is Toolroll's own; nothing else belongs there.
|
|
160
|
-
// The folder Linux builds' network bridges are made in (egress-bridge.ts): made before any build starts and fenced
|
|
161
|
-
// from every build, so no build — however long it runs — can reach a bridge made for a later one.
|
|
162
|
-
const bridges = join(state, BRIDGE_DIR);
|
|
163
|
-
try {
|
|
164
|
-
lstatSync(bridges);
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
try {
|
|
168
|
-
mkdirSync(bridges, { mode: 0o700 });
|
|
169
|
-
}
|
|
170
|
-
catch { /* fenced below either way */ }
|
|
171
|
-
}
|
|
172
|
-
// Fenced by its own name as well as where it leads: a link planted there is itself out of reach (and the bridge
|
|
173
|
-
// refuses a root that isn't a private folder: egress-bridge.ts).
|
|
174
|
-
bridgeFolder = bridges;
|
|
175
|
-
fence.add(bridges);
|
|
176
|
-
// The sign-in copies (check-access.ts): made before any build starts and fenced from every build, by its own name as
|
|
177
|
-
// well as where it leads (as the bridges and the run folder are), whether or not the state folder is Toolroll's own.
|
|
178
|
-
// A Linux check binds its own copy from outside its sandbox, so nothing here is granted back. Never swept: a copy a
|
|
179
|
-
// launch that died left behind stays in this fenced, private folder (walking a folder a build might swap is the
|
|
180
|
-
// greater risk); a root that isn't Toolroll's private folder is never used (signInCopiesRoot).
|
|
181
|
-
const copies = join(state, SIGN_IN_COPIES_DIR);
|
|
182
|
-
try {
|
|
183
|
-
lstatSync(copies);
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
try {
|
|
187
|
-
mkdirSync(copies, { mode: 0o700 });
|
|
188
|
-
}
|
|
189
|
-
catch { /* fenced below either way */ }
|
|
190
|
-
}
|
|
191
|
-
copiesFolder = copies;
|
|
192
|
-
fence.add(copies);
|
|
193
135
|
runFolder = join(state, INSTANCE_DIR);
|
|
194
136
|
const present = () => { try {
|
|
195
137
|
lstatSync(runFolder);
|
|
@@ -237,24 +179,20 @@ export function agentFence(options) {
|
|
|
237
179
|
// v104: the folder the audit stream writes to holds the whole instance's history.
|
|
238
180
|
if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
|
|
239
181
|
const stream = readMonitoring(dirname(real(options.databaseFile))).folder?.path;
|
|
240
|
-
if (stream !== undefined
|
|
182
|
+
if (stream !== undefined)
|
|
241
183
|
fence.add(real(stream));
|
|
242
184
|
}
|
|
243
185
|
for (const path of options.extra ?? [])
|
|
244
|
-
|
|
245
|
-
fence.add(real(path));
|
|
186
|
+
fence.add(real(path));
|
|
246
187
|
// Secret, state, database, explicit-extra and home-config denies are never exemptible.
|
|
247
|
-
if (options.runFolders !== undefined)
|
|
248
|
-
for (const path of otherRunFolders(options.runFolders))
|
|
249
|
-
fence.add(path);
|
|
250
188
|
for (const path of options.boundary?.protectedShell ?? [])
|
|
251
189
|
fence.add(real(path));
|
|
252
|
-
const protectedPaths = new Set([...fence].flatMap(path => path === runFolder
|
|
190
|
+
const protectedPaths = new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]));
|
|
253
191
|
for (const path of options.boundary?.shell ?? [])
|
|
254
192
|
fence.add(path);
|
|
255
193
|
const binding = options.binding;
|
|
256
194
|
const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
|
|
257
|
-
return [...new Set([...fence].flatMap(path => path === runFolder
|
|
195
|
+
return [...new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]))].filter(path => {
|
|
258
196
|
if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
|
|
259
197
|
return true;
|
|
260
198
|
console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
|
|
@@ -297,6 +235,10 @@ export function taskBoundary(options) {
|
|
|
297
235
|
const needed = [worktree, main, gitdir].filter((path) => path !== null);
|
|
298
236
|
const own = new Set(PROVIDER_HOME[options.provider] ?? []);
|
|
299
237
|
const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
|
|
238
|
+
// Policy v2 (docs/plans/sandbox-parity.md §1.2): the home folder, the repositories beside this one and the project's
|
|
239
|
+
// primary checkout are read as Anthropic's sandbox runtime reads them; only writes to the primary checkout stay denied.
|
|
240
|
+
if ((options.policy ?? 1) === 2)
|
|
241
|
+
return v2Boundary(worktree, main, gitdir);
|
|
300
242
|
const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
|
|
301
243
|
const protectedShell = [...shell];
|
|
302
244
|
const repository = (path) => existsSync(join(path, ".git"));
|
|
@@ -338,11 +280,43 @@ export function taskBoundary(options) {
|
|
|
338
280
|
shell.add(path);
|
|
339
281
|
}
|
|
340
282
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
283
|
+
const { readOnly, lookup } = siblingGitData(main, gitdir);
|
|
284
|
+
const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
|
|
285
|
+
// By name too, present or not: a build started while ~/.codex is absent must never make it (and later swap it for a
|
|
286
|
+
// link while a sign-ins check reads through it). The sandbox runtime takes them as read and write denies by name.
|
|
287
|
+
const homeNames = HOME_CONFIGURATION.filter(name => !own.has(name)).map(name => join(home, name)).sort();
|
|
288
|
+
return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames };
|
|
289
|
+
}
|
|
290
|
+
/** A v2 boundary: nothing of the home folder, the repositories beside this one or the primary checkout is denied to
|
|
291
|
+
* read; the primary checkout (all but its `.git`, which this worktree's Git needs) is never written; other tasks' Git
|
|
292
|
+
* metadata is read, never written, as under v1. */
|
|
293
|
+
function v2Boundary(worktree, main, gitdir) {
|
|
294
|
+
const noWrite = new Set();
|
|
295
|
+
if (main !== null && main !== worktree) {
|
|
296
|
+
let entries = [];
|
|
297
|
+
try {
|
|
298
|
+
entries = readdirSync(main);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
entries = [];
|
|
302
|
+
}
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
const path = join(main, entry);
|
|
305
|
+
if (entry !== ".git" && path !== worktree && !inside(worktree, path))
|
|
306
|
+
noWrite.add(path);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const { readOnly, lookup } = siblingGitData(main, gitdir);
|
|
310
|
+
for (const path of noWrite)
|
|
311
|
+
readOnly.add(path);
|
|
312
|
+
return { protectedShell: [], shell: [], fileTools: [], readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames: [] };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Git needs this worktree's metadata and the shared objects/refs, but must not prune or rewrite the metadata of another
|
|
316
|
+
* task's checkout. It still reads that metadata (gc keeps what a sibling's HEAD and index reach) and looks up the
|
|
317
|
+
* checkout it names: a checkout it can't see is one `git worktree prune` deletes the entry of.
|
|
318
|
+
*/
|
|
319
|
+
function siblingGitData(main, gitdir) {
|
|
346
320
|
const readOnly = new Set();
|
|
347
321
|
const lookup = new Set();
|
|
348
322
|
if (gitdir !== null && main !== null) {
|
|
@@ -386,15 +360,9 @@ export function taskBoundary(options) {
|
|
|
386
360
|
lookup.add(join(checkout, ".git"));
|
|
387
361
|
}
|
|
388
362
|
}
|
|
389
|
-
|
|
390
|
-
// By name too, present or not: a build started while ~/.codex is absent must never make it (and later swap it for a
|
|
391
|
-
// link while a sign-ins check reads through it). Only Seatbelt uses these (macosFenceProfile); bubblewrap can only
|
|
392
|
-
// mask what exists, and Codex's own sandbox takes existing paths.
|
|
393
|
-
const homeNames = HOME_CONFIGURATION.filter(name => !own.has(name)).map(name => join(home, name)).sort();
|
|
394
|
-
return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames };
|
|
363
|
+
return { readOnly, lookup };
|
|
395
364
|
}
|
|
396
|
-
/** The prefixes of every launch's private folders: its tools' config
|
|
397
|
-
* network bridge (egress-bridge.ts). */
|
|
365
|
+
/** The prefixes of every launch's private folders: its tools' config and a Codex build's own home (and a check's). */
|
|
398
366
|
export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
|
|
399
367
|
export function runFolders(own, root) {
|
|
400
368
|
// The system's own roots (not one a caller names): on a Mac, checks' launch folders are under the short one too.
|
|
@@ -402,9 +370,9 @@ export function runFolders(own, root) {
|
|
|
402
370
|
return { root: real(root ?? tmpdir()), own: own === null ? null : real(own), ...(also.length === 0 ? {} : { also }) };
|
|
403
371
|
}
|
|
404
372
|
/** Where a check's launch folder is made on a Mac: a short shared folder, so its TMPDIR leaves room for a unix socket
|
|
405
|
-
* path (about 104 bytes)
|
|
373
|
+
* path (about 104 bytes). */
|
|
406
374
|
export const CHECK_LAUNCH_ROOT_MACOS = "/private/tmp";
|
|
407
|
-
/** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder
|
|
375
|
+
/** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder. */
|
|
408
376
|
export const checkLaunchRoot = () => process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? CHECK_LAUNCH_ROOT_MACOS : tmpdir();
|
|
409
377
|
/** Every other launch's private folder in the system temp folder now, each only as the real folder it is (a link planted
|
|
410
378
|
* there is never followed, nor fenced in its target's place) and this user's own. A shared root (`also`, /private/tmp on
|
|
@@ -587,17 +555,6 @@ export function buildCacheEnv(folder) {
|
|
|
587
555
|
DENO_DIR: at("deno"), XDG_CACHE_HOME: at("xdg"),
|
|
588
556
|
};
|
|
589
557
|
}
|
|
590
|
-
/**
|
|
591
|
-
* Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
|
|
592
|
-
* Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
|
|
593
|
-
* (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
|
|
594
|
-
* confined; the run's proxy resolves names for it.
|
|
595
|
-
*/
|
|
596
|
-
export const DELEGATION_SERVICES = [
|
|
597
|
-
"com.apple.coreservices.launchservicesd", "com.apple.coreservices.quarantine-resolver", "com.apple.lsd.mapdb", "com.apple.lsd.modifydb", "com.apple.lsd.open",
|
|
598
|
-
"com.apple.coreservices.appleevents", "com.apple.nsurlsessiond", "com.apple.nsurlsessiond.NSURLSessionProxyService",
|
|
599
|
-
"com.apple.dnssd.service", "com.apple.mDNSResponder", "com.apple.pasteboard.1",
|
|
600
|
-
];
|
|
601
558
|
export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
|
|
602
559
|
/**
|
|
603
560
|
* The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
|
|
@@ -713,6 +670,35 @@ const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
|
713
670
|
* `codexPool`, which denies them around the build's own checkout, written back.
|
|
714
671
|
*/
|
|
715
672
|
export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
|
|
673
|
+
const access = codexFilesystem(fence, fullAccess, grants);
|
|
674
|
+
const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
|
|
675
|
+
return [
|
|
676
|
+
"-c", 'default_permissions="standing-orders"',
|
|
677
|
+
"-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
|
|
678
|
+
"-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
|
|
679
|
+
// Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
|
|
680
|
+
// network is open (and Full access asked for it), and are offline otherwise.
|
|
681
|
+
...(codexNetworkOn(fullAccess, grants) ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
|
|
682
|
+
...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
const codexNetworkOn = (fullAccess, grants) => fullAccess && (grants.network === undefined || grants.network.mode === "open");
|
|
686
|
+
/**
|
|
687
|
+
* The same permission profile as codexFenceArgv, as the config overrides a Codex app-server thread takes
|
|
688
|
+
* (`thread/start` and `thread/resume` `config`; checked against codex-cli 0.156: the thread reports
|
|
689
|
+
* `activePermissionProfile` standing-orders, and its commands are refused the denied paths). Approval policy is the
|
|
690
|
+
* caller's own (an interactive session asks).
|
|
691
|
+
*/
|
|
692
|
+
export function codexFenceConfig(fence, fullAccess, grants = NO_GRANTS) {
|
|
693
|
+
return {
|
|
694
|
+
default_permissions: "standing-orders",
|
|
695
|
+
"permissions.standing-orders.extends": grants.writes === undefined ? ":workspace" : ":read-only",
|
|
696
|
+
"permissions.standing-orders.filesystem": Object.fromEntries(codexFilesystem(fence, fullAccess, grants)),
|
|
697
|
+
...(codexNetworkOn(fullAccess, grants) ? { "permissions.standing-orders.network.enabled": true } : {}),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
/** Each path of a Codex permission profile and its access, the strongest last (a deny outranks a grant). */
|
|
701
|
+
function codexFilesystem(fence, fullAccess, grants) {
|
|
716
702
|
const { deny, readOnly } = flattened(fence, grants);
|
|
717
703
|
// Codex's harness reads its own launch files outside the command sandbox.
|
|
718
704
|
// Deny the namespace, including folders created after this policy was built.
|
|
@@ -747,27 +733,39 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
|
|
|
747
733
|
for (const path of held.read)
|
|
748
734
|
access.set(path, "read");
|
|
749
735
|
for (const path of held.deny)
|
|
750
|
-
access.set(path, "deny");
|
|
736
|
+
access.set(path, pool.readable === true && !(pool.sealed ?? []).some(one => path === one || inside(path, one)) ? "read" : "deny");
|
|
737
|
+
// A sealed folder (flows' scratch) is denied whole, but on the way to this launch's own folder (Codex can't deny an
|
|
738
|
+
// ancestor and still reach inside it: its other entries are denied one by one, by codexPool, from the same split).
|
|
739
|
+
for (const sealed of pool.sealed ?? [])
|
|
740
|
+
if (!(pool.own !== null && (pool.own === sealed || inside(pool.own, sealed))))
|
|
741
|
+
access.set(sealed, "deny");
|
|
751
742
|
for (const path of held.write)
|
|
752
743
|
access.set(path, "write");
|
|
753
744
|
}
|
|
754
|
-
//
|
|
745
|
+
// What no launch writes (homeWriteDenies): read-only, where Codex writes at all (Full access, or an allowlist).
|
|
746
|
+
// Never inside a denied path: Codex lets the more specific path win, so a read there would bring it back.
|
|
747
|
+
const deniedPaths = deny.filter(one => !one.includes("*")).map(real);
|
|
748
|
+
for (const path of grants.writeDenies ?? []) {
|
|
749
|
+
const leads = resolvedPath(path);
|
|
750
|
+
const denied = (one) => access.get(one) === "deny" || deniedPaths.some(den => one === den || inside(one, den));
|
|
751
|
+
// A name that leads into a denied path (a linked ~/.ssh into a read-denied folder) is denied by its own name too,
|
|
752
|
+
// never read back.
|
|
753
|
+
if (denied(leads)) {
|
|
754
|
+
access.set(path, "deny");
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (denied(path))
|
|
758
|
+
continue;
|
|
759
|
+
access.set(path, "read");
|
|
760
|
+
}
|
|
761
|
+
// Denies are keyed where they resolve; the console's run folder is also denied by its
|
|
755
762
|
// own pathname, so a link there stays unreplaceable, not only its target.
|
|
756
763
|
for (const path of deny) {
|
|
757
764
|
access.set(real(path), "deny");
|
|
758
|
-
if (basename(path) === INSTANCE_DIR
|
|
765
|
+
if (basename(path) === INSTANCE_DIR)
|
|
759
766
|
access.set(path, "deny");
|
|
760
767
|
}
|
|
761
|
-
|
|
762
|
-
return [
|
|
763
|
-
"-c", 'default_permissions="standing-orders"',
|
|
764
|
-
"-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
|
|
765
|
-
"-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
|
|
766
|
-
// Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
|
|
767
|
-
// network is open (and Full access asked for it), and are offline otherwise.
|
|
768
|
-
...(fullAccess && (grants.network === undefined || grants.network.mode === "open") ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
|
|
769
|
-
...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
|
|
770
|
-
];
|
|
768
|
+
return access;
|
|
771
769
|
}
|
|
772
770
|
/**
|
|
773
771
|
* Codex cannot deny an ancestor and still traverse it to the own checkout. Deny existing
|
|
@@ -864,140 +862,7 @@ export function claudeFenceSettings(fence, readOnly = []) {
|
|
|
864
862
|
const rules = [...fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]), ...readOnly.flatMap(path => [`Edit(/${path})`, `Edit(/${path}/**)`])];
|
|
865
863
|
return JSON.stringify({ permissions: { deny: rules } });
|
|
866
864
|
}
|
|
867
|
-
const
|
|
868
|
-
const seatbeltRegex = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
869
|
-
/**
|
|
870
|
-
* The folders the user owns above each path (never `/`, nor the path itself), and each `roots` folder itself when the
|
|
871
|
-
* user owns it. A Seatbelt path rule follows the name, not the folder: renaming one of these would bring a fenced path
|
|
872
|
-
* back under a name no rule mentions. A folder the user doesn't own can't be renamed by a build anyway.
|
|
873
|
-
*/
|
|
874
|
-
export function ownedAncestors(paths, roots = [], uid = process.getuid?.()) {
|
|
875
|
-
const out = new Set();
|
|
876
|
-
const owned = (path) => { try {
|
|
877
|
-
return uid !== undefined && statSync(path).uid === uid;
|
|
878
|
-
}
|
|
879
|
-
catch {
|
|
880
|
-
return false;
|
|
881
|
-
} };
|
|
882
|
-
const climb = (from) => {
|
|
883
|
-
for (let folder = from; folder !== dirname(folder); folder = dirname(folder))
|
|
884
|
-
if (owned(folder))
|
|
885
|
-
out.add(folder);
|
|
886
|
-
};
|
|
887
|
-
for (const path of paths)
|
|
888
|
-
climb(dirname(path));
|
|
889
|
-
for (const root of roots)
|
|
890
|
-
climb(root);
|
|
891
|
-
return [...out].sort();
|
|
892
|
-
}
|
|
893
|
-
/**
|
|
894
|
-
* A Seatbelt profile that allows everything except reading or writing the
|
|
895
|
-
* fenced paths, less what `grants` gives back (a later Seatbelt rule
|
|
896
|
-
* outranks an earlier one). Whatever the paths, the process can't read
|
|
897
|
-
* another's arguments and environment (`kern.procargs`, which `ps -E` and
|
|
898
|
-
* KERN_PROCARGS2 use) or inspect a process outside its own sandbox:
|
|
899
|
-
* `process-info*` limited by target alone doesn't stop the sysctl. It can't
|
|
900
|
-
* take another process's task port either (`task_for_pid`, what lldb attaches
|
|
901
|
-
* with; a task name port too), so a tool server's or Toolroll's own memory
|
|
902
|
-
* and environment stay out of reach. Last, every folder of the user's above
|
|
903
|
-
* a fenced or read-only path and above the temp folder (and the temp folder
|
|
904
|
-
* itself) can't be renamed or removed, though what is inside them stays
|
|
905
|
-
* writable as before. The checkout pools are denied first, and the build's own
|
|
906
|
-
* checkout given back right after, so every later deny still holds inside it.
|
|
907
|
-
*/
|
|
908
|
-
export function macosFenceProfile(fence, grants = NO_GRANTS) {
|
|
909
|
-
const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
|
|
910
|
-
const folders = grants.runFolders;
|
|
911
|
-
const pool = grants.pool;
|
|
912
|
-
const homeNames = grants.homeNames ?? [];
|
|
913
|
-
const above = ownedAncestors([...fence, ...grants.readOnly, ...homeNames, ...pool?.deny ?? []], folders === undefined ? [real(tmpdir())] : runRoots(folders));
|
|
914
|
-
// A home name's folder that isn't there yet (~/.config for ~/.config/gh): made by the build, it could be a link to
|
|
915
|
-
// anywhere and carry the name out of its deny, so nothing may make it while the build runs.
|
|
916
|
-
const missingAbove = [...new Set(homeNames.flatMap(name => {
|
|
917
|
-
const out = [];
|
|
918
|
-
for (let folder = dirname(name); folder !== dirname(folder) && !existsSync(folder); folder = dirname(folder))
|
|
919
|
-
out.push(folder);
|
|
920
|
-
return out;
|
|
921
|
-
}))].sort();
|
|
922
|
-
return [
|
|
923
|
-
"(version 1)",
|
|
924
|
-
"(allow default)",
|
|
925
|
-
// A workspace build writes only its allowlist (and the terminal devices); every deny and read-only rule after this
|
|
926
|
-
// still holds inside it.
|
|
927
|
-
...(grants.writes === undefined ? [] : [
|
|
928
|
-
"(deny file-write*)",
|
|
929
|
-
`(allow file-write* (subpath "/dev")${grants.writes.subpaths.length === 0 ? "" : ` ${paths("subpath", grants.writes.subpaths)}`}${grants.writes.prefixes.map(one => ` (regex #"^${seatbeltRegex(one.path)}${one.suffix}")`).join("")})`,
|
|
930
|
-
// A granted folder itself can't be renamed, removed or replaced (by a link to anywhere, say): only what is in it.
|
|
931
|
-
...(grants.writes.subpaths.length === 0 ? [] : [`(deny file-write* ${paths("literal", grants.writes.subpaths)})`]),
|
|
932
|
-
]),
|
|
933
|
-
...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
|
|
934
|
-
...(pool?.own == null ? [] : [
|
|
935
|
-
`(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
|
|
936
|
-
`(deny file-write* ${paths("literal", [pool.own])})`,
|
|
937
|
-
]),
|
|
938
|
-
...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
|
|
939
|
-
...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
|
|
940
|
-
// The home configuration by name, there or not: nothing may create, read, replace or write one (the sign-ins grants
|
|
941
|
-
// after this give back only their exact files).
|
|
942
|
-
...(homeNames.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", homeNames)})`]),
|
|
943
|
-
...(missingAbove.length === 0 ? [] : [`(deny file-write* ${paths("literal", missingAbove)})`]),
|
|
944
|
-
...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
|
|
945
|
-
...(folders === undefined ? [] : runRoots(folders).map(root => `(deny file-read* file-write* (regex #"^${seatbeltRegex(root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`)),
|
|
946
|
-
...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
|
|
947
|
-
// Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
|
|
948
|
-
...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
|
|
949
|
-
// A sign-ins check (check-access.ts): its exact sign-in files read, its own transcript folder written; nothing else
|
|
950
|
-
// of the home configuration comes back.
|
|
951
|
-
...(grants.signIns === undefined || grants.signIns.read.length === 0 ? [] : [`(allow file-read* ${paths("literal", grants.signIns.read)})`]),
|
|
952
|
-
// Its own transcript folder: only the session files directly in it (a session can be resumed). Nothing else there —
|
|
953
|
-
// never memory/ or any folder Claude would load into a later real session — comes back.
|
|
954
|
-
...(grants.signIns === undefined || grants.signIns.transcripts.length === 0 ? [] : [
|
|
955
|
-
`(allow file-read-data file-read-metadata ${paths("literal", grants.signIns.transcripts)})`,
|
|
956
|
-
`(allow file-read* file-write* ${grants.signIns.transcripts.map(folder => `(regex #"^${seatbeltRegex(folder)}/[^/]+\\.jsonl$")`).join(" ")})`,
|
|
957
|
-
]),
|
|
958
|
-
// Each readable copy's own folder can be listed, never read: bundlers such as esbuild find node_modules by listing
|
|
959
|
-
// its parent, which a lookup alone refuses (the Linux and Codex fences list it as read-only already).
|
|
960
|
-
...(grants.readable.length === 0 ? [] : [`(allow file-read-data ${paths("literal", [...new Set(grants.readable.map(one => dirname(one)))])})`]),
|
|
961
|
-
// Its own launch folder: what is in it, never the folder itself (it can't be renamed, removed or replaced by a link).
|
|
962
|
-
...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`, `(deny file-write* ${paths("literal", [folders.own])})`]),
|
|
963
|
-
'(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
|
|
964
|
-
"(deny process-info-pidinfo)",
|
|
965
|
-
"(allow process-info-pidinfo (target same-sandbox))",
|
|
966
|
-
"(deny mach-priv-task-port)",
|
|
967
|
-
"(deny mach-task-name)",
|
|
968
|
-
...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
|
|
969
|
-
...macosNetworkRules(grants.network),
|
|
970
|
-
// A workspace build reaches no unix socket but its own (a container engine's, an agent's, a database's would act
|
|
971
|
-
// outside its limits for it), whatever its network: a confined network has its own rule for this already. Its open
|
|
972
|
-
// network still names hosts: the system's DNS service answers on a unix socket, and without it nothing resolves.
|
|
973
|
-
...(grants.writes === undefined || grants.network?.mode === "allowlist" ? [] : [
|
|
974
|
-
"(deny network-outbound (remote unix-socket))",
|
|
975
|
-
`(allow network-outbound (remote unix-socket (path-literal ${seatbelt(MACOS_DNS_SOCKET)})))`,
|
|
976
|
-
...(grants.writes.subpaths.length === 0 ? [] : [`(allow network-outbound ${grants.writes.subpaths.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
|
|
977
|
-
]),
|
|
978
|
-
].join("\n");
|
|
979
|
-
}
|
|
980
|
-
/**
|
|
981
|
-
* The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
|
|
982
|
-
* allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
|
|
983
|
-
* allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
|
|
984
|
-
* or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
|
|
985
|
-
* connection.
|
|
986
|
-
*/
|
|
987
|
-
export function macosNetworkRules(network) {
|
|
988
|
-
if (network === undefined || network.mode === "open")
|
|
989
|
-
return [];
|
|
990
|
-
const local = (port) => `(remote ip "localhost:${port}")`;
|
|
991
|
-
return [
|
|
992
|
-
"(deny network-outbound)",
|
|
993
|
-
`(allow network-outbound ${network.loopback === "any" ? local("*") : [network.proxyPort, ...network.brokerPorts].map(local).join(" ")})`,
|
|
994
|
-
...(network.socketRoots.length === 0 ? [] : [`(allow network-outbound ${network.socketRoots.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
|
|
995
|
-
`(deny mach-lookup ${DELEGATION_SERVICES.map(name => `(global-name ${seatbelt(name)})`).join(" ")})`,
|
|
996
|
-
];
|
|
997
|
-
}
|
|
998
|
-
export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
999
|
-
/** Where macOS's resolver (mDNSResponder) answers: every name lookup on a Mac is a message to this socket. */
|
|
1000
|
-
export const MACOS_DNS_SOCKET = "/private/var/run/mDNSResponder";
|
|
865
|
+
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
1001
866
|
/** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
|
|
1002
867
|
const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
|
|
1003
868
|
let seatbeltWorks = null;
|
|
@@ -1023,223 +888,130 @@ export function macosFenceAvailable(platform = process.platform, probe) {
|
|
|
1023
888
|
if (probe !== undefined)
|
|
1024
889
|
return works(probe);
|
|
1025
890
|
seatbeltWorks ??= works(() => {
|
|
1026
|
-
const ran = spawnSync(SANDBOX_EXEC, ["-p",
|
|
891
|
+
const ran = spawnSync(SANDBOX_EXEC, ["-p", SEATBELT_PROBE, "/usr/bin/true"], { timeout: 5_000, encoding: "utf8" });
|
|
1027
892
|
return { status: ran.status, stderr: ran.stderr ?? "" };
|
|
1028
893
|
});
|
|
1029
894
|
return seatbeltWorks;
|
|
1030
895
|
}
|
|
1031
|
-
/**
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
/** Whether bubblewrap runs here (checked once): installed, and allowed to make its namespace. */
|
|
1036
|
-
let bubblewrapWorks = null;
|
|
1037
|
-
export function linuxFenceAvailable(platform = process.platform, probe = () => spawnSync("bwrap", ["--dev-bind", "/", "/", "--", "true"], { timeout: 5_000, stdio: "ignore" }).status === 0) {
|
|
896
|
+
/** Whether the sandbox runtime can sandbox here on Linux (checked once): bubblewrap, socat, its seccomp helper and
|
|
897
|
+
* ripgrep installed, and unprivileged user namespaces allowed. A machine that can't is no Linux fence at all. */
|
|
898
|
+
let linuxWorks = null;
|
|
899
|
+
export function linuxFenceAvailable(platform = process.platform, probe = () => sandboxRuntimeProblem("linux") === null) {
|
|
1038
900
|
if (platform !== "linux")
|
|
1039
901
|
return false;
|
|
1040
|
-
if (
|
|
902
|
+
if (linuxWorks === null) {
|
|
1041
903
|
try {
|
|
1042
|
-
|
|
904
|
+
linuxWorks = probe();
|
|
1043
905
|
}
|
|
1044
906
|
catch {
|
|
1045
|
-
|
|
907
|
+
linuxWorks = false;
|
|
1046
908
|
}
|
|
1047
909
|
}
|
|
1048
|
-
return
|
|
910
|
+
return linuxWorks;
|
|
911
|
+
}
|
|
912
|
+
/** Thrown when no sandbox can be made for a launch: the spawn road refuses it. */
|
|
913
|
+
export class LinuxFenceUnavailable extends Error {
|
|
1049
914
|
}
|
|
1050
|
-
/** A secret-name pattern (SPLIT_SECRET_PATTERNS) as a test of one path segment. */
|
|
1051
|
-
const patternTest = (pattern) => new RegExp(`^${pattern.split("*").map(seatbeltRegex).join(".*")}$`);
|
|
1052
|
-
const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
|
|
1053
915
|
/**
|
|
1054
|
-
* The
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
916
|
+
* The runtime's config for a launch (sandbox-runtime.ts SrtTerms), from its fence: every fenced path denied to read and
|
|
917
|
+
* write (with the exact paths read again inside them), the pools' other checkouts write-denied (and, under v1, read-
|
|
918
|
+
* denied) around the build's own, the flows' scratch folders sealed, the launch's writes (its allowlist, or anywhere
|
|
919
|
+
* outside the denies), its mach services and its network.
|
|
1058
920
|
*/
|
|
1059
|
-
function
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
const
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
* folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
|
|
1071
|
-
* bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
|
|
1072
|
-
export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
|
|
1073
|
-
const network = grants.network?.mode === "allowlist" ? grants.network : null;
|
|
1074
|
-
if (network !== null && network.bridge === undefined)
|
|
1075
|
-
throw new Error("A confined network on Linux needs its bridge");
|
|
1076
|
-
const mask = (path) => {
|
|
1077
|
-
let folder = true;
|
|
1078
|
-
try {
|
|
1079
|
-
folder = statSync(path).isDirectory();
|
|
1080
|
-
}
|
|
1081
|
-
catch {
|
|
1082
|
-
folder = true;
|
|
1083
|
-
}
|
|
1084
|
-
return folder ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path];
|
|
1085
|
-
};
|
|
1086
|
-
const pool = grants.pool?.deny ?? [];
|
|
1087
|
-
const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
|
|
1088
|
-
const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
|
|
1089
|
-
// A sign-ins check's exact files, bound back over the masks (validated when granted: signInGrants).
|
|
1090
|
-
const signIns = grants.signIns === undefined ? [] : [
|
|
1091
|
-
// Each from the launch's own copy (made from the validated, opened file), never the path itself: a link swapped in
|
|
1092
|
-
// after validation is never followed by the bind. A file with no copy isn't granted.
|
|
1093
|
-
...grants.signIns.read.flatMap(path => { const copy = grants.signIns.copies?.[path]; return copy !== undefined && existsSync(copy) ? ["--ro-bind", copy, path] : []; }),
|
|
1094
|
-
// Bubblewrap can't bind by pattern: a session transcript folder is never given back writable on Linux (a check
|
|
1095
|
-
// there can't resume a Claude session; it can't plant anything a later session loads either).
|
|
1096
|
-
];
|
|
1097
|
-
// A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
|
|
1098
|
-
// mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
|
|
1099
|
-
const isolated = network === null ? [] : [
|
|
1100
|
-
"--unshare-net", "--unshare-pid", "--proc", "/proc",
|
|
1101
|
-
...linuxHostSockets(undefined, undefined, undefined, grants.writes !== undefined).flatMap(mask),
|
|
1102
|
-
"--ro-bind", network.bridge.folder, network.bridge.folder,
|
|
1103
|
-
];
|
|
1104
|
-
// A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
|
|
1105
|
-
// all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
|
|
1106
|
-
// A workspace build (arc 2) sees the whole filesystem read-only, a /dev of its own and a /tmp of its own, with only
|
|
1107
|
-
// its allowlist bound back writable; its own checkout's Git data is in that list.
|
|
1108
|
-
const writes = grants.writes;
|
|
1109
|
-
const root = writes === undefined ? ["--dev-bind", "/", "/"] : ["--ro-bind", "/", "/", "--dev", "/dev"];
|
|
1110
|
-
const writable = writes === undefined ? [] : [...writes.subpaths, ...writes.prefixes.map(one => one.path)].filter(path => existsSync(path)).flatMap(path => ["--bind", path, path]);
|
|
1111
|
-
// What a private /tmp gives back is read-only for a workspace build (its writable part is bound again after): the
|
|
1112
|
-
// checkout's whole shared Git data among it must not come back writable.
|
|
1113
|
-
// A confined network brings a /tmp of its own (arc 1); a workspace build with an open network sees the shared /tmp
|
|
1114
|
-
// read-only like the rest of the root, its own folders there bound back writable.
|
|
1115
|
-
const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network?.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => [writes === undefined ? "--bind" : "--ro-bind", path, path])];
|
|
1116
|
-
// A workspace build never reaches the host's own services either (a container engine would write anywhere for it),
|
|
1117
|
-
// whatever its network: confined networks mask them below already.
|
|
1118
|
-
const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
|
|
1119
|
-
const command = network === null ? [file, ...args]
|
|
1120
|
-
: [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
|
|
1121
|
-
return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...signIns, ...services, ...isolated, "--die-with-parent", "--", ...command] };
|
|
1122
|
-
}
|
|
1123
|
-
/** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
|
|
1124
|
-
* engines), wherever they exist here. */
|
|
1125
|
-
export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp", followLinks = false, env = process.env) {
|
|
1126
|
-
// The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
|
|
1127
|
-
const system = [
|
|
1128
|
-
...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
|
|
1129
|
-
"/var/run/podman", "/run/systemd/private", "/run/snapd.socket", "/run/screen",
|
|
1130
|
-
];
|
|
1131
|
-
const masks = new Set();
|
|
1132
|
-
for (const path of system) {
|
|
1133
|
-
try {
|
|
1134
|
-
masks.add(realpathSync(path));
|
|
1135
|
-
}
|
|
1136
|
-
catch { /* not here */ }
|
|
921
|
+
export function srtTermsOf(fence, grants = NO_GRANTS) {
|
|
922
|
+
const denyRead = new Set(fence.map(real));
|
|
923
|
+
// A readable grant's own folder is read too: bundlers (esbuild) find node_modules by listing it. The runtime can't
|
|
924
|
+
// grant a listing without the folder's files, so the shared copy's folder is read whole (its ready note among them).
|
|
925
|
+
const allowRead = new Set(grants.readable.flatMap(path => [real(path), real(dirname(path))]));
|
|
926
|
+
const denyWrite = new Set([...fence, ...grants.readOnly].map(real));
|
|
927
|
+
for (const path of grants.writeDenies ?? [])
|
|
928
|
+
denyWrite.add(path);
|
|
929
|
+
for (const name of grants.homeNames ?? []) {
|
|
930
|
+
denyRead.add(name);
|
|
931
|
+
denyWrite.add(name);
|
|
1137
932
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
if (owned(path))
|
|
1156
|
-
masks.add(path);
|
|
1157
|
-
// A workspace build can't plant a link in the home folder, so a link there is the operator's own: its target is
|
|
1158
|
-
// masked too (an engine moved elsewhere).
|
|
1159
|
-
else if (followLinks) {
|
|
1160
|
-
try {
|
|
1161
|
-
if (lstatSync(path).isSymbolicLink())
|
|
1162
|
-
masks.add(realpathSync(path));
|
|
1163
|
-
}
|
|
1164
|
-
catch { /* dangling */ }
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
// An engine Toolroll's own environment points at, wherever it is (never the build's to choose): always masked. So is
|
|
1168
|
-
// the SSH agent it names (it signs as you), which a shared /tmp would otherwise leave in reach.
|
|
1169
|
-
for (const name of ["DOCKER_HOST", "CONTAINER_HOST"]) {
|
|
1170
|
-
const socket = /^unix:\/\/(\/.+)$/.exec(env[name] ?? "")?.[1];
|
|
1171
|
-
if (socket !== undefined) {
|
|
1172
|
-
try {
|
|
1173
|
-
masks.add(realpathSync(socket));
|
|
933
|
+
for (const file of grants.signIns?.read ?? [])
|
|
934
|
+
allowRead.add(file);
|
|
935
|
+
// Every other launch's private folder unread by pattern (one made after this launch too), this launch's own read
|
|
936
|
+
// again. Writes are denied to those there at launch, under every root: the runtime can't write back inside a write
|
|
937
|
+
// deny, so a pattern would take this launch's own folder too (one made after launch is writable under `anywhere`).
|
|
938
|
+
const folders = grants.runFolders;
|
|
939
|
+
if (folders !== undefined) {
|
|
940
|
+
for (const root of runRoots(folders)) {
|
|
941
|
+
for (const prefix of RUN_FOLDER_PREFIXES)
|
|
942
|
+
denyRead.add(join(root, `${prefix}*`));
|
|
943
|
+
// And by name, each there right before the launch (this runs at the spawn): on Linux the runtime gives a pattern
|
|
944
|
+
// no mask where a path it allows lies beside its matches. Written: only where the launch writes anywhere (under a
|
|
945
|
+
// write allowlist they are out of its writes already).
|
|
946
|
+
for (const path of otherRunFolders({ root, own: folders.own })) {
|
|
947
|
+
denyRead.add(path);
|
|
948
|
+
if (grants.writes === undefined)
|
|
949
|
+
denyWrite.add(path);
|
|
1174
950
|
}
|
|
1175
|
-
catch { /* not there */ }
|
|
1176
951
|
}
|
|
952
|
+
if (folders.own !== null)
|
|
953
|
+
allowRead.add(folders.own);
|
|
1177
954
|
}
|
|
1178
|
-
const
|
|
1179
|
-
if (
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
955
|
+
const pool = grants.pool;
|
|
956
|
+
if (pool !== undefined) {
|
|
957
|
+
// Under v1 the pools are unread too, the build's own checkout read again; under v2 they are read (owner Q-C).
|
|
958
|
+
if (pool.readable !== true)
|
|
959
|
+
for (const root of pool.deny)
|
|
960
|
+
denyRead.add(root);
|
|
961
|
+
// The build's own checkout is read again inside whatever is denied around it (the pools, a flow's sealed scratch).
|
|
962
|
+
if (pool.own !== null)
|
|
963
|
+
allowRead.add(pool.own);
|
|
964
|
+
// Never written but the build's own checkout: the runtime can't write back inside a write deny, so each other
|
|
965
|
+
// entry on the way is denied instead (codexPool's split, as Codex takes it).
|
|
966
|
+
for (const path of codexPool(pool).deny)
|
|
967
|
+
denyWrite.add(path);
|
|
968
|
+
for (const sealed of pool.sealed ?? []) {
|
|
969
|
+
denyRead.add(sealed);
|
|
970
|
+
if (!(pool.own !== null && (pool.own === sealed || inside(pool.own, sealed))))
|
|
971
|
+
denyWrite.add(sealed);
|
|
1186
972
|
}
|
|
1187
|
-
catch { /* not there */ }
|
|
1188
973
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
//
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
masks.add(path);
|
|
1215
|
-
found = true;
|
|
1216
|
-
}
|
|
1217
|
-
else if (held.isDirectory() && !held.isSymbolicLink() && depth > 0 && socketIn(path, depth - 1)) {
|
|
1218
|
-
masks.add(path);
|
|
1219
|
-
found = true;
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
return found;
|
|
1223
|
-
};
|
|
1224
|
-
socketIn(tmp, 1);
|
|
1225
|
-
}
|
|
1226
|
-
return [...masks].sort();
|
|
974
|
+
const readConfig = { denyOnly: [...denyRead].sort(), allowWithinDeny: [...allowRead].sort() };
|
|
975
|
+
const writes = grants.writes;
|
|
976
|
+
// A sign-ins check also writes Codex's session and log folders and its own Claude transcripts (never inside a
|
|
977
|
+
// write deny: under policy v1 the home configuration stays unwritten whole).
|
|
978
|
+
const signInWrites = [...grants.signIns?.writes ?? [], ...(grants.signIns?.transcripts ?? []).map(folder => join(folder, "*.jsonl"))];
|
|
979
|
+
// Writing anywhere: everything but the denies. The launch's own checkout and folder are named too, so a backend that
|
|
980
|
+
// binds a read deny's re-allowed paths back (bubblewrap) binds those back writable.
|
|
981
|
+
const allowOnly = writes === undefined ? ["/", ...(pool?.own == null ? [] : [real(pool.own)]), ...(folders?.own == null ? [] : [folders.own])]
|
|
982
|
+
: [...writes.subpaths, ...writes.prefixes.map(one => `${one.path}*`), ...signInWrites, ...getDefaultWritePaths({ denyRead: readConfig.denyOnly, allowRead: readConfig.allowWithinDeny })];
|
|
983
|
+
const network = grants.network;
|
|
984
|
+
return {
|
|
985
|
+
readConfig,
|
|
986
|
+
writeConfig: { allowOnly: [...new Set(allowOnly)].sort(), denyWithinAllow: [...denyWrite].sort() },
|
|
987
|
+
allowMachLookup: [...grants.machLookup ?? [], ...grants.signIns?.mach ?? []],
|
|
988
|
+
...(grants.keychain === true ? { keychain: true } : {}),
|
|
989
|
+
network: network === undefined || network.mode === "open" ? { kind: "open" } : {
|
|
990
|
+
kind: "proxy", httpPort: network.proxyPort, ...(network.brokerPorts[0] === undefined ? {} : { socksPort: network.brokerPorts[0] }),
|
|
991
|
+
...(network.token === undefined ? {} : { token: network.token }), localServices: network.loopback === "any", unixSockets: [...network.socketRoots],
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/** The spawn that runs `file args` from `cwd` inside the sandbox runtime's sandbox (sandbox-runtime.ts srtSpawn), and the
|
|
996
|
+
* environment it needs beside the caller's. */
|
|
997
|
+
export function sandboxedSpawn(file, args, fence, grants = NO_GRANTS, cwd, temp) {
|
|
998
|
+
return srtSpawn(file, args, srtTermsOf(fence, grants), cwd ?? process.cwd(), temp);
|
|
1227
999
|
}
|
|
1228
1000
|
/**
|
|
1229
1001
|
* What each agent's fence gives it, by permission mode: the one table README's "What an agent can reach" shows
|
|
1230
1002
|
* (fenceSummaryMarkdown; a test holds them equal).
|
|
1231
1003
|
*/
|
|
1232
1004
|
export const FENCE_SUMMARY = [
|
|
1233
|
-
{
|
|
1234
|
-
agent: "**Codex / OpenRouter** (any OS)",
|
|
1235
|
-
auto: "Codex's sandbox with the fence: writes only the checkout, its Git data and the project's caches (anywhere outside the fence, under a project set to write anywhere); no network",
|
|
1236
|
-
full: "the same writes, with the fence; its commands reach the network only when the project's network is open",
|
|
1237
|
-
},
|
|
1238
1005
|
{
|
|
1239
1006
|
agent: "**Claude, Gemini, ACP agents**",
|
|
1240
|
-
auto: "macOS:
|
|
1007
|
+
auto: "Anthropic's sandbox-runtime (Seatbelt on macOS; bubblewrap and its seccomp filter on Linux, which also needs socat and ripgrep): reads your home folder except the folders the project lists, never Toolroll's folders; other tasks' work read, never written; writes only the checkout, its Git data, the project's caches, the run's temp folder and the agent's own session files (anywhere outside the fence, under a project set to write anywhere), never the runtime's protected names in the checkout (Git hooks and config, shell profiles, `.mcp.json`, editor settings); no Apple Events. Neither (Windows, or a Linux missing one of those): the build doesn't start, unless an instance operator allows unconfined agents",
|
|
1241
1008
|
full: "same",
|
|
1242
1009
|
},
|
|
1010
|
+
{
|
|
1011
|
+
agent: "**Codex / OpenRouter** (any OS)",
|
|
1012
|
+
auto: "Codex's own sandbox, given the same fence: reads your home folder except the folders the project lists, never Toolroll's folders; other tasks' work read, never written; writes only the checkout, its Git data and the project's caches (anywhere outside the fence, under a project set to write anywhere); no network",
|
|
1013
|
+
full: "the same reads and writes, with the fence; its commands reach the network only when the project's network is open",
|
|
1014
|
+
},
|
|
1243
1015
|
];
|
|
1244
1016
|
/** FENCE_SUMMARY as README's markdown table. */
|
|
1245
1017
|
export function fenceSummaryMarkdown() {
|
|
@@ -1247,112 +1019,223 @@ export function fenceSummaryMarkdown() {
|
|
|
1247
1019
|
}
|
|
1248
1020
|
/** The sign-in files a `sign-ins` check reads, by fixed name under the person's home (never from the environment, so no
|
|
1249
1021
|
* caller's CLAUDE_CONFIG_DIR or GH_CONFIG_DIR points a grant at .ssh): Claude's account and settings (and, on Linux,
|
|
1250
|
-
* its credentials file; on a Mac they are in the keychain),
|
|
1251
|
-
* of the launch's own (invoke.ts), so nothing of ~/.codex is granted. */
|
|
1022
|
+
* its credentials file; on a Mac they are in the keychain), gh's hosts and config, and Codex's config and sign-in. */
|
|
1252
1023
|
const SIGN_IN_FILES = (platform) => [
|
|
1253
1024
|
".claude.json", join(".claude", "settings.json"), ...(platform === "linux" ? [join(".claude", ".credentials.json")] : []),
|
|
1254
|
-
join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"),
|
|
1025
|
+
join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"), join(".codex", "config.toml"), join(".codex", "auth.json"),
|
|
1255
1026
|
];
|
|
1256
1027
|
/**
|
|
1257
|
-
* A `sign-ins` check's grants: each sign-in file
|
|
1258
|
-
* through
|
|
1259
|
-
*
|
|
1260
|
-
*
|
|
1028
|
+
* A `sign-ins` check's grants (sandbox parity C10, srt-style): each sign-in file by its name, read by the check itself
|
|
1029
|
+
* through its sandbox. Nothing privileged reads or copies one: the sandbox resolves the name in its own view, so a link
|
|
1030
|
+
* planted there into Toolroll's state leads to a denied path (Seatbelt checks the resolved file; bubblewrap resolves
|
|
1031
|
+
* inside its mount namespace, where the state is masked). On a Mac, the checkout's own Claude transcript folder (made
|
|
1032
|
+
* when absent, only as the real folder it is), whose session files alone are written; and Codex's session and log
|
|
1033
|
+
* folders (when ~/.codex is there), never its config.
|
|
1261
1034
|
*/
|
|
1262
1035
|
export function signInGrants(home, checkout, platform = process.platform) {
|
|
1263
1036
|
const base = real(home);
|
|
1264
|
-
|
|
1037
|
+
// Read by name, but only a name that is itself now: every folder from the home folder down a real one, the file a
|
|
1038
|
+
// regular file with one name (never a link, never a hard link to something else). The sandbox resolves it again.
|
|
1039
|
+
const read = SIGN_IN_FILES(platform).map(name => join(base, name)).filter(path => signInIsItself(base, path));
|
|
1040
|
+
const transcript = claudeProjectFolder(base, real(checkout));
|
|
1041
|
+
// A transcript folder Claude would name by prefix (a very long checkout path) isn't granted: never a prefix of others.
|
|
1042
|
+
const folder = transcript.byPrefix || !existsSync(join(base, ".claude")) ? null : grantFolder(transcript.path);
|
|
1043
|
+
const codex = join(base, ".codex");
|
|
1044
|
+
const writes = grantFolder(codex, false) === null ? [] : ["sessions", "log"].map(name => grantFolder(join(codex, name))).filter((path) => path !== null);
|
|
1045
|
+
return { read, transcripts: folder === null || platform !== "darwin" ? [] : [folder], writes, mach: platform === "darwin" ? ["com.apple.trustd.agent"] : [] };
|
|
1046
|
+
}
|
|
1047
|
+
/** Whether a sign-in file is itself, now: every folder from the (real) home folder down a real folder, the file a
|
|
1048
|
+
* regular file with one name (never a link, never a hard link to something else). Checked when granted and again right
|
|
1049
|
+
* before the launch (invoke.ts); the sandbox resolves the name a third time. */
|
|
1050
|
+
export function signInIsItself(base, path) {
|
|
1051
|
+
let at = base;
|
|
1052
|
+
const parts = relative(base, path).split(sep);
|
|
1053
|
+
if (parts.length === 0 || parts[0] === ".." || isAbsolute(relative(base, path)))
|
|
1054
|
+
return false;
|
|
1055
|
+
for (const [index, part] of parts.entries()) {
|
|
1056
|
+
at = join(at, part);
|
|
1265
1057
|
try {
|
|
1266
|
-
const held = lstatSync(
|
|
1267
|
-
|
|
1268
|
-
|
|
1058
|
+
const held = lstatSync(at);
|
|
1059
|
+
if (held.isSymbolicLink() || (index < parts.length - 1 ? !held.isDirectory() : !(held.isFile() && held.nlink === 1)))
|
|
1060
|
+
return false;
|
|
1269
1061
|
}
|
|
1270
1062
|
catch {
|
|
1271
1063
|
return false;
|
|
1272
1064
|
}
|
|
1273
|
-
});
|
|
1274
|
-
const transcript = claudeProjectFolder(base, real(checkout));
|
|
1275
|
-
// A transcript folder Claude would name by prefix (a very long checkout path) isn't granted: never a prefix of others.
|
|
1276
|
-
const folder = transcript.byPrefix || !existsSync(join(base, ".claude")) ? null : grantFolder(transcript.path);
|
|
1277
|
-
return { read, transcripts: folder === null || platform !== "darwin" ? [] : [folder] };
|
|
1278
|
-
}
|
|
1279
|
-
/**
|
|
1280
|
-
* A sign-in file's bytes, read only as the real regular file it is: its folder reached through no link (its real path
|
|
1281
|
-
* is its own), and the file itself opened without following a link and checked as a regular file through what was
|
|
1282
|
-
* opened, so a link swapped in at the file's own name is never read. Null for anything else.
|
|
1283
|
-
*
|
|
1284
|
-
* Its folders are still resolved by name (Node has no openat), so a folder above it swapped for a link between the
|
|
1285
|
-
* checks and the open would be followed. That is detected after the fact: every folder from / down to the file's own
|
|
1286
|
-
* is looked at (no link, the same device and inode) before the open and again after it, the file's real path is
|
|
1287
|
-
* checked again, and the name must still be the very file that was opened. Any difference reads nothing.
|
|
1288
|
-
*
|
|
1289
|
-
* Residual risk: a swap made and undone entirely between the two looks isn't seen. On a Mac every build's and check's
|
|
1290
|
-
* fence denies the home configuration names (~/.codex among them) by name, present or not, so no build or sandboxed
|
|
1291
|
-
* check can make that swap; only a check the operator set to Not sandboxed, or the person themselves, still could.
|
|
1292
|
-
* `afterOpen` is for tests: it runs between the open and the second look.
|
|
1293
|
-
*/
|
|
1294
|
-
export function readGrantedFile(path, seams = {}) {
|
|
1295
|
-
const folder = dirname(path);
|
|
1296
|
-
const chain = [];
|
|
1297
|
-
for (let at = folder;; at = dirname(at)) {
|
|
1298
|
-
chain.push(at);
|
|
1299
|
-
if (at === dirname(at))
|
|
1300
|
-
break;
|
|
1301
1065
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
export const FENCE_POLICY_CURRENT = 2;
|
|
1069
|
+
export const LEGACY_SANDBOX = { policy: 1, readDeny: [], machLookup: [] };
|
|
1070
|
+
/** A build's sandbox terms, exactly as its approval sealed them: an approval from before 0.9.70 is policy v1 (the home
|
|
1071
|
+
* configuration denied) with nothing listed. */
|
|
1072
|
+
export function launchSandboxOf(profile) {
|
|
1073
|
+
return { policy: profile.fencePolicy ?? 1, readDeny: profile.readDeny ?? [], machLookup: profile.machLookup ?? [] };
|
|
1074
|
+
}
|
|
1075
|
+
/** The one place a launch's fence is made (docs/plans/sandbox-parity.md C1): its denies, its grants, and
|
|
1076
|
+
* `confinement: "required"`, so the spawn road refuses it where no OS fence applies rather than running it bare. */
|
|
1077
|
+
export function fenceFor(kind, ctx) {
|
|
1078
|
+
const home = ctx.home === undefined ? {} : { home: ctx.home };
|
|
1079
|
+
// The project's read denies join the fence by name, there or not (the runtime denies a path that isn't there yet).
|
|
1080
|
+
const fence = agentFence({ databaseFile: ctx.databaseFile, worktree: ctx.worktree, ...home, boundary: ctx.boundary, binding: ctx.binding, runFolders: ctx.runFolders, extra: [...ctx.extra ?? [], ...ctx.readDeny ?? []] });
|
|
1081
|
+
const found = fenceGrants({ databaseFile: ctx.databaseFile, worktree: ctx.worktree, boundary: ctx.boundary, runFolders: ctx.runFolders, linked: ctx.linked ?? null, binding: ctx.binding, ...home, poolRoots: ctx.poolRoots });
|
|
1082
|
+
// Toolroll's own install, the sandbox runtime's package and the Node running them, never written by anything it
|
|
1083
|
+
// launches: rewriting the code that fences a build would switch the fence off.
|
|
1084
|
+
const made = { ...found, readOnly: [...new Set([...found.readOnly, ...toolrollInstall(ctx.worktree, ctx.home ?? homedir(), ctx.binding?.main ?? null)])].sort() };
|
|
1085
|
+
const written = ctx.writes ?? null;
|
|
1086
|
+
const grants = {
|
|
1087
|
+
...made,
|
|
1088
|
+
...(written === null ? {} : { writes: written.writes, readOnly: [...new Set([...made.readOnly, ...written.readOnly])].sort() }),
|
|
1089
|
+
...(ctx.network === undefined ? {} : { network: ctx.network }),
|
|
1090
|
+
...(ctx.machLookup === undefined || ctx.machLookup.length === 0 ? {} : { machLookup: [...ctx.machLookup] }),
|
|
1091
|
+
// A build's agent signs in from the keychain (Claude Code does); nothing else it launches reaches it.
|
|
1092
|
+
...(kind === "build" ? { keychain: true } : {}),
|
|
1093
|
+
// Under a write allowlist these are out of reach already; where the launch writes anywhere, they are denied by name.
|
|
1094
|
+
writeDenies: homeWriteDenies(ctx.home ?? homedir(), ctx.binding, ctx.provider ?? null),
|
|
1095
|
+
...(made.pool === undefined ? {} : { pool: { ...made.pool, ...(ctx.policy === 2 ? { readable: true } : {}),
|
|
1096
|
+
// Flows' scratch folders in the pool (and any the caller names) stay denied whole, at every policy version.
|
|
1097
|
+
sealed: sealedScratch(ctx.poolRoots, ctx.scratchRoots ?? []) } }),
|
|
1311
1098
|
};
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1099
|
+
return { kind, fence, fenceGrants: grants, confinement: "required" };
|
|
1100
|
+
}
|
|
1101
|
+
/** The flows' scratch folders inside the checkout pools (`flow-checks` beside the checkouts) and any a caller names,
|
|
1102
|
+
* each denied by name whether or not it is there yet, and where it is a link, where it leads too. */
|
|
1103
|
+
export function sealedScratch(poolRoots, named) {
|
|
1104
|
+
const out = new Set();
|
|
1105
|
+
for (const path of [...poolRoots.filter((root) => root !== null).map(root => join(real(root), "flow-checks")), ...named.map(one => join(real(dirname(one)), basename(one)))]) {
|
|
1106
|
+
out.add(path);
|
|
1318
1107
|
try {
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
return null;
|
|
1322
|
-
seams.afterOpen?.();
|
|
1323
|
-
const after = look();
|
|
1324
|
-
if (after === null || !same(before, after) || realpathSync(folder) !== folder)
|
|
1325
|
-
return null;
|
|
1326
|
-
const named = lstatSync(path);
|
|
1327
|
-
if (named.isSymbolicLink() || named.dev !== held.dev || named.ino !== held.ino)
|
|
1328
|
-
return null;
|
|
1329
|
-
const bytes = readFileSync(fd);
|
|
1330
|
-
// And once more after the read: what was read must still be what the checked name holds.
|
|
1331
|
-
const last = look();
|
|
1332
|
-
return last !== null && same(before, last) ? bytes : null;
|
|
1333
|
-
}
|
|
1334
|
-
finally {
|
|
1335
|
-
closeSync(fd);
|
|
1108
|
+
if (lstatSync(path).isSymbolicLink())
|
|
1109
|
+
out.add(realpathSync(path));
|
|
1336
1110
|
}
|
|
1111
|
+
catch { /* not there, or dangling: denied by name */ }
|
|
1337
1112
|
}
|
|
1338
|
-
|
|
1339
|
-
return null;
|
|
1340
|
-
}
|
|
1113
|
+
return [...out].sort();
|
|
1341
1114
|
}
|
|
1342
1115
|
/**
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1116
|
+
* Toolroll's own install as no launch may write it: this package's folder (its compiled code, the sandbox launch
|
|
1117
|
+
* helper among it), the sandbox runtime's package wherever it was installed, and the Node running them with the
|
|
1118
|
+
* folder it lives in (its npm and global packages beside it), as the real folders they are. Never a folder that holds
|
|
1119
|
+
* the launch's own checkout or its repository (a build of Toolroll's own source, run from it: that repository's other
|
|
1120
|
+
* files are the primary checkout's, write-denied already), the home folder or one above it, and nothing the user
|
|
1121
|
+
* doesn't own (root's folders are out of a build's reach already).
|
|
1346
1122
|
*/
|
|
1347
|
-
export function
|
|
1348
|
-
const
|
|
1123
|
+
export function toolrollInstall(worktree, home, repository = null) {
|
|
1124
|
+
const own = worktree === null ? null : real(worktree);
|
|
1125
|
+
const repo = repository === null ? null : real(repository);
|
|
1126
|
+
const uid = process.getuid?.();
|
|
1127
|
+
const ours = (path) => { try {
|
|
1128
|
+
return uid === undefined || statSync(path).uid === uid;
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
return false;
|
|
1132
|
+
} };
|
|
1133
|
+
let runtime = [];
|
|
1349
1134
|
try {
|
|
1350
|
-
|
|
1351
|
-
if (held.isSymbolicLink() || !held.isDirectory() || (uid !== undefined && held.uid !== uid) || (held.mode & 0o777) !== 0o700)
|
|
1352
|
-
return null;
|
|
1353
|
-
return grantFolder(root, false) === root ? root : null;
|
|
1135
|
+
runtime = [dirname(dirname(createRequire(import.meta.url).resolve("@anthropic-ai/sandbox-runtime")))];
|
|
1354
1136
|
}
|
|
1355
1137
|
catch {
|
|
1356
|
-
|
|
1138
|
+
runtime = [];
|
|
1139
|
+
}
|
|
1140
|
+
const candidates = [resolve(dirname(fileURLToPath(import.meta.url)), ".."), ...runtime, dirname(dirname(real(process.execPath))), real(process.execPath)].map(real);
|
|
1141
|
+
const homeReal = real(home);
|
|
1142
|
+
return [...new Set(candidates)].filter(path => path !== dirname(path) && path !== homeReal && !inside(homeReal, path)
|
|
1143
|
+
&& !(own !== null && (own === path || inside(own, path))) && !(repo !== null && (repo === path || inside(repo, path))) && ours(path)).sort();
|
|
1144
|
+
}
|
|
1145
|
+
/** Names that run code or reconfigure tools for whoever opens the project next: the sandbox runtime's DANGEROUS_FILES
|
|
1146
|
+
* (@anthropic-ai/sandbox-runtime sandbox-utils), and the names its mandatory denies can't hold when they are made
|
|
1147
|
+
* after a launch starts (`.envrc`, package-manager and Claude settings). */
|
|
1148
|
+
export const PROTECTED_FILES = [".gitconfig", ".gitmodules", ".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile", ".ripgreprc", ".mcp.json",
|
|
1149
|
+
".envrc", ".npmrc", ".yarnrc", ".yarnrc.yml", ".claude/settings.json", ".claude/settings.local.json", ".git/config"];
|
|
1150
|
+
/** Folders protected whole, wherever they are (the runtime's dangerous directories, with Git's hooks). */
|
|
1151
|
+
export const PROTECTED_DIRS = [".vscode", ".idea", ".claude/commands", ".claude/agents", ".git/hooks"];
|
|
1152
|
+
/**
|
|
1153
|
+
* The handoff's backstop (builder.ts commit): the changed paths (relative, from Git) that are protected names at any
|
|
1154
|
+
* depth, in any case. The sandbox runtime denies these when a launch starts; one made after it started (a new nested
|
|
1155
|
+
* folder's `.envrc`, say) gets through the sandbox, and a result that holds it is refused.
|
|
1156
|
+
*/
|
|
1157
|
+
export function protectedChanges(changed) {
|
|
1158
|
+
const files = PROTECTED_FILES.map(one => one.toLowerCase());
|
|
1159
|
+
return changed.filter(path => {
|
|
1160
|
+
const lower = `/${path.toLowerCase()}`;
|
|
1161
|
+
// A protected folder's own name too (a link committed as `pkg/.vscode`), not only what is in it.
|
|
1162
|
+
return files.some(name => lower.endsWith(`/${name}`)) || PROTECTED_DIRS.some(dir => lower.includes(`/${dir.toLowerCase()}/`) || lower.endsWith(`/${dir.toLowerCase()}`));
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* The handoff's backstop for links (builder.ts commit): a staged symbolic link (Git mode 120000) whose path is a
|
|
1167
|
+
* protected name or a folder on the way to one (`pkg/.claude`, `.vscode`, `lib/.git`), at any depth, in any case. A
|
|
1168
|
+
* link there would lead whoever opens the project to settings, hooks or commands kept anywhere.
|
|
1169
|
+
*/
|
|
1170
|
+
export function protectedLinks(links) {
|
|
1171
|
+
const entries = [...PROTECTED_FILES, ...PROTECTED_DIRS].map(one => one.toLowerCase().split("/"));
|
|
1172
|
+
return links.filter(path => {
|
|
1173
|
+
const parts = path.toLowerCase().split("/").filter(one => one !== "");
|
|
1174
|
+
return entries.some(entry => {
|
|
1175
|
+
// The link's last parts are the entry's first ones (the whole entry, or a folder on the way to it).
|
|
1176
|
+
for (let take = 1; take <= Math.min(entry.length, parts.length); take++) {
|
|
1177
|
+
if (parts.slice(parts.length - take).join("/") === entry.slice(0, take).join("/"))
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
return false;
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
/** The home folder's own persistence points beyond the protected names (login items, systemd user units and autostart,
|
|
1185
|
+
* Git's, gh's, pip's and fish's configuration, the shells' other startup files, SSH and GnuPG, and the folders on a
|
|
1186
|
+
* login PATH). Under `.config/` each also stands under $XDG_CONFIG_HOME. */
|
|
1187
|
+
export const HOME_PERSISTENCE = ["Library/LaunchAgents", ".config/systemd/user", ".config/autostart", ".config/git", ".config/gh", ".config/pip",
|
|
1188
|
+
".config/fish", ".pip", ".zshenv", ".zlogin", ".bash_login", ".ssh", ".gnupg", ".local/bin", "bin"];
|
|
1189
|
+
/** The sign-in folders and files at home no launch writes, but an agent its own exact sign-in files (OWN_SIGN_INS). */
|
|
1190
|
+
const HOME_SIGN_INS = [".codex", ".claude.json", ".claude/.credentials.json"];
|
|
1191
|
+
/** The exact sign-in files an agent's own launch still writes (Claude's account and credentials). Codex's harness
|
|
1192
|
+
* refreshes its sign-in outside the sandbox, so its commands write none. Configuration and persistence names stay
|
|
1193
|
+
* denied for every agent. */
|
|
1194
|
+
const OWN_SIGN_INS = { claude: [".claude.json", ".claude/.credentials.json"] };
|
|
1195
|
+
/** `path` as it resolves now: through every link above it (and at it), however much of it exists. */
|
|
1196
|
+
function resolvedPath(path) {
|
|
1197
|
+
let head = path;
|
|
1198
|
+
const rest = [];
|
|
1199
|
+
while (head !== dirname(head)) {
|
|
1200
|
+
try {
|
|
1201
|
+
return join(realpathSync(head), ...rest.reverse());
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
rest.push(basename(head));
|
|
1205
|
+
head = dirname(head);
|
|
1206
|
+
}
|
|
1357
1207
|
}
|
|
1208
|
+
return path;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* What no launch writes, whatever its writes, as static write denies for the sandbox runtime (whose own protected names
|
|
1212
|
+
* are anchored at the launch's folder only) and Codex's profile: the home folder's protected names (shell profiles, Git
|
|
1213
|
+
* and tool configuration), its persistence points and sign-ins (but the launch's own agent's exact sign-in files), and
|
|
1214
|
+
* the bound repository's own Git config, hooks and submodules (a linked worktree reaches them through its `.git`
|
|
1215
|
+
* file). Each by its name and by where it leads now (a linked ~/.config or LaunchAgents is denied at both ends). On
|
|
1216
|
+
* Linux only those there at launch: the runtime holds an absent name with a stand-in nothing can read for the launch's
|
|
1217
|
+
* length, and the person's own git and shells then fail on their ~/.gitconfig and ~/.bashrc (measured). Under a write
|
|
1218
|
+
* allowlist an absent one is out of reach anyway; under `anywhere` it can be made (an accepted limit).
|
|
1219
|
+
*/
|
|
1220
|
+
export function homeWriteDenies(home, binding, provider, platform = process.platform, env = process.env) {
|
|
1221
|
+
const base = real(home);
|
|
1222
|
+
const own = new Set(OWN_SIGN_INS[provider ?? ""] ?? []);
|
|
1223
|
+
const names = [...new Set([...PROTECTED_FILES.filter(name => !name.startsWith(".git/")), ...PROTECTED_DIRS.filter(name => !name.startsWith(".git/")), ...HOME_PERSISTENCE, ...HOME_SIGN_INS])]
|
|
1224
|
+
.filter(name => !own.has(name));
|
|
1225
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
1226
|
+
const roots = (name) => [join(base, name), ...(name.startsWith(".config/") && xdg !== undefined && isAbsolute(xdg) ? [join(xdg, name.slice(".config/".length))] : [])];
|
|
1227
|
+
const paths = names.flatMap(name => roots(name).filter(path => platform !== "linux" || existsSync(path)));
|
|
1228
|
+
const git = binding === null ? [] : ["config", "hooks", "modules"].map(name => join(real(join(binding.main, ".git")), name));
|
|
1229
|
+
return [...new Set([...paths, ...git].flatMap(path => [path, resolvedPath(path)]))].sort();
|
|
1230
|
+
}
|
|
1231
|
+
/** A tool server's grants from its build's: never the agent's network or write allowlist (a server keeps its own state
|
|
1232
|
+
* where it always has), and never the build's own checkout or launch folder read back (nothing a build wrote loads in a
|
|
1233
|
+
* process that holds a tool's secrets). */
|
|
1234
|
+
export function serverGrantsOf(grants) {
|
|
1235
|
+
const { network: _agentOnly, writes: _agentWrites, keychain: _agentKeychain, ...rest } = grants;
|
|
1236
|
+
return {
|
|
1237
|
+
...rest,
|
|
1238
|
+
...(rest.pool === undefined ? {} : { pool: { ...rest.pool, own: null } }),
|
|
1239
|
+
...(rest.runFolders === undefined ? {} : { runFolders: { ...rest.runFolders, own: null } }),
|
|
1240
|
+
};
|
|
1358
1241
|
}
|