toolroll 0.9.61 → 0.9.63
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 +46 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +31 -11
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +26 -12
- package/dist/landed-cleanup.js +199 -85
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +74 -17
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +1 -0
- package/dist/project-delete-ui.js +4 -1
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +61 -0
- package/dist/remote-main.js +179 -0
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +14 -5
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +14 -0
- package/dist/storage-sweep-process.js +37 -3
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +6 -5
- package/dist/storage-sweep.js +10 -70
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +7 -4
- package/dist/store.d.ts +75 -7
- package/dist/store.js +241 -17
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +10 -6
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +4 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +64 -6
- package/dist/worktree.js +171 -35
- package/package.json +3 -1
- package/scripts/provider-canary.mjs +210 -31
package/dist/agent-fence.js
CHANGED
|
@@ -24,13 +24,29 @@
|
|
|
24
24
|
* A build's fence also holds its task boundary (`taskBoundary`): home
|
|
25
25
|
* configuration and the repositories beside its own, so a build works in
|
|
26
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.
|
|
27
42
|
*/
|
|
28
43
|
import { spawnSync } from "node:child_process";
|
|
29
|
-
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
30
|
-
import { homedir } from "node:os";
|
|
44
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
45
|
+
import { homedir, tmpdir } from "node:os";
|
|
31
46
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
32
47
|
import { readMonitoring } from "./monitoring-settings.js";
|
|
33
48
|
import { existingFolders } from "./names.js";
|
|
49
|
+
import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
|
|
34
50
|
/** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
|
|
35
51
|
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;
|
|
36
52
|
function real(path) {
|
|
@@ -45,6 +61,62 @@ function inside(child, parent) {
|
|
|
45
61
|
const rel = relative(parent, child);
|
|
46
62
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
47
63
|
}
|
|
64
|
+
export class CheckoutBindingError extends Error {
|
|
65
|
+
}
|
|
66
|
+
export const HARDENED_GIT_ARGS = ["--no-optional-locks", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"];
|
|
67
|
+
const GIT_ENV_NAMES = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "SystemRoot", "LANG", "LC_ALL", "CODEX_HOME"];
|
|
68
|
+
export function checkoutGitEnv(base = process.env) {
|
|
69
|
+
return Object.fromEntries(GIT_ENV_NAMES.flatMap(name => base[name] === undefined ? [] : [[name, base[name]]]));
|
|
70
|
+
}
|
|
71
|
+
export function trustedWorktreeBinding(repository, worktree, poolRoots = []) {
|
|
72
|
+
const main = real(repository);
|
|
73
|
+
const checkout = real(worktree);
|
|
74
|
+
const fail = () => new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
|
|
75
|
+
const registry = join(main, ".git", "worktrees");
|
|
76
|
+
try {
|
|
77
|
+
// The registry may legitimately contain the destination of an attacker link.
|
|
78
|
+
// Check the recorded name before accepting that destination as this run's checkout.
|
|
79
|
+
if (realpathSync(worktree) !== join(realpathSync(dirname(worktree)), basename(worktree)))
|
|
80
|
+
throw fail();
|
|
81
|
+
for (const root of poolRoots) {
|
|
82
|
+
if (root === null)
|
|
83
|
+
continue;
|
|
84
|
+
const components = [];
|
|
85
|
+
for (let component = resolve(worktree); component !== dirname(component); component = dirname(component)) {
|
|
86
|
+
components.push(component);
|
|
87
|
+
// Permit system aliases above the trusted pool (e.g. /var -> /private/var),
|
|
88
|
+
// but no redirect at or below it, including older checkout names.
|
|
89
|
+
if (real(component) !== real(root) && component !== resolve(root))
|
|
90
|
+
continue;
|
|
91
|
+
for (const one of components)
|
|
92
|
+
if (lstatSync(one).isSymbolicLink())
|
|
93
|
+
throw fail();
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!lstatSync(join(main, ".git")).isDirectory() || !lstatSync(registry).isDirectory())
|
|
98
|
+
throw fail();
|
|
99
|
+
// Query the trusted checkout, never the candidate's configurable Git pointer.
|
|
100
|
+
const listed = spawnSync("git", [...HARDENED_GIT_ARGS, "worktree", "list", "--porcelain", "-z"], { cwd: main, env: checkoutGitEnv(), encoding: "utf8" });
|
|
101
|
+
if (listed.status !== 0 || !listed.stdout.split("\0").includes(`worktree ${checkout}`))
|
|
102
|
+
throw fail();
|
|
103
|
+
for (const entry of readdirSync(registry)) {
|
|
104
|
+
const path = join(registry, entry);
|
|
105
|
+
if (!lstatSync(path).isDirectory() || !inside(real(path), real(registry)))
|
|
106
|
+
continue;
|
|
107
|
+
if (real(readFileSync(join(path, "gitdir"), "utf8").trim()) !== join(checkout, ".git"))
|
|
108
|
+
continue;
|
|
109
|
+
const gitdir = real(path);
|
|
110
|
+
if (worktreeGitDir(checkout) !== gitdir || real(join(gitdir, readFileSync(join(gitdir, "commondir"), "utf8").trim())) !== real(join(main, ".git")))
|
|
111
|
+
throw fail();
|
|
112
|
+
return { main, gitdir };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw fail();
|
|
117
|
+
}
|
|
118
|
+
throw fail();
|
|
119
|
+
}
|
|
48
120
|
/**
|
|
49
121
|
* Every path an agent must not reach: the Toolroll state folder
|
|
50
122
|
* beside the database (all of it, except the folder that holds this run's
|
|
@@ -71,7 +143,7 @@ export function agentFence(options) {
|
|
|
71
143
|
// The entries this run's worktree needs stay reachable: the one it lives
|
|
72
144
|
// under, and the repository its git metadata belongs to.
|
|
73
145
|
const keep = new Set();
|
|
74
|
-
for (const needed of [worktree,
|
|
146
|
+
for (const needed of [worktree, options.binding?.main ?? null]) {
|
|
75
147
|
if (needed !== null && inside(needed, state))
|
|
76
148
|
keep.add(relative(state, needed).split(sep)[0]);
|
|
77
149
|
}
|
|
@@ -101,10 +173,23 @@ export function agentFence(options) {
|
|
|
101
173
|
for (const path of options.extra ?? [])
|
|
102
174
|
if (existsSync(path))
|
|
103
175
|
fence.add(real(path));
|
|
176
|
+
// Secret, state, database, explicit-extra and home-config denies are never exemptible.
|
|
177
|
+
if (options.runFolders !== undefined)
|
|
178
|
+
for (const path of otherRunFolders(options.runFolders))
|
|
179
|
+
fence.add(path);
|
|
180
|
+
for (const path of options.boundary?.protectedShell ?? [])
|
|
181
|
+
fence.add(real(path));
|
|
182
|
+
const protectedPaths = new Set([...fence].map(real));
|
|
104
183
|
for (const path of options.boundary?.shell ?? [])
|
|
105
184
|
fence.add(path);
|
|
106
|
-
|
|
107
|
-
|
|
185
|
+
const binding = options.binding;
|
|
186
|
+
const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
|
|
187
|
+
return [...new Set([...fence].map(real))].filter(path => {
|
|
188
|
+
if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
|
|
189
|
+
return true;
|
|
190
|
+
console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
|
|
191
|
+
return false;
|
|
192
|
+
}).sort();
|
|
108
193
|
}
|
|
109
194
|
/**
|
|
110
195
|
* Credentials and agent configuration in the home folder. A build is the
|
|
@@ -129,10 +214,13 @@ const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.jso
|
|
|
129
214
|
export function taskBoundary(options) {
|
|
130
215
|
const home = real(options.home ?? homedir());
|
|
131
216
|
const worktree = real(options.worktree);
|
|
132
|
-
const main =
|
|
217
|
+
const main = options.binding?.main ?? null;
|
|
218
|
+
const gitdir = options.binding?.gitdir ?? null;
|
|
219
|
+
const needed = [worktree, main, gitdir].filter((path) => path !== null);
|
|
133
220
|
const own = new Set(PROVIDER_HOME[options.provider] ?? []);
|
|
134
221
|
const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
|
|
135
222
|
const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
|
|
223
|
+
const protectedShell = [...shell];
|
|
136
224
|
const repository = (path) => existsSync(join(path, ".git"));
|
|
137
225
|
for (const parent of new Set([dirname(worktree), ...(main === null ? [] : [dirname(main)])])) {
|
|
138
226
|
// Home contains toolchain clones such as .nvm and .oh-my-zsh. The
|
|
@@ -149,8 +237,11 @@ export function taskBoundary(options) {
|
|
|
149
237
|
for (const entry of entries) {
|
|
150
238
|
if (entry.startsWith("."))
|
|
151
239
|
continue;
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
240
|
+
const candidate = join(parent, entry);
|
|
241
|
+
if (!repository(candidate))
|
|
242
|
+
continue;
|
|
243
|
+
const path = real(candidate);
|
|
244
|
+
if (needed.some(target => target === path || inside(target, path)))
|
|
154
245
|
continue;
|
|
155
246
|
shell.add(path);
|
|
156
247
|
}
|
|
@@ -170,8 +261,12 @@ export function taskBoundary(options) {
|
|
|
170
261
|
}
|
|
171
262
|
}
|
|
172
263
|
// Git needs this worktree's metadata and the shared objects/refs, but
|
|
173
|
-
// must not prune or rewrite the metadata of another task's checkout.
|
|
174
|
-
|
|
264
|
+
// must not prune or rewrite the metadata of another task's checkout. It
|
|
265
|
+
// still reads that metadata (gc keeps what a sibling's HEAD and index
|
|
266
|
+
// reach) and looks up the checkout it names: a checkout it can't see is
|
|
267
|
+
// one `git worktree prune` deletes the entry of.
|
|
268
|
+
const readOnly = new Set();
|
|
269
|
+
const lookup = new Set();
|
|
175
270
|
if (gitdir !== null && main !== null) {
|
|
176
271
|
const siblings = dirname(gitdir);
|
|
177
272
|
let entries = [];
|
|
@@ -182,13 +277,148 @@ export function taskBoundary(options) {
|
|
|
182
277
|
entries = [];
|
|
183
278
|
}
|
|
184
279
|
for (const entry of entries) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
280
|
+
// Only a plain folder, by the name git gave it: a link a build planted here (to ~/.toolroll, say) is never
|
|
281
|
+
// followed into a grant, which a sandbox that lets the more specific path win would honour over the fence.
|
|
282
|
+
const path = join(siblings, entry);
|
|
283
|
+
if (path === gitdir)
|
|
284
|
+
continue;
|
|
285
|
+
try {
|
|
286
|
+
if (!lstatSync(path).isDirectory())
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
readOnly.add(path);
|
|
293
|
+
let pointer;
|
|
294
|
+
try {
|
|
295
|
+
pointer = readFileSync(join(path, "gitdir"), "utf8").trim();
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
pointer = undefined;
|
|
299
|
+
}
|
|
300
|
+
if (pointer === undefined || pointer === "" || !isAbsolute(pointer))
|
|
301
|
+
continue;
|
|
302
|
+
const checkout = dirname(real(pointer));
|
|
303
|
+
// The entry's pointer is build-writable: look up only a checkout whose own .git names this entry back, as git
|
|
304
|
+
// links a real one, so a planted pointer can't lift a fenced path's metadata.
|
|
305
|
+
if (worktreeGitDir(checkout) !== path)
|
|
306
|
+
continue;
|
|
307
|
+
lookup.add(checkout);
|
|
308
|
+
lookup.add(join(checkout, ".git"));
|
|
188
309
|
}
|
|
189
310
|
}
|
|
190
311
|
const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
|
|
191
|
-
return { shell: [...shell].sort(), fileTools: fileTools.sort() };
|
|
312
|
+
return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
|
|
313
|
+
}
|
|
314
|
+
/** The prefixes of every launch's private folders: its tools' config and a Codex build's own home. */
|
|
315
|
+
export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-"];
|
|
316
|
+
export function runFolders(own, root = tmpdir()) {
|
|
317
|
+
return { root: real(root), own: own === null ? null : real(own) };
|
|
318
|
+
}
|
|
319
|
+
/** Every other launch's private folder there now. */
|
|
320
|
+
function otherRunFolders(folders) {
|
|
321
|
+
let entries = [];
|
|
322
|
+
try {
|
|
323
|
+
entries = readdirSync(folders.root);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
entries = [];
|
|
327
|
+
}
|
|
328
|
+
return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
|
|
329
|
+
}
|
|
330
|
+
export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
|
|
331
|
+
/**
|
|
332
|
+
* The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
|
|
333
|
+
* the recorded checkout was made in), never from the checkout. A root that holds this checkout's own Git data (the
|
|
334
|
+
* binding's gitdir or the repository's .git) is never denied whole: it is split around that data instead, entry by
|
|
335
|
+
* entry, so it is denied as far as it can be (fail closed by denying more, never by refusing). A root inside the
|
|
336
|
+
* checkout, the home folder, a folder above it or / is left alone: the first is the build's own work, the others the
|
|
337
|
+
* whole machine.
|
|
338
|
+
*/
|
|
339
|
+
export function checkoutPool(options, list = path => readdirSync(path)) {
|
|
340
|
+
const own = options.worktree === null ? null : real(options.worktree);
|
|
341
|
+
const home = real(options.home ?? homedir());
|
|
342
|
+
const binding = options.binding ?? null;
|
|
343
|
+
const needed = binding === null ? [] : [realpathSync(binding.gitdir), realpathSync(join(binding.main, ".git"))];
|
|
344
|
+
const deny = new Set();
|
|
345
|
+
const visited = new Set();
|
|
346
|
+
const split = (input) => {
|
|
347
|
+
const path = realpathSync(input);
|
|
348
|
+
if (visited.has(path))
|
|
349
|
+
return;
|
|
350
|
+
visited.add(path);
|
|
351
|
+
if (!needed.some(target => target === path || inside(target, path))) {
|
|
352
|
+
deny.add(path);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (needed.includes(path))
|
|
356
|
+
return;
|
|
357
|
+
const entries = list(path); // An unreadable pool must refuse preparation, never silently widen access.
|
|
358
|
+
for (const entry of entries)
|
|
359
|
+
split(join(path, entry));
|
|
360
|
+
};
|
|
361
|
+
for (const root of options.roots) {
|
|
362
|
+
if (root === null || !existsSync(root))
|
|
363
|
+
continue;
|
|
364
|
+
const path = real(root);
|
|
365
|
+
if (path === dirname(path) || path === home || inside(home, path) || (own !== null && (path === own || inside(path, own))))
|
|
366
|
+
continue;
|
|
367
|
+
split(path);
|
|
368
|
+
}
|
|
369
|
+
const holding = own === null ? [] : [...deny].filter(path => inside(own, path));
|
|
370
|
+
const lookup = new Set([...visited].filter(path => needed.some(target => inside(target, path))));
|
|
371
|
+
for (const path of holding)
|
|
372
|
+
for (let folder = dirname(own); folder === path || inside(folder, path); folder = dirname(folder))
|
|
373
|
+
lookup.add(folder);
|
|
374
|
+
return { deny: [...deny].sort(), own: holding.length === 0 ? null : own, lookup: [...lookup].sort(), ...(needed.length === 0 ? {} : { gitPaths: needed }) };
|
|
375
|
+
}
|
|
376
|
+
/** The grants for one build: its task boundary's git rules, the shared dependencies its checkout links, and the
|
|
377
|
+
* checkout pools (`poolRoots`, from the plane's records) denied around its own checkout. */
|
|
378
|
+
export function fenceGrants(options) {
|
|
379
|
+
const readable = [];
|
|
380
|
+
const lookup = [...options.boundary?.lookup ?? []];
|
|
381
|
+
if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:" && options.worktree !== null && options.linked != null) {
|
|
382
|
+
// The link note names the copy as the plane wrote it; the sandbox sees real paths. The note is in the build's own
|
|
383
|
+
// checkout, so it is only a hint: the copy must be a ready one the plane made for this run's project under the
|
|
384
|
+
// setup it stamped on this checkout, or nothing is granted.
|
|
385
|
+
const written = join(dirname(options.databaseFile), DEPS_FOLDER);
|
|
386
|
+
const deps = join(dirname(real(options.databaseFile)), DEPS_FOLDER);
|
|
387
|
+
const key = linkedKey(options.worktree, written) ?? linkedKey(options.worktree, deps);
|
|
388
|
+
const linked = options.linked;
|
|
389
|
+
const copy = key === null || !/^[0-9a-f]{24}$/.test(key) ? undefined : sharedCopies(deps).find(one => one.key === key);
|
|
390
|
+
const ours = copy !== undefined && !copy.retired && copy.repo === linked.repo && linked.setupDigest !== null && readySetup(deps, key) === linked.setupDigest && readyCopy(deps, key) !== null;
|
|
391
|
+
if (ours && key !== null && existsSync(join(deps, key, "node_modules"))) {
|
|
392
|
+
lookup.push(deps, join(deps, key));
|
|
393
|
+
readable.push(join(deps, key, "node_modules"));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const pool = options.poolRoots === undefined ? undefined : checkoutPool({ roots: options.poolRoots, worktree: options.worktree, binding: options.binding ?? null, ...(options.home === undefined ? {} : { home: options.home }) });
|
|
397
|
+
return { readOnly: [...options.boundary?.readOnly ?? []], readable, lookup: [...new Set(lookup)].sort(), ...(options.runFolders === undefined ? {} : { runFolders: options.runFolders }),
|
|
398
|
+
...(pool === undefined || pool.deny.length === 0 ? {} : { pool }) };
|
|
399
|
+
}
|
|
400
|
+
/** The setup a shared copy was installed by (its ready note), or null. */
|
|
401
|
+
function readySetup(deps, key) {
|
|
402
|
+
try {
|
|
403
|
+
const setup = JSON.parse(readFileSync(join(deps, key, "ready.json"), "utf8")).setup;
|
|
404
|
+
return typeof setup === "string" ? setup : null;
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Git settings every fenced build runs with (GIT_CONFIG_COUNT, after any the
|
|
412
|
+
* caller already set): `git gc` never expires a reflog, so it never rewrites
|
|
413
|
+
* another task's reflog, which the fence would refuse mid-collection.
|
|
414
|
+
*/
|
|
415
|
+
export function fencedGitEnv(base = process.env) {
|
|
416
|
+
const count = Number.parseInt(base["GIT_CONFIG_COUNT"] ?? "0", 10);
|
|
417
|
+
const start = Number.isInteger(count) && count > 0 ? count : 0;
|
|
418
|
+
const settings = [["gc.reflogExpire", "never"], ["gc.reflogExpireUnreachable", "never"]];
|
|
419
|
+
const env = { GIT_CONFIG_COUNT: String(start + settings.length) };
|
|
420
|
+
settings.forEach(([key, value], index) => { env[`GIT_CONFIG_KEY_${start + index}`] = key; env[`GIT_CONFIG_VALUE_${start + index}`] = value; });
|
|
421
|
+
return env;
|
|
192
422
|
}
|
|
193
423
|
/** The repository a git worktree belongs to (its `.git` file names the main repository's git folder). */
|
|
194
424
|
function worktreeGitDir(worktree) {
|
|
@@ -200,24 +430,50 @@ function worktreeGitDir(worktree) {
|
|
|
200
430
|
return null;
|
|
201
431
|
}
|
|
202
432
|
}
|
|
203
|
-
function mainRepositoryOf(worktree) {
|
|
204
|
-
const gitdir = worktreeGitDir(worktree);
|
|
205
|
-
if (gitdir === null)
|
|
206
|
-
return null;
|
|
207
|
-
// <repo>/.git/worktrees/<name> → <repo>
|
|
208
|
-
const at = gitdir.lastIndexOf(`${sep}.git${sep}worktrees${sep}`);
|
|
209
|
-
return at < 0 ? null : gitdir.slice(0, at);
|
|
210
|
-
}
|
|
211
433
|
const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
212
434
|
/**
|
|
213
435
|
* Codex's own sandbox, fenced. Auto: the `:workspace` profile Codex's
|
|
214
436
|
* `--sandbox workspace-write` already gives (workspace and temp writes, no
|
|
215
437
|
* network). Full access: the same profile widened to write anywhere with
|
|
216
438
|
* network on, instead of no sandbox at all. Either way the fenced paths are
|
|
217
|
-
* denied, which in Codex outranks any write or read grant.
|
|
439
|
+
* denied, which in Codex outranks any write or read grant. The checkout pools
|
|
440
|
+
* are never `flattened` (which leaves what is made after launch readable): see
|
|
441
|
+
* `codexPool`, which denies them around the build's own checkout, written back.
|
|
218
442
|
*/
|
|
219
|
-
export function codexFenceArgv(fence, fullAccess) {
|
|
220
|
-
const
|
|
443
|
+
export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
|
|
444
|
+
const { deny, readOnly } = flattened(fence, grants);
|
|
445
|
+
// Codex's harness reads its own launch files outside the command sandbox.
|
|
446
|
+
// Deny the namespace, including folders created after this policy was built.
|
|
447
|
+
if (grants.runFolders !== undefined) {
|
|
448
|
+
for (const prefix of RUN_FOLDER_PREFIXES) {
|
|
449
|
+
deny.push(join(grants.runFolders.root, `${prefix}*`), join(grants.runFolders.root, `${prefix}*`, "**"));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Launch folders can already be in the base fence when invoke appends them.
|
|
453
|
+
// TOML forbids duplicate keys, even with identical values. Normalize aliases
|
|
454
|
+
// at this boundary and apply stronger access restrictions last.
|
|
455
|
+
const access = new Map();
|
|
456
|
+
const pool = grants.pool;
|
|
457
|
+
// Codex lets the more specific path win: no read grant inside a pool, but the build's own checkout.
|
|
458
|
+
const pooled = (path) => pool !== undefined && pool.deny.some(one => path === one || inside(path, one))
|
|
459
|
+
&& !(pool.own !== null && (path === pool.own || inside(path, pool.own)));
|
|
460
|
+
if (fullAccess)
|
|
461
|
+
access.set("/", "write");
|
|
462
|
+
for (const path of readOnly)
|
|
463
|
+
if (!pooled(real(path)))
|
|
464
|
+
access.set(real(path), "read");
|
|
465
|
+
if (pool !== undefined) {
|
|
466
|
+
const held = codexPool(pool);
|
|
467
|
+
for (const path of held.read)
|
|
468
|
+
access.set(path, "read");
|
|
469
|
+
for (const path of held.deny)
|
|
470
|
+
access.set(path, "deny");
|
|
471
|
+
for (const path of held.write)
|
|
472
|
+
access.set(path, "write");
|
|
473
|
+
}
|
|
474
|
+
for (const path of deny)
|
|
475
|
+
access.set(real(path), "deny");
|
|
476
|
+
const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
|
|
221
477
|
return [
|
|
222
478
|
"-c", 'default_permissions="standing-orders"',
|
|
223
479
|
"-c", 'permissions.standing-orders.extends=":workspace"',
|
|
@@ -225,15 +481,170 @@ export function codexFenceArgv(fence, fullAccess) {
|
|
|
225
481
|
...(fullAccess ? ["-c", "permissions.standing-orders.network.enabled=true", "-c", 'approval_policy="never"'] : []),
|
|
226
482
|
];
|
|
227
483
|
}
|
|
228
|
-
/**
|
|
229
|
-
|
|
230
|
-
|
|
484
|
+
/**
|
|
485
|
+
* Codex cannot deny an ancestor and still traverse it to the own checkout. Deny existing
|
|
486
|
+
* siblings individually, from the trusted pool roots and a fresh listing, never through
|
|
487
|
+
* flattened or name patterns. A project added after launch is covered on the next launch;
|
|
488
|
+
* Seatbelt and bubblewrap retain the stronger whole-pool rule for later-created projects.
|
|
489
|
+
*/
|
|
490
|
+
export function codexPool(pool, list = path => readdirSync(path)) {
|
|
491
|
+
const deny = new Set();
|
|
492
|
+
const read = new Set(pool.lookup);
|
|
493
|
+
const own = pool.own;
|
|
494
|
+
const needed = [...pool.gitPaths ?? [], ...(own === null ? [] : [own])].map(path => realpathSync(path));
|
|
495
|
+
const visited = new Set();
|
|
496
|
+
const split = (input) => {
|
|
497
|
+
let path;
|
|
498
|
+
try {
|
|
499
|
+
path = realpathSync(input);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
deny.add(resolve(input));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (visited.has(path))
|
|
506
|
+
return;
|
|
507
|
+
visited.add(path);
|
|
508
|
+
if (needed.some(target => path === target || inside(path, target)))
|
|
509
|
+
return;
|
|
510
|
+
if (!needed.some(target => inside(target, path))) {
|
|
511
|
+
deny.add(path);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// Only ancestors needed for traversal stay readable, never writable in Full access.
|
|
515
|
+
read.add(path);
|
|
516
|
+
for (const entry of list(path))
|
|
517
|
+
split(join(path, entry));
|
|
518
|
+
};
|
|
519
|
+
for (const root of pool.deny)
|
|
520
|
+
split(root);
|
|
521
|
+
return { read: [...read].sort(), deny: [...deny].sort(), write: own === null ? [] : [own] };
|
|
522
|
+
}
|
|
523
|
+
/** Secret-looking names denied by pattern inside a folder `flattened` makes read-only, so one written there after
|
|
524
|
+
* launch is denied too (the database and its journals, tokens, login and other text files). */
|
|
525
|
+
export const SPLIT_SECRET_PATTERNS = ["orders.db*", "*token*", "up-login*", "*.txt", "*secret*", "*password*", "*credential*", "*.pem", "*.key"];
|
|
526
|
+
/**
|
|
527
|
+
* A fence as plain denied and read-only paths, for a sandbox that can't
|
|
528
|
+
* grant inside a denied folder (Codex: a deny outranks any grant): a denied
|
|
529
|
+
* folder holding something readable becomes read-only, with each of its
|
|
530
|
+
* entries not on the way to what is readable denied instead. Fails closed: a
|
|
531
|
+
* folder that can't be listed stays denied whole and its grant is dropped.
|
|
532
|
+
* Residual: an entry made after launch in a read-only folder is denied only
|
|
533
|
+
* when its name matches SPLIT_SECRET_PATTERNS; any other new entry there is
|
|
534
|
+
* readable (never writable) until the next launch. The macOS fence has no
|
|
535
|
+
* such gap: it denies the folder and grants back only the readable path.
|
|
536
|
+
*/
|
|
537
|
+
export function flattened(fence, grants = NO_GRANTS, list = path => readdirSync(path)) {
|
|
538
|
+
const deny = [];
|
|
539
|
+
const readOnly = new Set(grants.readOnly);
|
|
540
|
+
const split = (path) => {
|
|
541
|
+
const within = grants.readable.filter(one => inside(one, path));
|
|
542
|
+
if (within.length === 0) {
|
|
543
|
+
deny.push(path);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
let entries;
|
|
547
|
+
try {
|
|
548
|
+
entries = list(path);
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
deny.push(path);
|
|
552
|
+
process.stderr.write(`toolroll: fence: couldn't list ${path} (${error instanceof Error ? error.message : String(error)}); it stays denied and the shared dependencies inside it aren't granted\n`);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
readOnly.add(path);
|
|
556
|
+
for (const pattern of SPLIT_SECRET_PATTERNS)
|
|
557
|
+
deny.push(join(path, pattern));
|
|
558
|
+
for (const entry of entries) {
|
|
559
|
+
const child = join(path, entry);
|
|
560
|
+
if (grants.readable.includes(child))
|
|
561
|
+
readOnly.add(child);
|
|
562
|
+
else
|
|
563
|
+
split(child);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
for (const path of fence)
|
|
567
|
+
split(path);
|
|
568
|
+
// Fail closed: a grant equal to or inside a denied path is dropped, so no read grant can outrank a deny (Codex lets
|
|
569
|
+
// the more specific path win). Patterns stay as Codex patterns; they can't contain a grant here.
|
|
570
|
+
const denied = deny.filter(path => !path.includes("*"));
|
|
571
|
+
return { deny, readOnly: [...readOnly].filter(path => !denied.some(one => path === one || inside(path, one))) };
|
|
572
|
+
}
|
|
573
|
+
/** Claude's own file tools refuse the fenced paths in every mode (its shell is fenced by the macOS sandbox), and
|
|
574
|
+
* never edit a read-only one. */
|
|
575
|
+
export function claudeFenceSettings(fence, readOnly = []) {
|
|
576
|
+
const rules = [...fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]), ...readOnly.flatMap(path => [`Edit(/${path})`, `Edit(/${path}/**)`])];
|
|
231
577
|
return JSON.stringify({ permissions: { deny: rules } });
|
|
232
578
|
}
|
|
233
579
|
const seatbelt = (path) => `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
580
|
+
const seatbeltRegex = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
581
|
+
/**
|
|
582
|
+
* The folders the user owns above each path (never `/`, nor the path itself), and each `roots` folder itself when the
|
|
583
|
+
* user owns it. A Seatbelt path rule follows the name, not the folder: renaming one of these would bring a fenced path
|
|
584
|
+
* back under a name no rule mentions. A folder the user doesn't own can't be renamed by a build anyway.
|
|
585
|
+
*/
|
|
586
|
+
export function ownedAncestors(paths, roots = [], uid = process.getuid?.()) {
|
|
587
|
+
const out = new Set();
|
|
588
|
+
const owned = (path) => { try {
|
|
589
|
+
return uid !== undefined && statSync(path).uid === uid;
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
return false;
|
|
593
|
+
} };
|
|
594
|
+
const climb = (from) => {
|
|
595
|
+
for (let folder = from; folder !== dirname(folder); folder = dirname(folder))
|
|
596
|
+
if (owned(folder))
|
|
597
|
+
out.add(folder);
|
|
598
|
+
};
|
|
599
|
+
for (const path of paths)
|
|
600
|
+
climb(dirname(path));
|
|
601
|
+
for (const root of roots)
|
|
602
|
+
climb(root);
|
|
603
|
+
return [...out].sort();
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* A Seatbelt profile that allows everything except reading or writing the
|
|
607
|
+
* fenced paths, less what `grants` gives back (a later Seatbelt rule
|
|
608
|
+
* outranks an earlier one). Whatever the paths, the process can't read
|
|
609
|
+
* another's arguments and environment (`kern.procargs`, which `ps -E` and
|
|
610
|
+
* KERN_PROCARGS2 use) or inspect a process outside its own sandbox:
|
|
611
|
+
* `process-info*` limited by target alone doesn't stop the sysctl. It can't
|
|
612
|
+
* take another process's task port either (`task_for_pid`, what lldb attaches
|
|
613
|
+
* with; a task name port too), so a tool server's or Toolroll's own memory
|
|
614
|
+
* and environment stay out of reach. Last, every folder of the user's above
|
|
615
|
+
* a fenced or read-only path and above the temp folder (and the temp folder
|
|
616
|
+
* itself) can't be renamed or removed, though what is inside them stays
|
|
617
|
+
* writable as before. The checkout pools are denied first, and the build's own
|
|
618
|
+
* checkout given back right after, so every later deny still holds inside it.
|
|
619
|
+
*/
|
|
620
|
+
export function macosFenceProfile(fence, grants = NO_GRANTS) {
|
|
621
|
+
const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
|
|
622
|
+
const folders = grants.runFolders;
|
|
623
|
+
const pool = grants.pool;
|
|
624
|
+
const above = ownedAncestors([...fence, ...grants.readOnly, ...pool?.deny ?? []], [folders?.root ?? real(tmpdir())]);
|
|
625
|
+
return [
|
|
626
|
+
"(version 1)",
|
|
627
|
+
"(allow default)",
|
|
628
|
+
...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
|
|
629
|
+
...(pool?.own == null ? [] : [
|
|
630
|
+
`(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
|
|
631
|
+
`(deny file-write* ${paths("literal", [pool.own])})`,
|
|
632
|
+
]),
|
|
633
|
+
...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
|
|
634
|
+
...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
|
|
635
|
+
...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
|
|
636
|
+
...(folders === undefined ? [] : [`(deny file-read* file-write* (regex #"^${seatbeltRegex(folders.root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`]),
|
|
637
|
+
...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
|
|
638
|
+
// Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
|
|
639
|
+
...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
|
|
640
|
+
...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`]),
|
|
641
|
+
'(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
|
|
642
|
+
"(deny process-info-pidinfo)",
|
|
643
|
+
"(allow process-info-pidinfo (target same-sandbox))",
|
|
644
|
+
"(deny mach-priv-task-port)",
|
|
645
|
+
"(deny mach-task-name)",
|
|
646
|
+
...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
|
|
647
|
+
].join("\n");
|
|
237
648
|
}
|
|
238
649
|
export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
239
650
|
/** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
|
|
@@ -267,8 +678,8 @@ export function macosFenceAvailable(platform = process.platform, probe) {
|
|
|
267
678
|
return seatbeltWorks;
|
|
268
679
|
}
|
|
269
680
|
/** The spawn that runs `file args` inside the macOS fence (same process: sandbox-exec replaces itself with the agent). */
|
|
270
|
-
export function macosFenced(file, args, fence) {
|
|
271
|
-
return { file: SANDBOX_EXEC, args: ["-p", macosFenceProfile(fence), file, ...args] };
|
|
681
|
+
export function macosFenced(file, args, fence, grants = NO_GRANTS) {
|
|
682
|
+
return { file: SANDBOX_EXEC, args: ["-p", macosFenceProfile(fence, grants), file, ...args] };
|
|
272
683
|
}
|
|
273
684
|
/** Whether bubblewrap runs here (checked once): installed, and allowed to make its namespace. */
|
|
274
685
|
let bubblewrapWorks = null;
|
|
@@ -285,9 +696,30 @@ export function linuxFenceAvailable(platform = process.platform, probe = () => s
|
|
|
285
696
|
}
|
|
286
697
|
return bubblewrapWorks;
|
|
287
698
|
}
|
|
288
|
-
/**
|
|
289
|
-
|
|
290
|
-
|
|
699
|
+
/** A secret-name pattern (SPLIT_SECRET_PATTERNS) as a test of one path segment. */
|
|
700
|
+
const patternTest = (pattern) => new RegExp(`^${pattern.split("*").map(seatbeltRegex).join(".*")}$`);
|
|
701
|
+
const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
|
|
702
|
+
/**
|
|
703
|
+
* The grants bubblewrap may bind back over its masks: each existing read-only or readable path, never one that is a
|
|
704
|
+
* fenced path itself, a read-only one inside a fenced path (only the shared install this run links is read inside
|
|
705
|
+
* one), another launch's private folder, or one with a secret-looking name below its fenced folder. Bubblewrap can't
|
|
706
|
+
* mask by pattern, so a grant a pattern would deny is dropped instead (fail closed).
|
|
707
|
+
*/
|
|
708
|
+
function linuxGrants(fence, grants) {
|
|
709
|
+
const folders = grants.runFolders;
|
|
710
|
+
const launchFolder = (path) => folders !== undefined && inside(path, folders.root) && path !== folders.own && !(folders.own != null && inside(path, folders.own))
|
|
711
|
+
&& RUN_FOLDER_PREFIXES.some(prefix => relative(folders.root, path).split(sep)[0].startsWith(prefix));
|
|
712
|
+
const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
|
|
713
|
+
const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
|
|
714
|
+
&& !(readOnlyGrant && fence.some(one => inside(path, one)));
|
|
715
|
+
return [...new Set([...grants.readOnly.filter(path => ok(path, true)), ...grants.readable.filter(path => ok(path, false))])];
|
|
716
|
+
}
|
|
717
|
+
/** The spawn that runs `file args` inside the Linux fence: the whole filesystem as it is, the checkout pools masked
|
|
718
|
+
* and the build's own checkout bound back read-write over them, then every fenced path masked whole (an empty
|
|
719
|
+
* folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
|
|
720
|
+
* bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
|
|
721
|
+
export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
|
|
722
|
+
const mask = (path) => {
|
|
291
723
|
let folder = true;
|
|
292
724
|
try {
|
|
293
725
|
folder = statSync(path).isDirectory();
|
|
@@ -296,6 +728,9 @@ export function linuxFenced(file, args, fence) {
|
|
|
296
728
|
folder = true;
|
|
297
729
|
}
|
|
298
730
|
return folder ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path];
|
|
299
|
-
}
|
|
300
|
-
|
|
731
|
+
};
|
|
732
|
+
const pool = grants.pool?.deny ?? [];
|
|
733
|
+
const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
|
|
734
|
+
const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
|
|
735
|
+
return { file: "bwrap", args: ["--dev-bind", "/", "/", ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, "--die-with-parent", "--", file, ...args] };
|
|
301
736
|
}
|