toolroll 0.9.62 → 0.9.65
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 +80 -0
- package/README.md +66 -8
- package/dist/acp-agents.d.ts +43 -0
- package/dist/acp-agents.js +62 -0
- package/dist/acp-attest.d.ts +44 -0
- package/dist/acp-attest.js +269 -0
- package/dist/acp-check.d.ts +28 -0
- package/dist/acp-check.js +410 -0
- package/dist/acp-client.d.ts +163 -0
- package/dist/acp-client.js +738 -0
- package/dist/acp-inspect.d.ts +23 -0
- package/dist/acp-inspect.js +109 -0
- package/dist/agent-board.d.ts +19 -6
- package/dist/agent-board.js +30 -9
- package/dist/agent-fence.d.ts +203 -10
- package/dist/agent-fence.js +623 -41
- package/dist/agent-pulse.js +9 -2
- package/dist/agent-state-words.d.ts +19 -0
- package/dist/agent-state-words.js +20 -0
- package/dist/agent-state.d.ts +65 -0
- package/dist/agent-state.js +229 -0
- package/dist/agentconfig.js +36 -13
- package/dist/agents-board.d.ts +34 -2
- package/dist/agents-board.js +110 -1
- package/dist/approval-rules-ui.js +2 -2
- package/dist/assignment-delivery.d.ts +14 -0
- package/dist/assignment-delivery.js +13 -0
- package/dist/assignment.js +13 -0
- package/dist/attest.js +7 -0
- package/dist/backup-ui.js +2 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +117 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +42 -38
- package/dist/browser-shell.js +6 -3
- package/dist/browser-workspace.d.ts +263 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +6 -1
- package/dist/builder.js +34 -13
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +199 -17
- package/dist/chat-ask.d.ts +4 -0
- package/dist/chat-ask.js +14 -0
- package/dist/chat-channel.js +20 -0
- package/dist/chat-continuity.js +5 -4
- package/dist/chat-decide.d.ts +0 -14
- package/dist/chat-decide.js +4 -1
- package/dist/chat-delivery.d.ts +9 -0
- package/dist/chat-delivery.js +45 -6
- package/dist/chat-messages.d.ts +8 -0
- package/dist/chat-messages.js +36 -4
- package/dist/check-levels-ui.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +20 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +5 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +24 -2
- package/dist/coding-ui.js +6 -4
- package/dist/console-instance.d.ts +124 -0
- package/dist/console-instance.js +337 -0
- package/dist/contracts/chat-actions.d.ts +16 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/chat-content.d.ts +19 -0
- package/dist/contracts/chat-content.js +6 -0
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +30 -4
- package/dist/contracts/console-api.js +13 -4
- package/dist/contracts/flow-card.d.ts +4 -0
- package/dist/contracts/flow-card.js +3 -0
- package/dist/contracts/flow.d.ts +46 -0
- package/dist/contracts/flow.js +11 -2
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/contracts/lead-tools.d.ts +92 -0
- package/dist/contracts/lead-tools.js +13 -0
- package/dist/contracts/provider.d.ts +11 -1
- package/dist/contracts/provider.js +1 -1
- package/dist/contracts/route.d.ts +76 -1
- package/dist/contracts/route.js +1 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/control-setup.d.ts +1 -1
- package/dist/control-ui.js +2 -2
- package/dist/converse.d.ts +16 -0
- package/dist/converse.js +32 -4
- package/dist/demo-chat.js +4 -4
- package/dist/demo.js +2 -2
- package/dist/desktop-update-gate.js +6 -0
- package/dist/discord-chat.js +4 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/egress-bridge.d.ts +17 -0
- package/dist/egress-bridge.js +111 -0
- package/dist/egress.d.ts +119 -0
- package/dist/egress.js +423 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +81 -0
- package/dist/exec.js +368 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +35 -11
- package/dist/flow-gallery-ui.js +2 -2
- package/dist/flow-gallery.js +27 -8
- package/dist/flow-share.js +16 -5
- package/dist/flow-triggers.d.ts +84 -11
- package/dist/flow-triggers.js +554 -87
- 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 +63 -12
- package/dist/flows-ui.d.ts +2 -3
- package/dist/flows-ui.js +56 -5
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- 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.d.ts +17 -0
- package/dist/invoke.js +510 -182
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- package/dist/landed-cleanup.d.ts +3 -2
- package/dist/landed-cleanup.js +12 -5
- package/dist/lead-capabilities.js +3 -2
- package/dist/lead-contract.js +2 -0
- package/dist/lead-doors.d.ts +2 -0
- package/dist/lead-doors.js +7 -0
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +58 -2
- package/dist/lead-progress.js +1 -0
- package/dist/lead-queue-runtime.d.ts +46 -0
- package/dist/lead-queue-runtime.js +191 -0
- package/dist/lead-queue.d.ts +104 -0
- package/dist/lead-queue.js +187 -0
- package/dist/lead-sweep.d.ts +67 -0
- package/dist/lead-sweep.js +99 -0
- package/dist/lead-tools.d.ts +22 -0
- package/dist/lead-tools.js +163 -4
- package/dist/lead.d.ts +13 -5
- package/dist/lead.js +89 -29
- package/dist/limits-ui.d.ts +8 -1
- package/dist/limits-ui.js +26 -0
- package/dist/live-bus.d.ts +8 -0
- package/dist/live-bus.js +40 -4
- package/dist/live-detail.d.ts +27 -0
- package/dist/live-detail.js +57 -0
- package/dist/live-notes.d.ts +20 -0
- package/dist/live-notes.js +47 -0
- package/dist/live-records.d.ts +140 -0
- package/dist/live-records.js +935 -0
- package/dist/live-terminal.d.ts +258 -0
- package/dist/live-terminal.js +649 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/mcp-oauth.d.ts +6 -3
- package/dist/mcp-oauth.js +8 -4
- package/dist/memory-pass.js +2 -2
- package/dist/mobile-viewport.d.ts +5 -1
- package/dist/mobile-viewport.js +18 -2
- package/dist/model-catalog.js +4 -0
- package/dist/monitoring-ui.js +2 -2
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +11 -4
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +282 -18
- package/dist/passwordless.d.ts +23 -0
- package/dist/passwordless.js +26 -0
- package/dist/peek-cli.d.ts +12 -4
- package/dist/peek-cli.js +19 -11
- package/dist/phase-routing.js +10 -3
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/planner-source.d.ts +3 -0
- package/dist/planner-source.js +14 -0
- package/dist/planner.d.ts +2 -1
- package/dist/planner.js +7 -1
- package/dist/policy-ui.js +2 -2
- package/dist/policy.d.ts +1 -20
- package/dist/policy.js +29 -2
- 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-delete-ui.d.ts +12 -0
- package/dist/project-delete-ui.js +41 -9
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +12 -6
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +103 -27
- package/dist/project-tools.js +399 -100
- package/dist/provider-auth.d.ts +4 -3
- package/dist/provider-auth.js +5 -4
- package/dist/provider-connection.d.ts +2 -1
- package/dist/provider-connection.js +5 -0
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +42 -5
- package/dist/provider.js +97 -19
- package/dist/pull-request-ui.js +2 -2
- package/dist/push.js +1 -1
- package/dist/quick-start.d.ts +24 -0
- package/dist/quick-start.js +127 -0
- package/dist/release.d.ts +3 -0
- package/dist/release.js +14 -8
- package/dist/remote-main.d.ts +4 -0
- package/dist/remote-main.js +3 -3
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-review.d.ts +1 -1
- package/dist/result-review.js +9 -3
- package/dist/retention-ui.js +2 -2
- package/dist/retention.js +10 -1
- package/dist/run-efficiency.d.ts +73 -0
- package/dist/run-efficiency.js +228 -0
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +22 -1
- package/dist/scope.d.ts +171 -6
- package/dist/scope.js +251 -31
- package/dist/scout-net.d.ts +4 -1
- package/dist/scout-net.js +37 -12
- package/dist/scout.d.ts +20 -6
- package/dist/scout.js +27 -3
- package/dist/serve.js +258 -43
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +6 -1
- package/dist/server/chrome.js +18 -7
- package/dist/server/console.css +52 -38
- package/dist/server/flows.js +65 -13
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +23 -9
- package/dist/server/http.d.ts +7 -0
- package/dist/server/http.js +3 -2
- package/dist/server/live.d.ts +34 -1
- package/dist/server/live.js +39 -17
- package/dist/server/pages-assets.js +5 -0
- package/dist/server/pages.js +90 -13
- package/dist/server/people-tokens.d.ts +3 -0
- package/dist/server/people-tokens.js +55 -17
- package/dist/server/remote-hooks.js +12 -6
- package/dist/server/remote.d.ts +2 -1
- package/dist/server/remote.js +14 -5
- package/dist/server/render-chat.d.ts +12 -3
- package/dist/server/render-chat.js +47 -16
- package/dist/server/render-pages.d.ts +4 -1
- package/dist/server/render-pages.js +5 -4
- package/dist/server/render-people.d.ts +3 -2
- package/dist/server/render-people.js +3 -2
- package/dist/server/render-results.d.ts +18 -2
- package/dist/server/render-results.js +37 -28
- package/dist/server/render-settings.js +6 -5
- package/dist/server/render-tasks.d.ts +35 -10
- package/dist/server/render-tasks.js +110 -27
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/request-context.js +2 -1
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +49 -5
- package/dist/server/runtime.d.ts +34 -3
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +132 -13
- package/dist/server/sweeps.d.ts +4 -0
- package/dist/server/sweeps.js +58 -0
- package/dist/server/task-views.d.ts +2 -1
- package/dist/server/task-views.js +96 -6
- package/dist/server/tasks.js +320 -74
- package/dist/server/terminals.d.ts +31 -0
- package/dist/server/terminals.js +365 -0
- package/dist/setup-guide.js +3 -2
- package/dist/slack-chat.js +4 -1
- package/dist/spend-ui.js +3 -3
- package/dist/spend.js +2 -1
- package/dist/steer-capabilities.d.ts +32 -0
- package/dist/steer-capabilities.js +60 -0
- package/dist/steer-controls.d.ts +14 -0
- package/dist/steer-controls.js +36 -0
- package/dist/steer-status.js +2 -2
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +12 -5
- package/dist/storage-sweep-process.js +23 -6
- package/dist/storage-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +466 -19
- package/dist/store.js +1185 -63
- package/dist/subagent-desk.js +1 -1
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/summary.js +3 -0
- package/dist/surface.js +6 -4
- package/dist/task-source.d.ts +58 -0
- package/dist/task-source.js +93 -0
- package/dist/team-leads.js +1 -1
- package/dist/teams-chat.js +4 -1
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +44 -7
- package/dist/telegram.d.ts +11 -2
- package/dist/telegram.js +51 -17
- package/dist/terminal-catalog.d.ts +11 -0
- package/dist/terminal-catalog.js +129 -0
- package/dist/text-limits.d.ts +2 -0
- package/dist/text-limits.js +2 -0
- package/dist/tool-broker.d.ts +39 -0
- package/dist/tool-broker.js +370 -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-ui.js +2 -2
- package/dist/toolroll-update.js +14 -3
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +19 -10
- package/dist/webhook-limits.d.ts +23 -0
- package/dist/webhook-limits.js +30 -0
- package/dist/webhook-readers.d.ts +56 -0
- package/dist/webhook-readers.js +138 -0
- package/dist/webhook-signing.d.ts +27 -0
- package/dist/webhook-signing.js +119 -0
- package/dist/work-index.d.ts +29 -0
- package/dist/work-index.js +21 -5
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +10 -4
- package/dist/worktree.js +70 -29
- package/package.json +13 -2
- package/scripts/provider-canary.mjs +210 -31
package/dist/agent-fence.js
CHANGED
|
@@ -24,13 +24,33 @@
|
|
|
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
|
*/
|
|
43
|
+
import { INSTANCE_DIR } from "./console-instance.js";
|
|
44
|
+
import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
|
|
28
45
|
import { spawnSync } from "node:child_process";
|
|
29
|
-
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
30
|
-
import { homedir } from "node:os";
|
|
46
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
47
|
+
import { homedir, tmpdir } from "node:os";
|
|
31
48
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
32
49
|
import { readMonitoring } from "./monitoring-settings.js";
|
|
33
50
|
import { existingFolders } from "./names.js";
|
|
51
|
+
import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
|
|
52
|
+
/** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
|
|
53
|
+
export const BRIDGE_DIR = "bridge-secrets";
|
|
34
54
|
/** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
|
|
35
55
|
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
56
|
function real(path) {
|
|
@@ -45,6 +65,62 @@ function inside(child, parent) {
|
|
|
45
65
|
const rel = relative(parent, child);
|
|
46
66
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
47
67
|
}
|
|
68
|
+
export class CheckoutBindingError extends Error {
|
|
69
|
+
}
|
|
70
|
+
export const HARDENED_GIT_ARGS = ["--no-optional-locks", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"];
|
|
71
|
+
const GIT_ENV_NAMES = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "SystemRoot", "LANG", "LC_ALL", "CODEX_HOME"];
|
|
72
|
+
export function checkoutGitEnv(base = process.env) {
|
|
73
|
+
return Object.fromEntries(GIT_ENV_NAMES.flatMap(name => base[name] === undefined ? [] : [[name, base[name]]]));
|
|
74
|
+
}
|
|
75
|
+
export function trustedWorktreeBinding(repository, worktree, poolRoots = []) {
|
|
76
|
+
const main = real(repository);
|
|
77
|
+
const checkout = real(worktree);
|
|
78
|
+
const fail = () => new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
|
|
79
|
+
const registry = join(main, ".git", "worktrees");
|
|
80
|
+
try {
|
|
81
|
+
// The registry may legitimately contain the destination of an attacker link.
|
|
82
|
+
// Check the recorded name before accepting that destination as this run's checkout.
|
|
83
|
+
if (realpathSync(worktree) !== join(realpathSync(dirname(worktree)), basename(worktree)))
|
|
84
|
+
throw fail();
|
|
85
|
+
for (const root of poolRoots) {
|
|
86
|
+
if (root === null)
|
|
87
|
+
continue;
|
|
88
|
+
const components = [];
|
|
89
|
+
for (let component = resolve(worktree); component !== dirname(component); component = dirname(component)) {
|
|
90
|
+
components.push(component);
|
|
91
|
+
// Permit system aliases above the trusted pool (e.g. /var -> /private/var),
|
|
92
|
+
// but no redirect at or below it, including older checkout names.
|
|
93
|
+
if (real(component) !== real(root) && component !== resolve(root))
|
|
94
|
+
continue;
|
|
95
|
+
for (const one of components)
|
|
96
|
+
if (lstatSync(one).isSymbolicLink())
|
|
97
|
+
throw fail();
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!lstatSync(join(main, ".git")).isDirectory() || !lstatSync(registry).isDirectory())
|
|
102
|
+
throw fail();
|
|
103
|
+
// Query the trusted checkout, never the candidate's configurable Git pointer.
|
|
104
|
+
const listed = spawnSync("git", [...HARDENED_GIT_ARGS, "worktree", "list", "--porcelain", "-z"], { cwd: main, env: checkoutGitEnv(), encoding: "utf8" });
|
|
105
|
+
if (listed.status !== 0 || !listed.stdout.split("\0").includes(`worktree ${checkout}`))
|
|
106
|
+
throw fail();
|
|
107
|
+
for (const entry of readdirSync(registry)) {
|
|
108
|
+
const path = join(registry, entry);
|
|
109
|
+
if (!lstatSync(path).isDirectory() || !inside(real(path), real(registry)))
|
|
110
|
+
continue;
|
|
111
|
+
if (real(readFileSync(join(path, "gitdir"), "utf8").trim()) !== join(checkout, ".git"))
|
|
112
|
+
continue;
|
|
113
|
+
const gitdir = real(path);
|
|
114
|
+
if (worktreeGitDir(checkout) !== gitdir || real(join(gitdir, readFileSync(join(gitdir, "commondir"), "utf8").trim())) !== real(join(main, ".git")))
|
|
115
|
+
throw fail();
|
|
116
|
+
return { main, gitdir };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw fail();
|
|
121
|
+
}
|
|
122
|
+
throw fail();
|
|
123
|
+
}
|
|
48
124
|
/**
|
|
49
125
|
* Every path an agent must not reach: the Toolroll state folder
|
|
50
126
|
* beside the database (all of it, except the folder that holds this run's
|
|
@@ -55,6 +131,10 @@ function inside(child, parent) {
|
|
|
55
131
|
*/
|
|
56
132
|
export function agentFence(options) {
|
|
57
133
|
const fence = new Set();
|
|
134
|
+
/** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
|
|
135
|
+
let runFolder = null;
|
|
136
|
+
/** The bridges' folder, likewise. */
|
|
137
|
+
let bridgeFolder = null;
|
|
58
138
|
const home = real(options.home ?? homedir());
|
|
59
139
|
const worktree = options.worktree === null ? null : real(options.worktree);
|
|
60
140
|
if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
|
|
@@ -63,6 +143,45 @@ export function agentFence(options) {
|
|
|
63
143
|
for (const suffix of ["", "-wal", "-shm", "-journal"])
|
|
64
144
|
if (existsSync(database + suffix))
|
|
65
145
|
fence.add(database + suffix);
|
|
146
|
+
// The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
|
|
147
|
+
// build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
|
|
148
|
+
// 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
|
|
149
|
+
// real folder to cover (a Linux bind needs one).
|
|
150
|
+
// Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
|
|
151
|
+
// points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
|
|
152
|
+
// If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
|
|
153
|
+
// rather than run unfenced. The name is Toolroll's own; nothing else belongs there.
|
|
154
|
+
// The folder Linux builds' network bridges are made in (egress-bridge.ts): made before any build starts and fenced
|
|
155
|
+
// from every build, so no build — however long it runs — can reach a bridge made for a later one.
|
|
156
|
+
const bridges = join(state, BRIDGE_DIR);
|
|
157
|
+
try {
|
|
158
|
+
lstatSync(bridges);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
try {
|
|
162
|
+
mkdirSync(bridges, { mode: 0o700 });
|
|
163
|
+
}
|
|
164
|
+
catch { /* fenced below either way */ }
|
|
165
|
+
}
|
|
166
|
+
// Fenced by its own name as well as where it leads: a link planted there is itself out of reach (and the bridge
|
|
167
|
+
// refuses a root that isn't a private folder: egress-bridge.ts).
|
|
168
|
+
bridgeFolder = bridges;
|
|
169
|
+
fence.add(bridges);
|
|
170
|
+
runFolder = join(state, INSTANCE_DIR);
|
|
171
|
+
const present = () => { try {
|
|
172
|
+
lstatSync(runFolder);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
} };
|
|
178
|
+
if (!present()) {
|
|
179
|
+
try {
|
|
180
|
+
mkdirSync(runFolder, { mode: 0o700 });
|
|
181
|
+
}
|
|
182
|
+
catch { /* fenced below either way */ }
|
|
183
|
+
}
|
|
184
|
+
fence.add(runFolder);
|
|
66
185
|
// Only a folder that is Toolroll's own (~/.config/toolroll or an older name,
|
|
67
186
|
// the desktop app's "Toolroll") is fenced whole: a database kept
|
|
68
187
|
// in a shared folder (a projects folder, a test's temp folder) must not
|
|
@@ -71,7 +190,7 @@ export function agentFence(options) {
|
|
|
71
190
|
// The entries this run's worktree needs stay reachable: the one it lives
|
|
72
191
|
// under, and the repository its git metadata belongs to.
|
|
73
192
|
const keep = new Set();
|
|
74
|
-
for (const needed of [worktree,
|
|
193
|
+
for (const needed of [worktree, options.binding?.main ?? null]) {
|
|
75
194
|
if (needed !== null && inside(needed, state))
|
|
76
195
|
keep.add(relative(state, needed).split(sep)[0]);
|
|
77
196
|
}
|
|
@@ -101,10 +220,23 @@ export function agentFence(options) {
|
|
|
101
220
|
for (const path of options.extra ?? [])
|
|
102
221
|
if (existsSync(path))
|
|
103
222
|
fence.add(real(path));
|
|
223
|
+
// Secret, state, database, explicit-extra and home-config denies are never exemptible.
|
|
224
|
+
if (options.runFolders !== undefined)
|
|
225
|
+
for (const path of otherRunFolders(options.runFolders))
|
|
226
|
+
fence.add(path);
|
|
227
|
+
for (const path of options.boundary?.protectedShell ?? [])
|
|
228
|
+
fence.add(real(path));
|
|
229
|
+
const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]));
|
|
104
230
|
for (const path of options.boundary?.shell ?? [])
|
|
105
231
|
fence.add(path);
|
|
106
|
-
|
|
107
|
-
|
|
232
|
+
const binding = options.binding;
|
|
233
|
+
const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
|
|
234
|
+
return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]))].filter(path => {
|
|
235
|
+
if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
|
|
236
|
+
return true;
|
|
237
|
+
console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
|
|
238
|
+
return false;
|
|
239
|
+
}).sort();
|
|
108
240
|
}
|
|
109
241
|
/**
|
|
110
242
|
* Credentials and agent configuration in the home folder. A build is the
|
|
@@ -112,10 +244,18 @@ export function agentFence(options) {
|
|
|
112
244
|
* cloud logins or another agent's sign-in, or rewrite their own Claude or
|
|
113
245
|
* Codex setup for the next interactive session.
|
|
114
246
|
*/
|
|
115
|
-
const HOME_CONFIGURATION = [
|
|
247
|
+
const HOME_CONFIGURATION = [
|
|
248
|
+
".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json"),
|
|
249
|
+
// Every ACP agent's own configuration and sign-in (acp-agents.ts): fenced from every build but that agent's own.
|
|
250
|
+
...ACP_PROVIDER_IDS.flatMap(id => ACP_AGENTS[id].home),
|
|
251
|
+
];
|
|
116
252
|
/** What a provider's own CLI reads to sign in and keep its sessions. The OS fence around Claude and Gemini holds
|
|
117
253
|
* their whole process, so those stay reachable there; Codex fences only its agent's commands, so it keeps none. */
|
|
118
|
-
const PROVIDER_HOME = {
|
|
254
|
+
const PROVIDER_HOME = {
|
|
255
|
+
claude: [".claude", ".claude.json"], gemini: [".gemini"], codex: [], openrouter: [],
|
|
256
|
+
// Each ACP agent keeps its own configuration and sign-in where its row says (acp-agents.ts).
|
|
257
|
+
...Object.fromEntries(ACP_PROVIDER_IDS.map(id => [id, ACP_AGENTS[id].home])),
|
|
258
|
+
};
|
|
119
259
|
/** What Claude's own file tools refuse inside its home: the CLI reads these itself, its model never needs to. */
|
|
120
260
|
const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.json"), join(".claude", "settings.json"), join(".claude", "settings.local.json"), ".npmrc"];
|
|
121
261
|
/**
|
|
@@ -129,10 +269,13 @@ const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.jso
|
|
|
129
269
|
export function taskBoundary(options) {
|
|
130
270
|
const home = real(options.home ?? homedir());
|
|
131
271
|
const worktree = real(options.worktree);
|
|
132
|
-
const main =
|
|
272
|
+
const main = options.binding?.main ?? null;
|
|
273
|
+
const gitdir = options.binding?.gitdir ?? null;
|
|
274
|
+
const needed = [worktree, main, gitdir].filter((path) => path !== null);
|
|
133
275
|
const own = new Set(PROVIDER_HOME[options.provider] ?? []);
|
|
134
276
|
const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
|
|
135
277
|
const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
|
|
278
|
+
const protectedShell = [...shell];
|
|
136
279
|
const repository = (path) => existsSync(join(path, ".git"));
|
|
137
280
|
for (const parent of new Set([dirname(worktree), ...(main === null ? [] : [dirname(main)])])) {
|
|
138
281
|
// Home contains toolchain clones such as .nvm and .oh-my-zsh. The
|
|
@@ -149,8 +292,11 @@ export function taskBoundary(options) {
|
|
|
149
292
|
for (const entry of entries) {
|
|
150
293
|
if (entry.startsWith("."))
|
|
151
294
|
continue;
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
295
|
+
const candidate = join(parent, entry);
|
|
296
|
+
if (!repository(candidate))
|
|
297
|
+
continue;
|
|
298
|
+
const path = real(candidate);
|
|
299
|
+
if (needed.some(target => target === path || inside(target, path)))
|
|
154
300
|
continue;
|
|
155
301
|
shell.add(path);
|
|
156
302
|
}
|
|
@@ -170,8 +316,12 @@ export function taskBoundary(options) {
|
|
|
170
316
|
}
|
|
171
317
|
}
|
|
172
318
|
// 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
|
-
|
|
319
|
+
// must not prune or rewrite the metadata of another task's checkout. It
|
|
320
|
+
// still reads that metadata (gc keeps what a sibling's HEAD and index
|
|
321
|
+
// reach) and looks up the checkout it names: a checkout it can't see is
|
|
322
|
+
// one `git worktree prune` deletes the entry of.
|
|
323
|
+
const readOnly = new Set();
|
|
324
|
+
const lookup = new Set();
|
|
175
325
|
if (gitdir !== null && main !== null) {
|
|
176
326
|
const siblings = dirname(gitdir);
|
|
177
327
|
let entries = [];
|
|
@@ -182,13 +332,160 @@ export function taskBoundary(options) {
|
|
|
182
332
|
entries = [];
|
|
183
333
|
}
|
|
184
334
|
for (const entry of entries) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
335
|
+
// Only a plain folder, by the name git gave it: a link a build planted here (to ~/.toolroll, say) is never
|
|
336
|
+
// followed into a grant, which a sandbox that lets the more specific path win would honour over the fence.
|
|
337
|
+
const path = join(siblings, entry);
|
|
338
|
+
if (path === gitdir)
|
|
339
|
+
continue;
|
|
340
|
+
try {
|
|
341
|
+
if (!lstatSync(path).isDirectory())
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
readOnly.add(path);
|
|
348
|
+
let pointer;
|
|
349
|
+
try {
|
|
350
|
+
pointer = readFileSync(join(path, "gitdir"), "utf8").trim();
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
pointer = undefined;
|
|
354
|
+
}
|
|
355
|
+
if (pointer === undefined || pointer === "" || !isAbsolute(pointer))
|
|
356
|
+
continue;
|
|
357
|
+
const checkout = dirname(real(pointer));
|
|
358
|
+
// The entry's pointer is build-writable: look up only a checkout whose own .git names this entry back, as git
|
|
359
|
+
// links a real one, so a planted pointer can't lift a fenced path's metadata.
|
|
360
|
+
if (worktreeGitDir(checkout) !== path)
|
|
361
|
+
continue;
|
|
362
|
+
lookup.add(checkout);
|
|
363
|
+
lookup.add(join(checkout, ".git"));
|
|
188
364
|
}
|
|
189
365
|
}
|
|
190
366
|
const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
|
|
191
|
-
return { shell: [...shell].sort(), fileTools: fileTools.sort() };
|
|
367
|
+
return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
|
|
368
|
+
}
|
|
369
|
+
/** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
|
|
370
|
+
* network bridge (egress-bridge.ts). */
|
|
371
|
+
export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-"];
|
|
372
|
+
export function runFolders(own, root = tmpdir()) {
|
|
373
|
+
return { root: real(root), own: own === null ? null : real(own) };
|
|
374
|
+
}
|
|
375
|
+
/** Every other launch's private folder there now. */
|
|
376
|
+
function otherRunFolders(folders) {
|
|
377
|
+
let entries = [];
|
|
378
|
+
try {
|
|
379
|
+
entries = readdirSync(folders.root);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
entries = [];
|
|
383
|
+
}
|
|
384
|
+
return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
|
|
388
|
+
* Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
|
|
389
|
+
* (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
|
|
390
|
+
* confined; the run's proxy resolves names for it.
|
|
391
|
+
*/
|
|
392
|
+
export const DELEGATION_SERVICES = [
|
|
393
|
+
"com.apple.coreservices.launchservicesd", "com.apple.coreservices.quarantine-resolver", "com.apple.lsd.mapdb", "com.apple.lsd.modifydb", "com.apple.lsd.open",
|
|
394
|
+
"com.apple.coreservices.appleevents", "com.apple.nsurlsessiond", "com.apple.nsurlsessiond.NSURLSessionProxyService",
|
|
395
|
+
"com.apple.dnssd.service", "com.apple.mDNSResponder", "com.apple.pasteboard.1",
|
|
396
|
+
];
|
|
397
|
+
export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
|
|
398
|
+
/**
|
|
399
|
+
* The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
|
|
400
|
+
* the recorded checkout was made in), never from the checkout. A root that holds this checkout's own Git data (the
|
|
401
|
+
* binding's gitdir or the repository's .git) is never denied whole: it is split around that data instead, entry by
|
|
402
|
+
* entry, so it is denied as far as it can be (fail closed by denying more, never by refusing). A root inside the
|
|
403
|
+
* checkout, the home folder, a folder above it or / is left alone: the first is the build's own work, the others the
|
|
404
|
+
* whole machine.
|
|
405
|
+
*/
|
|
406
|
+
export function checkoutPool(options, list = path => readdirSync(path)) {
|
|
407
|
+
const own = options.worktree === null ? null : real(options.worktree);
|
|
408
|
+
const home = real(options.home ?? homedir());
|
|
409
|
+
const binding = options.binding ?? null;
|
|
410
|
+
const needed = binding === null ? [] : [realpathSync(binding.gitdir), realpathSync(join(binding.main, ".git"))];
|
|
411
|
+
const deny = new Set();
|
|
412
|
+
const visited = new Set();
|
|
413
|
+
const split = (input) => {
|
|
414
|
+
const path = realpathSync(input);
|
|
415
|
+
if (visited.has(path))
|
|
416
|
+
return;
|
|
417
|
+
visited.add(path);
|
|
418
|
+
if (!needed.some(target => target === path || inside(target, path))) {
|
|
419
|
+
deny.add(path);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (needed.includes(path))
|
|
423
|
+
return;
|
|
424
|
+
const entries = list(path); // An unreadable pool must refuse preparation, never silently widen access.
|
|
425
|
+
for (const entry of entries)
|
|
426
|
+
split(join(path, entry));
|
|
427
|
+
};
|
|
428
|
+
for (const root of options.roots) {
|
|
429
|
+
if (root === null || !existsSync(root))
|
|
430
|
+
continue;
|
|
431
|
+
const path = real(root);
|
|
432
|
+
if (path === dirname(path) || path === home || inside(home, path) || (own !== null && (path === own || inside(path, own))))
|
|
433
|
+
continue;
|
|
434
|
+
split(path);
|
|
435
|
+
}
|
|
436
|
+
const holding = own === null ? [] : [...deny].filter(path => inside(own, path));
|
|
437
|
+
const lookup = new Set([...visited].filter(path => needed.some(target => inside(target, path))));
|
|
438
|
+
for (const path of holding)
|
|
439
|
+
for (let folder = dirname(own); folder === path || inside(folder, path); folder = dirname(folder))
|
|
440
|
+
lookup.add(folder);
|
|
441
|
+
return { deny: [...deny].sort(), own: holding.length === 0 ? null : own, lookup: [...lookup].sort(), ...(needed.length === 0 ? {} : { gitPaths: needed }) };
|
|
442
|
+
}
|
|
443
|
+
/** The grants for one build: its task boundary's git rules, the shared dependencies its checkout links, and the
|
|
444
|
+
* checkout pools (`poolRoots`, from the plane's records) denied around its own checkout. */
|
|
445
|
+
export function fenceGrants(options) {
|
|
446
|
+
const readable = [];
|
|
447
|
+
const lookup = [...options.boundary?.lookup ?? []];
|
|
448
|
+
if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:" && options.worktree !== null && options.linked != null) {
|
|
449
|
+
// The link note names the copy as the plane wrote it; the sandbox sees real paths. The note is in the build's own
|
|
450
|
+
// checkout, so it is only a hint: the copy must be a ready one the plane made for this run's project under the
|
|
451
|
+
// setup it stamped on this checkout, or nothing is granted.
|
|
452
|
+
const written = join(dirname(options.databaseFile), DEPS_FOLDER);
|
|
453
|
+
const deps = join(dirname(real(options.databaseFile)), DEPS_FOLDER);
|
|
454
|
+
const key = linkedKey(options.worktree, written) ?? linkedKey(options.worktree, deps);
|
|
455
|
+
const linked = options.linked;
|
|
456
|
+
const copy = key === null || !/^[0-9a-f]{24}$/.test(key) ? undefined : sharedCopies(deps).find(one => one.key === key);
|
|
457
|
+
const ours = copy !== undefined && !copy.retired && copy.repo === linked.repo && linked.setupDigest !== null && readySetup(deps, key) === linked.setupDigest && readyCopy(deps, key) !== null;
|
|
458
|
+
if (ours && key !== null && existsSync(join(deps, key, "node_modules"))) {
|
|
459
|
+
lookup.push(deps, join(deps, key));
|
|
460
|
+
readable.push(join(deps, key, "node_modules"));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const pool = options.poolRoots === undefined ? undefined : checkoutPool({ roots: options.poolRoots, worktree: options.worktree, binding: options.binding ?? null, ...(options.home === undefined ? {} : { home: options.home }) });
|
|
464
|
+
return { readOnly: [...options.boundary?.readOnly ?? []], readable, lookup: [...new Set(lookup)].sort(), ...(options.runFolders === undefined ? {} : { runFolders: options.runFolders }),
|
|
465
|
+
...(pool === undefined || pool.deny.length === 0 ? {} : { pool }) };
|
|
466
|
+
}
|
|
467
|
+
/** The setup a shared copy was installed by (its ready note), or null. */
|
|
468
|
+
function readySetup(deps, key) {
|
|
469
|
+
try {
|
|
470
|
+
const setup = JSON.parse(readFileSync(join(deps, key, "ready.json"), "utf8")).setup;
|
|
471
|
+
return typeof setup === "string" ? setup : null;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Git settings every fenced build runs with (GIT_CONFIG_COUNT, after any the
|
|
479
|
+
* caller already set): `git gc` never expires a reflog, so it never rewrites
|
|
480
|
+
* another task's reflog, which the fence would refuse mid-collection.
|
|
481
|
+
*/
|
|
482
|
+
export function fencedGitEnv(base = process.env) {
|
|
483
|
+
const count = Number.parseInt(base["GIT_CONFIG_COUNT"] ?? "0", 10);
|
|
484
|
+
const start = Number.isInteger(count) && count > 0 ? count : 0;
|
|
485
|
+
const settings = [["gc.reflogExpire", "never"], ["gc.reflogExpireUnreachable", "never"]];
|
|
486
|
+
const env = { GIT_CONFIG_COUNT: String(start + settings.length) };
|
|
487
|
+
settings.forEach(([key, value], index) => { env[`GIT_CONFIG_KEY_${start + index}`] = key; env[`GIT_CONFIG_VALUE_${start + index}`] = value; });
|
|
488
|
+
return env;
|
|
192
489
|
}
|
|
193
490
|
/** The repository a git worktree belongs to (its `.git` file names the main repository's git folder). */
|
|
194
491
|
function worktreeGitDir(worktree) {
|
|
@@ -200,40 +497,248 @@ function worktreeGitDir(worktree) {
|
|
|
200
497
|
return null;
|
|
201
498
|
}
|
|
202
499
|
}
|
|
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
500
|
const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
212
501
|
/**
|
|
213
502
|
* Codex's own sandbox, fenced. Auto: the `:workspace` profile Codex's
|
|
214
503
|
* `--sandbox workspace-write` already gives (workspace and temp writes, no
|
|
215
504
|
* network). Full access: the same profile widened to write anywhere with
|
|
216
505
|
* 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.
|
|
506
|
+
* denied, which in Codex outranks any write or read grant. The checkout pools
|
|
507
|
+
* are never `flattened` (which leaves what is made after launch readable): see
|
|
508
|
+
* `codexPool`, which denies them around the build's own checkout, written back.
|
|
218
509
|
*/
|
|
219
|
-
export function codexFenceArgv(fence, fullAccess) {
|
|
220
|
-
const
|
|
510
|
+
export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
|
|
511
|
+
const { deny, readOnly } = flattened(fence, grants);
|
|
512
|
+
// Codex's harness reads its own launch files outside the command sandbox.
|
|
513
|
+
// Deny the namespace, including folders created after this policy was built.
|
|
514
|
+
if (grants.runFolders !== undefined) {
|
|
515
|
+
for (const prefix of RUN_FOLDER_PREFIXES) {
|
|
516
|
+
deny.push(join(grants.runFolders.root, `${prefix}*`), join(grants.runFolders.root, `${prefix}*`, "**"));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// Launch folders can already be in the base fence when invoke appends them.
|
|
520
|
+
// TOML forbids duplicate keys, even with identical values. Normalize aliases
|
|
521
|
+
// at this boundary and apply stronger access restrictions last.
|
|
522
|
+
const access = new Map();
|
|
523
|
+
const pool = grants.pool;
|
|
524
|
+
// Codex lets the more specific path win: no read grant inside a pool, but the build's own checkout.
|
|
525
|
+
const pooled = (path) => pool !== undefined && pool.deny.some(one => path === one || inside(path, one))
|
|
526
|
+
&& !(pool.own !== null && (path === pool.own || inside(path, pool.own)));
|
|
527
|
+
if (fullAccess)
|
|
528
|
+
access.set("/", "write");
|
|
529
|
+
for (const path of readOnly)
|
|
530
|
+
if (!pooled(real(path)))
|
|
531
|
+
access.set(real(path), "read");
|
|
532
|
+
if (pool !== undefined) {
|
|
533
|
+
const held = codexPool(pool);
|
|
534
|
+
for (const path of held.read)
|
|
535
|
+
access.set(path, "read");
|
|
536
|
+
for (const path of held.deny)
|
|
537
|
+
access.set(path, "deny");
|
|
538
|
+
for (const path of held.write)
|
|
539
|
+
access.set(path, "write");
|
|
540
|
+
}
|
|
541
|
+
// Denies are keyed where they resolve; the console's run folder is also denied by its own pathname, so a link there
|
|
542
|
+
// stays unreplaceable, not only its target.
|
|
543
|
+
for (const path of deny) {
|
|
544
|
+
access.set(real(path), "deny");
|
|
545
|
+
if (basename(path) === INSTANCE_DIR)
|
|
546
|
+
access.set(path, "deny");
|
|
547
|
+
}
|
|
548
|
+
const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
|
|
221
549
|
return [
|
|
222
550
|
"-c", 'default_permissions="standing-orders"',
|
|
223
551
|
"-c", 'permissions.standing-orders.extends=":workspace"',
|
|
224
552
|
"-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
|
|
225
|
-
|
|
553
|
+
// Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
|
|
554
|
+
// network is open (and Full access asked for it), and are offline otherwise.
|
|
555
|
+
...(fullAccess && (grants.network === undefined || grants.network.mode === "open") ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
|
|
556
|
+
...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
|
|
226
557
|
];
|
|
227
558
|
}
|
|
228
|
-
/**
|
|
229
|
-
|
|
230
|
-
|
|
559
|
+
/**
|
|
560
|
+
* Codex cannot deny an ancestor and still traverse it to the own checkout. Deny existing
|
|
561
|
+
* siblings individually, from the trusted pool roots and a fresh listing, never through
|
|
562
|
+
* flattened or name patterns. A project added after launch is covered on the next launch;
|
|
563
|
+
* Seatbelt and bubblewrap retain the stronger whole-pool rule for later-created projects.
|
|
564
|
+
*/
|
|
565
|
+
export function codexPool(pool, list = path => readdirSync(path)) {
|
|
566
|
+
const deny = new Set();
|
|
567
|
+
const read = new Set(pool.lookup);
|
|
568
|
+
const own = pool.own;
|
|
569
|
+
const needed = [...pool.gitPaths ?? [], ...(own === null ? [] : [own])].map(path => realpathSync(path));
|
|
570
|
+
const visited = new Set();
|
|
571
|
+
const split = (input) => {
|
|
572
|
+
let path;
|
|
573
|
+
try {
|
|
574
|
+
path = realpathSync(input);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
deny.add(resolve(input));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (visited.has(path))
|
|
581
|
+
return;
|
|
582
|
+
visited.add(path);
|
|
583
|
+
if (needed.some(target => path === target || inside(path, target)))
|
|
584
|
+
return;
|
|
585
|
+
if (!needed.some(target => inside(target, path))) {
|
|
586
|
+
deny.add(path);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// Only ancestors needed for traversal stay readable, never writable in Full access.
|
|
590
|
+
read.add(path);
|
|
591
|
+
for (const entry of list(path))
|
|
592
|
+
split(join(path, entry));
|
|
593
|
+
};
|
|
594
|
+
for (const root of pool.deny)
|
|
595
|
+
split(root);
|
|
596
|
+
return { read: [...read].sort(), deny: [...deny].sort(), write: own === null ? [] : [own] };
|
|
597
|
+
}
|
|
598
|
+
/** Secret-looking names denied by pattern inside a folder `flattened` makes read-only, so one written there after
|
|
599
|
+
* launch is denied too (the database and its journals, tokens, login and other text files). */
|
|
600
|
+
export const SPLIT_SECRET_PATTERNS = ["orders.db*", "*token*", "up-login*", "*.txt", "*secret*", "*password*", "*credential*", "*.pem", "*.key"];
|
|
601
|
+
/**
|
|
602
|
+
* A fence as plain denied and read-only paths, for a sandbox that can't
|
|
603
|
+
* grant inside a denied folder (Codex: a deny outranks any grant): a denied
|
|
604
|
+
* folder holding something readable becomes read-only, with each of its
|
|
605
|
+
* entries not on the way to what is readable denied instead. Fails closed: a
|
|
606
|
+
* folder that can't be listed stays denied whole and its grant is dropped.
|
|
607
|
+
* Residual: an entry made after launch in a read-only folder is denied only
|
|
608
|
+
* when its name matches SPLIT_SECRET_PATTERNS; any other new entry there is
|
|
609
|
+
* readable (never writable) until the next launch. The macOS fence has no
|
|
610
|
+
* such gap: it denies the folder and grants back only the readable path.
|
|
611
|
+
*/
|
|
612
|
+
export function flattened(fence, grants = NO_GRANTS, list = path => readdirSync(path)) {
|
|
613
|
+
const deny = [];
|
|
614
|
+
const readOnly = new Set(grants.readOnly);
|
|
615
|
+
const split = (path) => {
|
|
616
|
+
const within = grants.readable.filter(one => inside(one, path));
|
|
617
|
+
if (within.length === 0) {
|
|
618
|
+
deny.push(path);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
let entries;
|
|
622
|
+
try {
|
|
623
|
+
entries = list(path);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
deny.push(path);
|
|
627
|
+
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`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
readOnly.add(path);
|
|
631
|
+
for (const pattern of SPLIT_SECRET_PATTERNS)
|
|
632
|
+
deny.push(join(path, pattern));
|
|
633
|
+
for (const entry of entries) {
|
|
634
|
+
const child = join(path, entry);
|
|
635
|
+
if (grants.readable.includes(child))
|
|
636
|
+
readOnly.add(child);
|
|
637
|
+
else
|
|
638
|
+
split(child);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
for (const path of fence)
|
|
642
|
+
split(path);
|
|
643
|
+
// Fail closed: a grant equal to or inside a denied path is dropped, so no read grant can outrank a deny (Codex lets
|
|
644
|
+
// the more specific path win). Patterns stay as Codex patterns; they can't contain a grant here.
|
|
645
|
+
const denied = deny.filter(path => !path.includes("*"));
|
|
646
|
+
return { deny, readOnly: [...readOnly].filter(path => !denied.some(one => path === one || inside(path, one))) };
|
|
647
|
+
}
|
|
648
|
+
/** Claude's own file tools refuse the fenced paths in every mode (its shell is fenced by the macOS sandbox), and
|
|
649
|
+
* never edit a read-only one. */
|
|
650
|
+
export function claudeFenceSettings(fence, readOnly = []) {
|
|
651
|
+
const rules = [...fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]), ...readOnly.flatMap(path => [`Edit(/${path})`, `Edit(/${path}/**)`])];
|
|
231
652
|
return JSON.stringify({ permissions: { deny: rules } });
|
|
232
653
|
}
|
|
233
654
|
const seatbelt = (path) => `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
655
|
+
const seatbeltRegex = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
656
|
+
/**
|
|
657
|
+
* The folders the user owns above each path (never `/`, nor the path itself), and each `roots` folder itself when the
|
|
658
|
+
* user owns it. A Seatbelt path rule follows the name, not the folder: renaming one of these would bring a fenced path
|
|
659
|
+
* back under a name no rule mentions. A folder the user doesn't own can't be renamed by a build anyway.
|
|
660
|
+
*/
|
|
661
|
+
export function ownedAncestors(paths, roots = [], uid = process.getuid?.()) {
|
|
662
|
+
const out = new Set();
|
|
663
|
+
const owned = (path) => { try {
|
|
664
|
+
return uid !== undefined && statSync(path).uid === uid;
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return false;
|
|
668
|
+
} };
|
|
669
|
+
const climb = (from) => {
|
|
670
|
+
for (let folder = from; folder !== dirname(folder); folder = dirname(folder))
|
|
671
|
+
if (owned(folder))
|
|
672
|
+
out.add(folder);
|
|
673
|
+
};
|
|
674
|
+
for (const path of paths)
|
|
675
|
+
climb(dirname(path));
|
|
676
|
+
for (const root of roots)
|
|
677
|
+
climb(root);
|
|
678
|
+
return [...out].sort();
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* A Seatbelt profile that allows everything except reading or writing the
|
|
682
|
+
* fenced paths, less what `grants` gives back (a later Seatbelt rule
|
|
683
|
+
* outranks an earlier one). Whatever the paths, the process can't read
|
|
684
|
+
* another's arguments and environment (`kern.procargs`, which `ps -E` and
|
|
685
|
+
* KERN_PROCARGS2 use) or inspect a process outside its own sandbox:
|
|
686
|
+
* `process-info*` limited by target alone doesn't stop the sysctl. It can't
|
|
687
|
+
* take another process's task port either (`task_for_pid`, what lldb attaches
|
|
688
|
+
* with; a task name port too), so a tool server's or Toolroll's own memory
|
|
689
|
+
* and environment stay out of reach. Last, every folder of the user's above
|
|
690
|
+
* a fenced or read-only path and above the temp folder (and the temp folder
|
|
691
|
+
* itself) can't be renamed or removed, though what is inside them stays
|
|
692
|
+
* writable as before. The checkout pools are denied first, and the build's own
|
|
693
|
+
* checkout given back right after, so every later deny still holds inside it.
|
|
694
|
+
*/
|
|
695
|
+
export function macosFenceProfile(fence, grants = NO_GRANTS) {
|
|
696
|
+
const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
|
|
697
|
+
const folders = grants.runFolders;
|
|
698
|
+
const pool = grants.pool;
|
|
699
|
+
const above = ownedAncestors([...fence, ...grants.readOnly, ...pool?.deny ?? []], [folders?.root ?? real(tmpdir())]);
|
|
700
|
+
return [
|
|
701
|
+
"(version 1)",
|
|
702
|
+
"(allow default)",
|
|
703
|
+
...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
|
|
704
|
+
...(pool?.own == null ? [] : [
|
|
705
|
+
`(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
|
|
706
|
+
`(deny file-write* ${paths("literal", [pool.own])})`,
|
|
707
|
+
]),
|
|
708
|
+
...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
|
|
709
|
+
...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
|
|
710
|
+
...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
|
|
711
|
+
...(folders === undefined ? [] : [`(deny file-read* file-write* (regex #"^${seatbeltRegex(folders.root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`]),
|
|
712
|
+
...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
|
|
713
|
+
// Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
|
|
714
|
+
...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
|
|
715
|
+
...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`]),
|
|
716
|
+
'(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
|
|
717
|
+
"(deny process-info-pidinfo)",
|
|
718
|
+
"(allow process-info-pidinfo (target same-sandbox))",
|
|
719
|
+
"(deny mach-priv-task-port)",
|
|
720
|
+
"(deny mach-task-name)",
|
|
721
|
+
...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
|
|
722
|
+
...macosNetworkRules(grants.network),
|
|
723
|
+
].join("\n");
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
|
|
727
|
+
* allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
|
|
728
|
+
* allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
|
|
729
|
+
* or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
|
|
730
|
+
* connection.
|
|
731
|
+
*/
|
|
732
|
+
export function macosNetworkRules(network) {
|
|
733
|
+
if (network === undefined || network.mode === "open")
|
|
734
|
+
return [];
|
|
735
|
+
const local = (port) => `(remote ip "localhost:${port}")`;
|
|
736
|
+
return [
|
|
737
|
+
"(deny network-outbound)",
|
|
738
|
+
`(allow network-outbound ${network.loopback === "any" ? local("*") : [network.proxyPort, ...network.brokerPorts].map(local).join(" ")})`,
|
|
739
|
+
...(network.socketRoots.length === 0 ? [] : [`(allow network-outbound ${network.socketRoots.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
|
|
740
|
+
`(deny mach-lookup ${DELEGATION_SERVICES.map(name => `(global-name ${seatbelt(name)})`).join(" ")})`,
|
|
741
|
+
];
|
|
237
742
|
}
|
|
238
743
|
export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
239
744
|
/** 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 +772,8 @@ export function macosFenceAvailable(platform = process.platform, probe) {
|
|
|
267
772
|
return seatbeltWorks;
|
|
268
773
|
}
|
|
269
774
|
/** 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] };
|
|
775
|
+
export function macosFenced(file, args, fence, grants = NO_GRANTS) {
|
|
776
|
+
return { file: SANDBOX_EXEC, args: ["-p", macosFenceProfile(fence, grants), file, ...args] };
|
|
272
777
|
}
|
|
273
778
|
/** Whether bubblewrap runs here (checked once): installed, and allowed to make its namespace. */
|
|
274
779
|
let bubblewrapWorks = null;
|
|
@@ -285,9 +790,33 @@ export function linuxFenceAvailable(platform = process.platform, probe = () => s
|
|
|
285
790
|
}
|
|
286
791
|
return bubblewrapWorks;
|
|
287
792
|
}
|
|
288
|
-
/**
|
|
289
|
-
|
|
290
|
-
|
|
793
|
+
/** A secret-name pattern (SPLIT_SECRET_PATTERNS) as a test of one path segment. */
|
|
794
|
+
const patternTest = (pattern) => new RegExp(`^${pattern.split("*").map(seatbeltRegex).join(".*")}$`);
|
|
795
|
+
const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
|
|
796
|
+
/**
|
|
797
|
+
* The grants bubblewrap may bind back over its masks: each existing read-only or readable path, never one that is a
|
|
798
|
+
* fenced path itself, a read-only one inside a fenced path (only the shared install this run links is read inside
|
|
799
|
+
* one), another launch's private folder, or one with a secret-looking name below its fenced folder. Bubblewrap can't
|
|
800
|
+
* mask by pattern, so a grant a pattern would deny is dropped instead (fail closed).
|
|
801
|
+
*/
|
|
802
|
+
function linuxGrants(fence, grants) {
|
|
803
|
+
const folders = grants.runFolders;
|
|
804
|
+
const launchFolder = (path) => folders !== undefined && inside(path, folders.root) && path !== folders.own && !(folders.own != null && inside(path, folders.own))
|
|
805
|
+
&& RUN_FOLDER_PREFIXES.some(prefix => relative(folders.root, path).split(sep)[0].startsWith(prefix));
|
|
806
|
+
const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
|
|
807
|
+
const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
|
|
808
|
+
&& !(readOnlyGrant && fence.some(one => inside(path, one)));
|
|
809
|
+
return [...new Set([...grants.readOnly.filter(path => ok(path, true)), ...grants.readable.filter(path => ok(path, false))])];
|
|
810
|
+
}
|
|
811
|
+
/** The spawn that runs `file args` inside the Linux fence: the whole filesystem as it is, the checkout pools masked
|
|
812
|
+
* and the build's own checkout bound back read-write over them, then every fenced path masked whole (an empty
|
|
813
|
+
* folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
|
|
814
|
+
* bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
|
|
815
|
+
export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
|
|
816
|
+
const network = grants.network?.mode === "allowlist" ? grants.network : null;
|
|
817
|
+
if (network !== null && network.bridge === undefined)
|
|
818
|
+
throw new Error("A confined network on Linux needs its bridge");
|
|
819
|
+
const mask = (path) => {
|
|
291
820
|
let folder = true;
|
|
292
821
|
try {
|
|
293
822
|
folder = statSync(path).isDirectory();
|
|
@@ -296,6 +825,59 @@ export function linuxFenced(file, args, fence) {
|
|
|
296
825
|
folder = true;
|
|
297
826
|
}
|
|
298
827
|
return folder ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path];
|
|
299
|
-
}
|
|
300
|
-
|
|
828
|
+
};
|
|
829
|
+
const pool = grants.pool?.deny ?? [];
|
|
830
|
+
const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
|
|
831
|
+
const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
|
|
832
|
+
// A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
|
|
833
|
+
// mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
|
|
834
|
+
const isolated = network === null ? [] : [
|
|
835
|
+
"--unshare-net", "--unshare-pid", "--proc", "/proc",
|
|
836
|
+
...linuxHostSockets().flatMap(mask),
|
|
837
|
+
"--ro-bind", network.bridge.folder, network.bridge.folder,
|
|
838
|
+
];
|
|
839
|
+
// A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
|
|
840
|
+
// all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
|
|
841
|
+
const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => ["--bind", path, path])];
|
|
842
|
+
const command = network === null ? [file, ...args]
|
|
843
|
+
: [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
|
|
844
|
+
return { file: "bwrap", args: ["--dev-bind", "/", "/", ...privateTmp, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...isolated, "--die-with-parent", "--", ...command] };
|
|
845
|
+
}
|
|
846
|
+
/** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
|
|
847
|
+
* engines), wherever they exist here. */
|
|
848
|
+
export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp") {
|
|
849
|
+
// The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
|
|
850
|
+
const system = [
|
|
851
|
+
...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
|
|
852
|
+
"/var/run/podman", "/run/systemd/private", "/run/snapd.socket", "/run/screen",
|
|
853
|
+
];
|
|
854
|
+
const masks = new Set();
|
|
855
|
+
for (const path of system) {
|
|
856
|
+
try {
|
|
857
|
+
masks.add(realpathSync(path));
|
|
858
|
+
}
|
|
859
|
+
catch { /* not here */ }
|
|
860
|
+
}
|
|
861
|
+
// The user's own, in places a build could plant names: taken only as the real folders or sockets they are, never
|
|
862
|
+
// through a link (a planted link would turn a mask onto any folder at all). Container engines
|
|
863
|
+
// run per user (Docker Desktop and the docker CLI's folder whole, so a socket made there later is covered too;
|
|
864
|
+
// Colima, OrbStack, Lima, Rancher Desktop, rootless containers); a terminal multiplexer takes commands from its
|
|
865
|
+
// socket; an SSH agent signs as you; editors keep IPC sockets in /tmp (hidden by the build's private /tmp).
|
|
866
|
+
// Masked whoever owns it (a folder `sudo docker` made is root's): only a link is never followed, since a link turns
|
|
867
|
+
// a mask onto its target. A link there is a residual until builds can't write the home folder (arc 2).
|
|
868
|
+
const owned = (path) => {
|
|
869
|
+
try {
|
|
870
|
+
const held = lstatSync(path);
|
|
871
|
+
return !held.isSymbolicLink() && (held.isDirectory() || held.isSocket());
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name)))
|
|
878
|
+
if (owned(path))
|
|
879
|
+
masks.add(path);
|
|
880
|
+
// /tmp's sockets (the display's, an SSH agent's, a multiplexer's, editors') go with the build's private /tmp.
|
|
881
|
+
void tmp;
|
|
882
|
+
return [...masks].sort();
|
|
301
883
|
}
|