toolroll 0.9.63 → 0.9.67
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 +108 -0
- package/README.md +33 -5
- 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 +117 -2
- package/dist/agent-fence.js +439 -12
- 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 +39 -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/batch-checks.js +3 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +228 -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 +235 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +10 -1
- package/dist/builder.js +87 -24
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +197 -15
- 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/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +19 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +4 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +21 -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/console-api.d.ts +13 -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/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/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +4 -1
- package/dist/doctor.js +1 -1
- 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/exec.d.ts +78 -0
- package/dist/exec.js +367 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-engine.js +24 -5
- 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 +77 -8
- package/dist/flow-triggers.js +517 -70
- package/dist/flows-cli.js +8 -2
- package/dist/flows-ui.d.ts +0 -2
- package/dist/flows-ui.js +52 -4
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- package/dist/invoke.d.ts +35 -0
- package/dist/invoke.js +347 -28
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- 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 +152 -1
- 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 +269 -0
- package/dist/live-terminal.js +686 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- 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/operate.d.ts +4 -0
- package/dist/operate.js +335 -19
- 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/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/project-delete-ui.d.ts +18 -0
- package/dist/project-delete-ui.js +60 -9
- package/dist/project-delete.js +2 -2
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +5 -3
- package/dist/project-tools.js +12 -9
- 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.d.ts +25 -2
- package/dist/provider.js +63 -9
- 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-adapters.d.ts +6 -0
- package/dist/release-adapters.js +70 -3
- package/dist/release.d.ts +10 -1
- package/dist/release.js +34 -15
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-follow-ups.js +3 -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 +183 -5
- package/dist/scope.js +256 -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 +248 -40
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +16 -2
- package/dist/server/chrome.js +63 -11
- package/dist/server/console.css +107 -42
- package/dist/server/flows.js +38 -8
- 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 +32 -4
- package/dist/server/render-pages.js +83 -13
- 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 +111 -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 +47 -5
- package/dist/server/runtime.d.ts +33 -2
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +142 -10
- 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 +327 -78
- 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-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +440 -15
- package/dist/store.js +1072 -52
- package/dist/subagent-desk.js +1 -1
- package/dist/summary.js +3 -0
- 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 +22 -0
- package/dist/teardown-git.js +136 -2
- 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 +14 -0
- package/dist/tool-broker.js +67 -2
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +13 -3
- package/dist/tools-ui.js +2 -2
- package/dist/unfenced-test-support.d.ts +2 -0
- package/dist/unfenced-test-support.js +11 -0
- 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 +16 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/writes-mode.d.ts +9 -0
- package/dist/writes-mode.js +8 -0
- package/package.json +15 -2
package/dist/agent-fence.js
CHANGED
|
@@ -40,13 +40,18 @@
|
|
|
40
40
|
* rename or remove a folder of the user's above a fenced path or the temp
|
|
41
41
|
* folder, which would reach the fenced path under a new name.
|
|
42
42
|
*/
|
|
43
|
+
import { INSTANCE_DIR } from "./console-instance.js";
|
|
44
|
+
import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
|
|
43
45
|
import { spawnSync } from "node:child_process";
|
|
44
|
-
import {
|
|
46
|
+
import { createHash } from "node:crypto";
|
|
47
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
45
48
|
import { homedir, tmpdir } from "node:os";
|
|
46
49
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
47
50
|
import { readMonitoring } from "./monitoring-settings.js";
|
|
48
51
|
import { existingFolders } from "./names.js";
|
|
49
52
|
import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
|
|
53
|
+
/** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
|
|
54
|
+
export const BRIDGE_DIR = "bridge-secrets";
|
|
50
55
|
/** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
|
|
51
56
|
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;
|
|
52
57
|
function real(path) {
|
|
@@ -127,6 +132,10 @@ export function trustedWorktreeBinding(repository, worktree, poolRoots = []) {
|
|
|
127
132
|
*/
|
|
128
133
|
export function agentFence(options) {
|
|
129
134
|
const fence = new Set();
|
|
135
|
+
/** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
|
|
136
|
+
let runFolder = null;
|
|
137
|
+
/** The bridges' folder, likewise. */
|
|
138
|
+
let bridgeFolder = null;
|
|
130
139
|
const home = real(options.home ?? homedir());
|
|
131
140
|
const worktree = options.worktree === null ? null : real(options.worktree);
|
|
132
141
|
if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
|
|
@@ -135,6 +144,45 @@ export function agentFence(options) {
|
|
|
135
144
|
for (const suffix of ["", "-wal", "-shm", "-journal"])
|
|
136
145
|
if (existsSync(database + suffix))
|
|
137
146
|
fence.add(database + suffix);
|
|
147
|
+
// The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
|
|
148
|
+
// build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
|
|
149
|
+
// 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
|
|
150
|
+
// real folder to cover (a Linux bind needs one).
|
|
151
|
+
// Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
|
|
152
|
+
// points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
|
|
153
|
+
// If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
|
|
154
|
+
// rather than run unfenced. The name is Toolroll's own; nothing else belongs there.
|
|
155
|
+
// The folder Linux builds' network bridges are made in (egress-bridge.ts): made before any build starts and fenced
|
|
156
|
+
// from every build, so no build — however long it runs — can reach a bridge made for a later one.
|
|
157
|
+
const bridges = join(state, BRIDGE_DIR);
|
|
158
|
+
try {
|
|
159
|
+
lstatSync(bridges);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
try {
|
|
163
|
+
mkdirSync(bridges, { mode: 0o700 });
|
|
164
|
+
}
|
|
165
|
+
catch { /* fenced below either way */ }
|
|
166
|
+
}
|
|
167
|
+
// Fenced by its own name as well as where it leads: a link planted there is itself out of reach (and the bridge
|
|
168
|
+
// refuses a root that isn't a private folder: egress-bridge.ts).
|
|
169
|
+
bridgeFolder = bridges;
|
|
170
|
+
fence.add(bridges);
|
|
171
|
+
runFolder = join(state, INSTANCE_DIR);
|
|
172
|
+
const present = () => { try {
|
|
173
|
+
lstatSync(runFolder);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
} };
|
|
179
|
+
if (!present()) {
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(runFolder, { mode: 0o700 });
|
|
182
|
+
}
|
|
183
|
+
catch { /* fenced below either way */ }
|
|
184
|
+
}
|
|
185
|
+
fence.add(runFolder);
|
|
138
186
|
// Only a folder that is Toolroll's own (~/.config/toolroll or an older name,
|
|
139
187
|
// the desktop app's "Toolroll") is fenced whole: a database kept
|
|
140
188
|
// in a shared folder (a projects folder, a test's temp folder) must not
|
|
@@ -179,12 +227,12 @@ export function agentFence(options) {
|
|
|
179
227
|
fence.add(path);
|
|
180
228
|
for (const path of options.boundary?.protectedShell ?? [])
|
|
181
229
|
fence.add(real(path));
|
|
182
|
-
const protectedPaths = new Set([...fence].
|
|
230
|
+
const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]));
|
|
183
231
|
for (const path of options.boundary?.shell ?? [])
|
|
184
232
|
fence.add(path);
|
|
185
233
|
const binding = options.binding;
|
|
186
234
|
const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
|
|
187
|
-
return [...new Set([...fence].
|
|
235
|
+
return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]))].filter(path => {
|
|
188
236
|
if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
|
|
189
237
|
return true;
|
|
190
238
|
console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
|
|
@@ -197,10 +245,18 @@ export function agentFence(options) {
|
|
|
197
245
|
* cloud logins or another agent's sign-in, or rewrite their own Claude or
|
|
198
246
|
* Codex setup for the next interactive session.
|
|
199
247
|
*/
|
|
200
|
-
const HOME_CONFIGURATION = [
|
|
248
|
+
const HOME_CONFIGURATION = [
|
|
249
|
+
".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json"),
|
|
250
|
+
// Every ACP agent's own configuration and sign-in (acp-agents.ts): fenced from every build but that agent's own.
|
|
251
|
+
...ACP_PROVIDER_IDS.flatMap(id => ACP_AGENTS[id].home),
|
|
252
|
+
];
|
|
201
253
|
/** What a provider's own CLI reads to sign in and keep its sessions. The OS fence around Claude and Gemini holds
|
|
202
254
|
* their whole process, so those stay reachable there; Codex fences only its agent's commands, so it keeps none. */
|
|
203
|
-
const PROVIDER_HOME = {
|
|
255
|
+
const PROVIDER_HOME = {
|
|
256
|
+
claude: [".claude", ".claude.json"], gemini: [".gemini"], codex: [], openrouter: [],
|
|
257
|
+
// Each ACP agent keeps its own configuration and sign-in where its row says (acp-agents.ts).
|
|
258
|
+
...Object.fromEntries(ACP_PROVIDER_IDS.map(id => [id, ACP_AGENTS[id].home])),
|
|
259
|
+
};
|
|
204
260
|
/** What Claude's own file tools refuse inside its home: the CLI reads these itself, its model never needs to. */
|
|
205
261
|
const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.json"), join(".claude", "settings.json"), join(".claude", "settings.local.json"), ".npmrc"];
|
|
206
262
|
/**
|
|
@@ -311,8 +367,9 @@ export function taskBoundary(options) {
|
|
|
311
367
|
const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
|
|
312
368
|
return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
|
|
313
369
|
}
|
|
314
|
-
/** The prefixes of every launch's private folders: its tools' config
|
|
315
|
-
|
|
370
|
+
/** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
|
|
371
|
+
* network bridge (egress-bridge.ts). */
|
|
372
|
+
export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
|
|
316
373
|
export function runFolders(own, root = tmpdir()) {
|
|
317
374
|
return { root: real(root), own: own === null ? null : real(own) };
|
|
318
375
|
}
|
|
@@ -327,6 +384,172 @@ function otherRunFolders(folders) {
|
|
|
327
384
|
}
|
|
328
385
|
return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
|
|
329
386
|
}
|
|
387
|
+
export { isWritesMode, WRITES_DEFAULT } from "./writes-mode.js";
|
|
388
|
+
/** Claude's transcript folder for a working folder: its path with every character but a letter or digit made `-`
|
|
389
|
+
* (Claude Code's own naming; a name over 200 characters gets a suffix, so it is granted by prefix). */
|
|
390
|
+
export function claudeProjectFolder(home, cwd) {
|
|
391
|
+
const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
392
|
+
return slug.length > 200 ? { path: join(home, ".claude", "projects", slug.slice(0, 200)), byPrefix: true } : { path: join(home, ".claude", "projects", slug), byPrefix: false };
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* A folder a write grant may name: made when absent, then only as the real folder it is — never a link, nor reached
|
|
396
|
+
* through one (its real path is its parent's real path and its own name). A build can rewrite what it may write, so a
|
|
397
|
+
* folder it was given could otherwise come back as a link to anywhere and be granted, followed, on its next launch.
|
|
398
|
+
* Null when it isn't such a folder: nothing is granted, and the write it was for fails.
|
|
399
|
+
*/
|
|
400
|
+
export function grantFolder(path, create = true) {
|
|
401
|
+
if (create) {
|
|
402
|
+
try {
|
|
403
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
404
|
+
}
|
|
405
|
+
catch { /* checked below */ }
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
const held = lstatSync(path);
|
|
409
|
+
if (held.isSymbolicLink() || !held.isDirectory())
|
|
410
|
+
return null;
|
|
411
|
+
// Nor through a link of this user's anywhere above it (a system alias, /var → /private/var, is root's and fine).
|
|
412
|
+
const uid = process.getuid?.();
|
|
413
|
+
for (let folder = dirname(resolve(path)); folder !== dirname(folder); folder = dirname(folder)) {
|
|
414
|
+
const above = lstatSync(folder);
|
|
415
|
+
if (above.isSymbolicLink() && (uid === undefined || above.uid === uid))
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
const at = realpathSync(path);
|
|
419
|
+
return at === join(realpathSync(dirname(path)), basename(path)) ? at : null;
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The data folders an ACP agent writes besides a ~/.local/share|state root, per agent, only where known: Goose's
|
|
427
|
+
* sessions and state; Qwen Code (a Gemini CLI fork) its history and temp, as Gemini measured. An agent not listed
|
|
428
|
+
* (Cursor, Cline: their settings sit among their data) writes nothing of its own under a workspace build until its
|
|
429
|
+
* layout is measured; a project using one chooses `anywhere`.
|
|
430
|
+
*/
|
|
431
|
+
const ACP_DATA = {
|
|
432
|
+
goose: [".local/share/goose", ".local/state/goose"],
|
|
433
|
+
qwen: [".qwen/tmp", ".qwen/history"],
|
|
434
|
+
};
|
|
435
|
+
/** The suffix Claude Code gives a transcript folder whose name it shortened: `-` and a hash, nothing else. */
|
|
436
|
+
const CLAUDE_SHORTENED = "-[a-z0-9]+(/|$)";
|
|
437
|
+
/** A file and the temporary names it is replaced through (`name.tmp-1`, `name.json.12345`), never a folder below it. */
|
|
438
|
+
const ATOMIC_NAMES = "([.~][^/]*)?$";
|
|
439
|
+
/** The parts of a repository's shared Git data a commit in a worktree writes: objects, refs and their logs (and the
|
|
440
|
+
* packed refs). Its config, hooks, info, other worktrees' data and submodules' are never among them. */
|
|
441
|
+
function commitGitData(common) {
|
|
442
|
+
const made = (path) => { try {
|
|
443
|
+
mkdirSync(path, { recursive: true });
|
|
444
|
+
}
|
|
445
|
+
catch { /* granted as named */ } return real(path); };
|
|
446
|
+
return {
|
|
447
|
+
subpaths: ["objects", "refs", "logs"].map(name => grantFolder(join(common, name))).filter((path) => path !== null),
|
|
448
|
+
prefixes: [{ path: join(common, "packed-refs"), suffix: ATOMIC_NAMES }],
|
|
449
|
+
// Alternates there would let objects come from anywhere.
|
|
450
|
+
readOnly: [made(join(common, "objects", "info"))],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* What a `workspace` build writes and what it loses (arc 2): its checkout, its checkout's Git data (less the shared
|
|
455
|
+
* repository's config, hooks and info, which Git runs or reads as commands), its own launch folder (its temp is there),
|
|
456
|
+
* its project's cache folder, the terminal devices, and only the provider state its CLI must write (measured: Claude's
|
|
457
|
+
* transcript folder for this checkout; Gemini's history, temp and project list; an ACP agent's own folders). The
|
|
458
|
+
* folders named here are made first, so the provider never has to make them where it can't. `deny` is what this
|
|
459
|
+
* provider must not even read (Claude's live sessions' keys).
|
|
460
|
+
*/
|
|
461
|
+
export function writeAllowlist(options) {
|
|
462
|
+
const home = real(options.home);
|
|
463
|
+
const worktree = real(options.worktree);
|
|
464
|
+
const subpaths = new Set([worktree]);
|
|
465
|
+
const prefixes = new Map();
|
|
466
|
+
const readOnly = new Set();
|
|
467
|
+
const deny = new Set();
|
|
468
|
+
const made = (path) => { try {
|
|
469
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
470
|
+
}
|
|
471
|
+
catch { /* granted as named either way */ } return real(path); };
|
|
472
|
+
const grant = (path, create = true) => { const at = grantFolder(path, create); if (at !== null)
|
|
473
|
+
subpaths.add(at); };
|
|
474
|
+
if (options.binding !== null) {
|
|
475
|
+
const common = real(join(options.binding.main, ".git"));
|
|
476
|
+
const gitdir = real(options.binding.gitdir);
|
|
477
|
+
grant(gitdir, false);
|
|
478
|
+
// The shared Git data a commit writes, and nothing Git reads as commands or settings: the shared config (filters,
|
|
479
|
+
// hooks path, fsmonitor), hooks, info (attributes, excludes), other worktrees' data and submodules' stay as they are.
|
|
480
|
+
const data = commitGitData(common);
|
|
481
|
+
for (const path of data.subpaths)
|
|
482
|
+
subpaths.add(path);
|
|
483
|
+
for (const one of data.prefixes)
|
|
484
|
+
prefixes.set(`${one.path}\0${one.suffix}`, one);
|
|
485
|
+
for (const path of data.readOnly)
|
|
486
|
+
readOnly.add(path);
|
|
487
|
+
// This worktree's own settings file, present (empty) so it is held read-only everywhere, a bind included.
|
|
488
|
+
const own = join(gitdir, "config.worktree");
|
|
489
|
+
try {
|
|
490
|
+
if (!existsSync(own))
|
|
491
|
+
writeFileSync(own, "", { mode: 0o644, flag: "wx" });
|
|
492
|
+
}
|
|
493
|
+
catch { /* held read-only by name */ }
|
|
494
|
+
readOnly.add(own);
|
|
495
|
+
}
|
|
496
|
+
if (options.launchFolder !== null)
|
|
497
|
+
grant(options.launchFolder, false);
|
|
498
|
+
if (options.cache !== null)
|
|
499
|
+
grant(options.cache);
|
|
500
|
+
if (options.provider === "claude") {
|
|
501
|
+
const folder = claudeProjectFolder(home, worktree);
|
|
502
|
+
if (folder.byPrefix) {
|
|
503
|
+
made(dirname(folder.path));
|
|
504
|
+
prefixes.set(folder.path, { path: folder.path, suffix: CLAUDE_SHORTENED });
|
|
505
|
+
}
|
|
506
|
+
else
|
|
507
|
+
grant(folder.path);
|
|
508
|
+
deny.add(join(home, ".claude", "sessions"));
|
|
509
|
+
}
|
|
510
|
+
else if (options.provider === "gemini") {
|
|
511
|
+
for (const name of ["history", "tmp"])
|
|
512
|
+
grant(join(home, ".gemini", name));
|
|
513
|
+
prefixes.set("gemini", { path: join(home, ".gemini", "projects.json"), suffix: ATOMIC_NAMES });
|
|
514
|
+
}
|
|
515
|
+
else if (Object.hasOwn(ACP_AGENTS, options.provider)) {
|
|
516
|
+
// An ACP agent's configuration stays read-only whole (its MCP servers, hooks, plugins, agents and commands could
|
|
517
|
+
// otherwise be planted for the operator's next session of it); only its known data folders are written (ACP_DATA).
|
|
518
|
+
for (const name of ACP_AGENTS[options.provider].home)
|
|
519
|
+
if (/^\.local\/(share|state)\//.test(name))
|
|
520
|
+
grant(join(home, name));
|
|
521
|
+
for (const name of ACP_DATA[options.provider] ?? [])
|
|
522
|
+
grant(join(home, name));
|
|
523
|
+
}
|
|
524
|
+
return { writes: { subpaths: [...subpaths].sort(), prefixes: [...prefixes.values()].sort((a, b) => a.path.localeCompare(b.path)) }, readOnly: [...readOnly].sort(), deny: [...deny].sort() };
|
|
525
|
+
}
|
|
526
|
+
/** Where a project's builds keep their package caches: one folder per project (one trust domain), never used by the
|
|
527
|
+
* plane's own tools, below the user's cache folder. */
|
|
528
|
+
export function buildCacheFolder(repo, home = homedir(), platform = process.platform, env = process.env) {
|
|
529
|
+
const root = platform === "darwin" ? join(home, "Library", "Caches") : env["XDG_CACHE_HOME"] !== undefined && isAbsolute(env["XDG_CACHE_HOME"]) ? env["XDG_CACHE_HOME"] : join(home, ".cache");
|
|
530
|
+
return join(root, "toolroll-builds", createHash("sha256").update(real(repo)).digest("hex").slice(0, 16));
|
|
531
|
+
}
|
|
532
|
+
/** The environment that points a build's package managers at its project's cache folder, not the user's own. */
|
|
533
|
+
export function buildCacheEnv(folder) {
|
|
534
|
+
const at = (name) => join(folder, name);
|
|
535
|
+
return {
|
|
536
|
+
npm_config_cache: at("npm"), npm_config_store_dir: at("pnpm"), YARN_CACHE_FOLDER: at("yarn"), BUN_INSTALL_CACHE_DIR: at("bun"),
|
|
537
|
+
COREPACK_HOME: at("corepack"), npm_config_devdir: at("node-gyp"), PIP_CACHE_DIR: at("pip"), UV_CACHE_DIR: at("uv"),
|
|
538
|
+
GOCACHE: at("go-build"), GOMODCACHE: at("go-mod"), CARGO_HOME: at("cargo"), GRADLE_USER_HOME: at("gradle"),
|
|
539
|
+
DENO_DIR: at("deno"), XDG_CACHE_HOME: at("xdg"),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
|
|
544
|
+
* Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
|
|
545
|
+
* (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
|
|
546
|
+
* confined; the run's proxy resolves names for it.
|
|
547
|
+
*/
|
|
548
|
+
export const DELEGATION_SERVICES = [
|
|
549
|
+
"com.apple.coreservices.launchservicesd", "com.apple.coreservices.quarantine-resolver", "com.apple.lsd.mapdb", "com.apple.lsd.modifydb", "com.apple.lsd.open",
|
|
550
|
+
"com.apple.coreservices.appleevents", "com.apple.nsurlsessiond", "com.apple.nsurlsessiond.NSURLSessionProxyService",
|
|
551
|
+
"com.apple.dnssd.service", "com.apple.mDNSResponder", "com.apple.pasteboard.1",
|
|
552
|
+
];
|
|
330
553
|
export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
|
|
331
554
|
/**
|
|
332
555
|
* The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
|
|
@@ -457,7 +680,14 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
|
|
|
457
680
|
// Codex lets the more specific path win: no read grant inside a pool, but the build's own checkout.
|
|
458
681
|
const pooled = (path) => pool !== undefined && pool.deny.some(one => path === one || inside(path, one))
|
|
459
682
|
&& !(pool.own !== null && (path === pool.own || inside(path, pool.own)));
|
|
460
|
-
|
|
683
|
+
// Full access writes anywhere its fence allows, unless the build writes only its workspace (arc 2): then exactly its
|
|
684
|
+
// allowlist, in either mode, beside the workspace Codex's own profile gives.
|
|
685
|
+
// Codex's own workspace profile also writes the shared temp folders; a workspace build starts from its read-only
|
|
686
|
+
// profile instead, with exactly the allowlist (its checkout among it) written back.
|
|
687
|
+
if (grants.writes !== undefined)
|
|
688
|
+
for (const path of grants.writes.subpaths)
|
|
689
|
+
access.set(real(path), "write");
|
|
690
|
+
else if (fullAccess)
|
|
461
691
|
access.set("/", "write");
|
|
462
692
|
for (const path of readOnly)
|
|
463
693
|
if (!pooled(real(path)))
|
|
@@ -471,14 +701,22 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
|
|
|
471
701
|
for (const path of held.write)
|
|
472
702
|
access.set(path, "write");
|
|
473
703
|
}
|
|
474
|
-
|
|
704
|
+
// Denies are keyed where they resolve; the console's run folder is also denied by its own pathname, so a link there
|
|
705
|
+
// stays unreplaceable, not only its target.
|
|
706
|
+
for (const path of deny) {
|
|
475
707
|
access.set(real(path), "deny");
|
|
708
|
+
if (basename(path) === INSTANCE_DIR)
|
|
709
|
+
access.set(path, "deny");
|
|
710
|
+
}
|
|
476
711
|
const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
|
|
477
712
|
return [
|
|
478
713
|
"-c", 'default_permissions="standing-orders"',
|
|
479
|
-
"-c",
|
|
714
|
+
"-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
|
|
480
715
|
"-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
|
|
481
|
-
|
|
716
|
+
// Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
|
|
717
|
+
// network is open (and Full access asked for it), and are offline otherwise.
|
|
718
|
+
...(fullAccess && (grants.network === undefined || grants.network.mode === "open") ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
|
|
719
|
+
...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
|
|
482
720
|
];
|
|
483
721
|
}
|
|
484
722
|
/**
|
|
@@ -625,6 +863,14 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
|
|
|
625
863
|
return [
|
|
626
864
|
"(version 1)",
|
|
627
865
|
"(allow default)",
|
|
866
|
+
// A workspace build writes only its allowlist (and the terminal devices); every deny and read-only rule after this
|
|
867
|
+
// still holds inside it.
|
|
868
|
+
...(grants.writes === undefined ? [] : [
|
|
869
|
+
"(deny file-write*)",
|
|
870
|
+
`(allow file-write* (subpath "/dev")${grants.writes.subpaths.length === 0 ? "" : ` ${paths("subpath", grants.writes.subpaths)}`}${grants.writes.prefixes.map(one => ` (regex #"^${seatbeltRegex(one.path)}${one.suffix}")`).join("")})`,
|
|
871
|
+
// A granted folder itself can't be renamed, removed or replaced (by a link to anywhere, say): only what is in it.
|
|
872
|
+
...(grants.writes.subpaths.length === 0 ? [] : [`(deny file-write* ${paths("literal", grants.writes.subpaths)})`]),
|
|
873
|
+
]),
|
|
628
874
|
...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
|
|
629
875
|
...(pool?.own == null ? [] : [
|
|
630
876
|
`(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
|
|
@@ -644,9 +890,38 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
|
|
|
644
890
|
"(deny mach-priv-task-port)",
|
|
645
891
|
"(deny mach-task-name)",
|
|
646
892
|
...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
|
|
893
|
+
...macosNetworkRules(grants.network),
|
|
894
|
+
// A workspace build reaches no unix socket but its own (a container engine's, an agent's, a database's would act
|
|
895
|
+
// outside its limits for it), whatever its network: a confined network has its own rule for this already. Its open
|
|
896
|
+
// network still names hosts: the system's DNS service answers on a unix socket, and without it nothing resolves.
|
|
897
|
+
...(grants.writes === undefined || grants.network?.mode === "allowlist" ? [] : [
|
|
898
|
+
"(deny network-outbound (remote unix-socket))",
|
|
899
|
+
`(allow network-outbound (remote unix-socket (path-literal ${seatbelt(MACOS_DNS_SOCKET)})))`,
|
|
900
|
+
...(grants.writes.subpaths.length === 0 ? [] : [`(allow network-outbound ${grants.writes.subpaths.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
|
|
901
|
+
]),
|
|
647
902
|
].join("\n");
|
|
648
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
|
|
906
|
+
* allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
|
|
907
|
+
* allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
|
|
908
|
+
* or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
|
|
909
|
+
* connection.
|
|
910
|
+
*/
|
|
911
|
+
export function macosNetworkRules(network) {
|
|
912
|
+
if (network === undefined || network.mode === "open")
|
|
913
|
+
return [];
|
|
914
|
+
const local = (port) => `(remote ip "localhost:${port}")`;
|
|
915
|
+
return [
|
|
916
|
+
"(deny network-outbound)",
|
|
917
|
+
`(allow network-outbound ${network.loopback === "any" ? local("*") : [network.proxyPort, ...network.brokerPorts].map(local).join(" ")})`,
|
|
918
|
+
...(network.socketRoots.length === 0 ? [] : [`(allow network-outbound ${network.socketRoots.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
|
|
919
|
+
`(deny mach-lookup ${DELEGATION_SERVICES.map(name => `(global-name ${seatbelt(name)})`).join(" ")})`,
|
|
920
|
+
];
|
|
921
|
+
}
|
|
649
922
|
export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
923
|
+
/** Where macOS's resolver (mDNSResponder) answers: every name lookup on a Mac is a message to this socket. */
|
|
924
|
+
export const MACOS_DNS_SOCKET = "/private/var/run/mDNSResponder";
|
|
650
925
|
/** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
|
|
651
926
|
const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
|
|
652
927
|
let seatbeltWorks = null;
|
|
@@ -719,6 +994,9 @@ function linuxGrants(fence, grants) {
|
|
|
719
994
|
* folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
|
|
720
995
|
* bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
|
|
721
996
|
export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
|
|
997
|
+
const network = grants.network?.mode === "allowlist" ? grants.network : null;
|
|
998
|
+
if (network !== null && network.bridge === undefined)
|
|
999
|
+
throw new Error("A confined network on Linux needs its bridge");
|
|
722
1000
|
const mask = (path) => {
|
|
723
1001
|
let folder = true;
|
|
724
1002
|
try {
|
|
@@ -732,5 +1010,154 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
|
|
|
732
1010
|
const pool = grants.pool?.deny ?? [];
|
|
733
1011
|
const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
|
|
734
1012
|
const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
|
|
735
|
-
|
|
1013
|
+
// A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
|
|
1014
|
+
// mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
|
|
1015
|
+
const isolated = network === null ? [] : [
|
|
1016
|
+
"--unshare-net", "--unshare-pid", "--proc", "/proc",
|
|
1017
|
+
...linuxHostSockets(undefined, undefined, undefined, grants.writes !== undefined).flatMap(mask),
|
|
1018
|
+
"--ro-bind", network.bridge.folder, network.bridge.folder,
|
|
1019
|
+
];
|
|
1020
|
+
// A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
|
|
1021
|
+
// all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
|
|
1022
|
+
// A workspace build (arc 2) sees the whole filesystem read-only, a /dev of its own and a /tmp of its own, with only
|
|
1023
|
+
// its allowlist bound back writable; its own checkout's Git data is in that list.
|
|
1024
|
+
const writes = grants.writes;
|
|
1025
|
+
const root = writes === undefined ? ["--dev-bind", "/", "/"] : ["--ro-bind", "/", "/", "--dev", "/dev"];
|
|
1026
|
+
const writable = writes === undefined ? [] : [...writes.subpaths, ...writes.prefixes.map(one => one.path)].filter(path => existsSync(path)).flatMap(path => ["--bind", path, path]);
|
|
1027
|
+
// What a private /tmp gives back is read-only for a workspace build (its writable part is bound again after): the
|
|
1028
|
+
// checkout's whole shared Git data among it must not come back writable.
|
|
1029
|
+
// A confined network brings a /tmp of its own (arc 1); a workspace build with an open network sees the shared /tmp
|
|
1030
|
+
// read-only like the rest of the root, its own folders there bound back writable.
|
|
1031
|
+
const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network?.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => [writes === undefined ? "--bind" : "--ro-bind", path, path])];
|
|
1032
|
+
// A workspace build never reaches the host's own services either (a container engine would write anywhere for it),
|
|
1033
|
+
// whatever its network: confined networks mask them below already.
|
|
1034
|
+
const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
|
|
1035
|
+
const command = network === null ? [file, ...args]
|
|
1036
|
+
: [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
|
|
1037
|
+
return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...services, ...isolated, "--die-with-parent", "--", ...command] };
|
|
1038
|
+
}
|
|
1039
|
+
/** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
|
|
1040
|
+
* engines), wherever they exist here. */
|
|
1041
|
+
export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp", followLinks = false, env = process.env) {
|
|
1042
|
+
// The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
|
|
1043
|
+
const system = [
|
|
1044
|
+
...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
|
|
1045
|
+
"/var/run/podman", "/run/systemd/private", "/run/snapd.socket", "/run/screen",
|
|
1046
|
+
];
|
|
1047
|
+
const masks = new Set();
|
|
1048
|
+
for (const path of system) {
|
|
1049
|
+
try {
|
|
1050
|
+
masks.add(realpathSync(path));
|
|
1051
|
+
}
|
|
1052
|
+
catch { /* not here */ }
|
|
1053
|
+
}
|
|
1054
|
+
// The user's own, in places a build could plant names: taken only as the real folders or sockets they are, never
|
|
1055
|
+
// through a link (a planted link would turn a mask onto any folder at all). Container engines
|
|
1056
|
+
// run per user (Docker Desktop and the docker CLI's folder whole, so a socket made there later is covered too;
|
|
1057
|
+
// Colima, OrbStack, Lima, Rancher Desktop, rootless containers); a terminal multiplexer takes commands from its
|
|
1058
|
+
// socket; an SSH agent signs as you; editors keep IPC sockets in /tmp (hidden by the build's private /tmp).
|
|
1059
|
+
// Masked whoever owns it (a folder `sudo docker` made is root's): only a link is never followed, since a link turns
|
|
1060
|
+
// a mask onto its target. A link there is a residual until builds can't write the home folder (arc 2).
|
|
1061
|
+
const owned = (path) => {
|
|
1062
|
+
try {
|
|
1063
|
+
const held = lstatSync(path);
|
|
1064
|
+
return !held.isSymbolicLink() && (held.isDirectory() || held.isSocket());
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name))) {
|
|
1071
|
+
if (owned(path))
|
|
1072
|
+
masks.add(path);
|
|
1073
|
+
// A workspace build can't plant a link in the home folder, so a link there is the operator's own: its target is
|
|
1074
|
+
// masked too (an engine moved elsewhere).
|
|
1075
|
+
else if (followLinks) {
|
|
1076
|
+
try {
|
|
1077
|
+
if (lstatSync(path).isSymbolicLink())
|
|
1078
|
+
masks.add(realpathSync(path));
|
|
1079
|
+
}
|
|
1080
|
+
catch { /* dangling */ }
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// An engine Toolroll's own environment points at, wherever it is (never the build's to choose): always masked. So is
|
|
1084
|
+
// the SSH agent it names (it signs as you), which a shared /tmp would otherwise leave in reach.
|
|
1085
|
+
for (const name of ["DOCKER_HOST", "CONTAINER_HOST"]) {
|
|
1086
|
+
const socket = /^unix:\/\/(\/.+)$/.exec(env[name] ?? "")?.[1];
|
|
1087
|
+
if (socket !== undefined) {
|
|
1088
|
+
try {
|
|
1089
|
+
masks.add(realpathSync(socket));
|
|
1090
|
+
}
|
|
1091
|
+
catch { /* not there */ }
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
const agent = env["SSH_AUTH_SOCK"];
|
|
1095
|
+
if (agent !== undefined && isAbsolute(agent)) {
|
|
1096
|
+
try {
|
|
1097
|
+
const socket = realpathSync(agent);
|
|
1098
|
+
masks.add(socket);
|
|
1099
|
+
// The folder an agent makes for its socket (ssh-XXXX in /tmp) too, so a socket it makes next is out of reach.
|
|
1100
|
+
if (inside(socket, real(tmp)) && dirname(socket) !== real(tmp) && !lstatSync(dirname(socket)).isSymbolicLink())
|
|
1101
|
+
masks.add(dirname(socket));
|
|
1102
|
+
}
|
|
1103
|
+
catch { /* not there */ }
|
|
1104
|
+
}
|
|
1105
|
+
// A build that shares /tmp (a workspace build with an open network) reaches none of the sockets there either: every
|
|
1106
|
+
// socket two levels down (a multiplexer's, an agent's, a display's, an editor's), and the folder that holds it, taken
|
|
1107
|
+
// as it is (never through a link). Toolroll's own launch folders are fenced by name already.
|
|
1108
|
+
if (followLinks) {
|
|
1109
|
+
const socketIn = (folder, depth) => {
|
|
1110
|
+
let found = false;
|
|
1111
|
+
let entries = [];
|
|
1112
|
+
try {
|
|
1113
|
+
entries = readdirSync(folder);
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
for (const entry of entries) {
|
|
1119
|
+
const path = join(folder, entry);
|
|
1120
|
+
if (RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix)))
|
|
1121
|
+
continue;
|
|
1122
|
+
let held;
|
|
1123
|
+
try {
|
|
1124
|
+
held = lstatSync(path);
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
if (held.isSocket()) {
|
|
1130
|
+
masks.add(path);
|
|
1131
|
+
found = true;
|
|
1132
|
+
}
|
|
1133
|
+
else if (held.isDirectory() && !held.isSymbolicLink() && depth > 0 && socketIn(path, depth - 1)) {
|
|
1134
|
+
masks.add(path);
|
|
1135
|
+
found = true;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return found;
|
|
1139
|
+
};
|
|
1140
|
+
socketIn(tmp, 1);
|
|
1141
|
+
}
|
|
1142
|
+
return [...masks].sort();
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* What each agent's fence gives it, by permission mode: the one table README's "What an agent can reach" shows
|
|
1146
|
+
* (fenceSummaryMarkdown; a test holds them equal).
|
|
1147
|
+
*/
|
|
1148
|
+
export const FENCE_SUMMARY = [
|
|
1149
|
+
{
|
|
1150
|
+
agent: "**Codex / OpenRouter** (any OS)",
|
|
1151
|
+
auto: "Codex's sandbox with the fence: writes only the checkout, its Git data and the project's caches (anywhere outside the fence, under a project set to write anywhere); no network",
|
|
1152
|
+
full: "the same writes, with the fence; its commands reach the network only when the project's network is open",
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
agent: "**Claude, Gemini, ACP agents**",
|
|
1156
|
+
auto: "macOS: inside a Seatbelt sandbox that denies the fence and writes only the checkout, its Git data, the project's caches, the run's temp folder and the agent's own session files. Linux: the same in bubblewrap, with a /tmp of its own. Neither (Windows, or Linux without bubblewrap): the build doesn't start, unless an instance operator allows unconfined agents",
|
|
1157
|
+
full: "same",
|
|
1158
|
+
},
|
|
1159
|
+
];
|
|
1160
|
+
/** FENCE_SUMMARY as README's markdown table. */
|
|
1161
|
+
export function fenceSummaryMarkdown() {
|
|
1162
|
+
return ["| | Auto | Full access |", "|---|---|---|", ...FENCE_SUMMARY.map(row => `| ${row.agent} | ${row.auto} | ${row.full} |`)].join("\n");
|
|
736
1163
|
}
|
package/dist/agent-pulse.js
CHANGED
|
@@ -18,7 +18,7 @@ import { closeSync, constants, fstatSync, openSync, readSync, statSync, unlinkSy
|
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { ingestStatus, markStuck, noteDiff, noteTranscript, NOTE_MAX, pageQuestions, QUESTION_KEY, QUESTION_MAX, QUESTIONS_OPEN_MAX, QUESTIONS_PER_ATTEMPT } from "./agent-board.js";
|
|
20
20
|
import { readVerifiedArtifact } from "./evidence.js";
|
|
21
|
-
import { liveLogPath, sanitizeTranscriptLine } from "./live.js";
|
|
21
|
+
import { legacyLiveLogPath, liveLogPath, sanitizeTranscriptLine } from "./live.js";
|
|
22
22
|
import { observeWorktree, parseBaseTreeSnapshot, PEEK_LIMITS } from "./peek.js";
|
|
23
23
|
/** The whole file may grow this far; past it, nothing more is read. */
|
|
24
24
|
export const STATUS_FILE_CAP = 256 * 1024;
|
|
@@ -168,7 +168,14 @@ export async function changedFileCount(worktree, base) {
|
|
|
168
168
|
*/
|
|
169
169
|
export function pulseAgentBoard(store, state, now) {
|
|
170
170
|
try {
|
|
171
|
-
|
|
171
|
+
// The records file every run writes now, else a legacy text file.
|
|
172
|
+
let bytes;
|
|
173
|
+
try {
|
|
174
|
+
bytes = statSync(liveLogPath(state.root, state.run)).size;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
bytes = statSync(legacyLiveLogPath(state.root, state.run)).size;
|
|
178
|
+
}
|
|
172
179
|
noteTranscript(store, state.run, state.attemptKey, bytes, now);
|
|
173
180
|
}
|
|
174
181
|
catch { /* no live log: the activity row still counts */ }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent states' words, tones and counts (docs/plans/agents-cockpit.md §1), with nothing the browser can't load:
|
|
3
|
+
* agent-state.ts derives the states; the console's pages and the sidebar say them with these.
|
|
4
|
+
*/
|
|
5
|
+
/** The display states, strongest first: a roll-up takes the first one any member has. */
|
|
6
|
+
export declare const DISPLAY_STATES: readonly ["needs-you", "failed", "stuck", "paused", "working", "waiting", "done", "idle"];
|
|
7
|
+
export type DisplayState = (typeof DISPLAY_STATES)[number];
|
|
8
|
+
export declare const STATE_WORDS: Record<DisplayState, string>;
|
|
9
|
+
/** For someone who may not act on it: the obligation is still there, and still counts. */
|
|
10
|
+
export declare const NEEDS_SOMEONE = "Needs someone";
|
|
11
|
+
/** Each state's tone, for the console's one palette (attention, problem, live, ready, done, muted). */
|
|
12
|
+
export declare const STATE_TONE: Record<DisplayState, "attention" | "problem" | "live" | "ready" | "done" | "muted">;
|
|
13
|
+
export type StateCounts = Record<DisplayState, number>;
|
|
14
|
+
export declare const NO_COUNTS: StateCounts;
|
|
15
|
+
/** The strongest state among counts, and the counts themselves. Nothing counted is idle. */
|
|
16
|
+
export declare function rollUp(counts: StateCounts): {
|
|
17
|
+
state: DisplayState;
|
|
18
|
+
counts: StateCounts;
|
|
19
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent states' words, tones and counts (docs/plans/agents-cockpit.md §1), with nothing the browser can't load:
|
|
3
|
+
* agent-state.ts derives the states; the console's pages and the sidebar say them with these.
|
|
4
|
+
*/
|
|
5
|
+
/** The display states, strongest first: a roll-up takes the first one any member has. */
|
|
6
|
+
export const DISPLAY_STATES = ["needs-you", "failed", "stuck", "paused", "working", "waiting", "done", "idle"];
|
|
7
|
+
export const STATE_WORDS = {
|
|
8
|
+
"needs-you": "Needs you", failed: "Failed", stuck: "Stuck", paused: "Paused", working: "Working", waiting: "Waiting", done: "Done", idle: "Idle",
|
|
9
|
+
};
|
|
10
|
+
/** For someone who may not act on it: the obligation is still there, and still counts. */
|
|
11
|
+
export const NEEDS_SOMEONE = "Needs someone";
|
|
12
|
+
/** Each state's tone, for the console's one palette (attention, problem, live, ready, done, muted). */
|
|
13
|
+
export const STATE_TONE = {
|
|
14
|
+
"needs-you": "attention", failed: "problem", stuck: "problem", paused: "muted", working: "live", waiting: "muted", done: "ready", idle: "muted",
|
|
15
|
+
};
|
|
16
|
+
export const NO_COUNTS = Object.freeze({ "needs-you": 0, failed: 0, stuck: 0, paused: 0, working: 0, waiting: 0, done: 0, idle: 0 });
|
|
17
|
+
/** The strongest state among counts, and the counts themselves. Nothing counted is idle. */
|
|
18
|
+
export function rollUp(counts) {
|
|
19
|
+
return { state: DISPLAY_STATES.find(state => state !== "idle" && counts[state] > 0) ?? "idle", counts };
|
|
20
|
+
}
|